Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b88bad32d | ||
|
|
4c9efda9ca | ||
|
|
0c8ec73722 | ||
|
|
02db836003 | ||
|
|
46774bd114 | ||
|
|
ba1d6c4bd0 | ||
|
|
5a26c8c788 | ||
|
|
cc39043a68 | ||
|
|
93f22dfc54 | ||
|
|
376b39e247 | ||
|
|
6c32b64ac6 | ||
|
|
7cb10f5aa1 | ||
|
|
4bede171fa | ||
|
|
f72bc33871 | ||
|
|
bc27d7cded | ||
|
|
7b54c109f0 | ||
|
|
229c341d7a | ||
|
|
985b3af2e0 | ||
|
|
fc78b3bb43 | ||
|
|
af32669b32 | ||
|
|
e9505e24a0 | ||
|
|
d25bc72623 | ||
|
|
2c9f5c45f8 | ||
|
|
b5b92b947c | ||
|
|
7ae4766771 | ||
|
|
895ebb197d | ||
|
|
39b58c65ca | ||
|
|
b5f1783050 | ||
|
|
e6c9ad9470 | ||
|
|
3eb32145aa | ||
|
|
f3542f711b |
@@ -39,7 +39,7 @@ Sign-up on [Litlyx cloud](https://dashboard.litlyx.com) using OAuth & name your
|
||||
<script defer data-project="project_id_here" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
|
||||
```
|
||||
|
||||
Importing Litlyx with a direct script already tracks 10 KPIs such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Avarage Session Time`.
|
||||
Importing Litlyx with a direct script already tracks 10 KPIs such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Average Session Time`.
|
||||
|
||||
# All Javascript Runtimes
|
||||
|
||||
@@ -69,7 +69,7 @@ Once imported, you need to initialize Litlyx:
|
||||
Lit.init('your_project_id');
|
||||
```
|
||||
|
||||
After initialization, Litlyx will automatically track Analytics such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Avarage Session Time`.
|
||||
After initialization, Litlyx will automatically track Analytics such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Average Session Time`.
|
||||
|
||||
# Custom Events
|
||||
|
||||
|
||||
@@ -97,6 +97,11 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
|
||||
|
||||
const { pid, instant, flowHash } = data;
|
||||
|
||||
const existingSession = await SessionModel.findOne({ project_id: pid }, { _id: 1 });
|
||||
if (!existingSession) {
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
||||
}
|
||||
|
||||
if (instant == "true") {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 0 },
|
||||
|
||||
@@ -6,15 +6,37 @@ Lit.init('6643cd08a1854e3b81722ab5');
|
||||
|
||||
const debugMode = process.dev;
|
||||
|
||||
const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDialog();
|
||||
const { alerts, closeAlert } = useAlert();
|
||||
|
||||
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-dvw h-dvh bg-[#151517] relative">
|
||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
||||
|
||||
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
||||
<div v-for="alert of alerts"
|
||||
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||
<div class="flex items-start gap-4">
|
||||
<div> <i :class="alert.icon"></i> </div>
|
||||
<div class="grow">
|
||||
<div class="poppins font-semibold">{{ alert.title }}</div>
|
||||
<div class="poppins">
|
||||
{{ alert.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<i @click="closeAlert(alert.id)" class="fas fa-close hover:text-[#CCCCCC] cursor-pointer"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="`width: ${Math.floor(100 / alert.ms * alert.remaining)}%; ${alert.transitionStyle}`"
|
||||
class="absolute bottom-0 left-0 h-1 bg-lyx-primary z-100 alert-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="debugMode"
|
||||
class="absolute bottom-8 left-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
|
||||
class="absolute bottom-8 right-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
|
||||
<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>
|
||||
@@ -24,9 +46,9 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
|
||||
</div>
|
||||
|
||||
<div v-if="showDialog"
|
||||
class="custom-dialog flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 w-full h-full z-[100] backdrop-blur-[2px] bg-black/50">
|
||||
<div class="bg-menu w-full h-full rounded-xl relative">
|
||||
<div class="flex justify-end absolute z-[100] right-8 top-8">
|
||||
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] bg-black/50">
|
||||
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
|
||||
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
|
||||
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full h-full p-4">
|
||||
@@ -41,3 +63,4 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
|
||||
@@ -80,6 +80,7 @@ const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, op
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${props.color}22`;
|
||||
@@ -95,7 +96,6 @@ onMounted(async () => {
|
||||
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
watch(props, () => {
|
||||
console.log('UPDATE')
|
||||
chartData.value.labels = props.labels;
|
||||
chartData.value.datasets[0].data = props.data;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TProject } from '@schema/ProjectSchema';
|
||||
import CreateSnapshot from './dialog/CreateSnapshot.vue';
|
||||
|
||||
export type Entry = {
|
||||
label: string,
|
||||
@@ -8,7 +10,8 @@ export type Entry = {
|
||||
icon?: string,
|
||||
action?: () => any,
|
||||
adminOnly?: boolean,
|
||||
external?: boolean
|
||||
external?: boolean,
|
||||
grow?: boolean
|
||||
}
|
||||
|
||||
export type Section = {
|
||||
@@ -29,20 +32,127 @@ const debugMode = process.dev;
|
||||
|
||||
const { isOpen, close } = useMenu();
|
||||
|
||||
const { snapshots, snapshot, updateSnapshots } = useSnapshot();
|
||||
|
||||
const snapshotsItems = computed(() => {
|
||||
if (!snapshots.value) return []
|
||||
return snapshots.value as any[];
|
||||
})
|
||||
|
||||
|
||||
const { openDialogEx } = useCustomDialog();
|
||||
|
||||
function openSnapshotDialog() {
|
||||
openDialogEx(CreateSnapshot, {
|
||||
width: "24rem",
|
||||
height: "16rem",
|
||||
closable: false
|
||||
});
|
||||
}
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function deleteSnapshot(close: () => any) {
|
||||
await $fetch("/api/snapshot/delete", {
|
||||
method: 'DELETE',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
id: snapshot.value._id.toString(),
|
||||
})
|
||||
});
|
||||
await updateSnapshots();
|
||||
snapshot.value = snapshots.value[1];
|
||||
createAlert('Snapshot deleted', 'Snapshot deleted successfully', 'far fa-circle-check', 5000);
|
||||
close();
|
||||
}
|
||||
|
||||
async function generatePDF() {
|
||||
|
||||
try {
|
||||
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
||||
...signHeaders(),
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(res);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Report.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { setToken } = useAccessToken();
|
||||
const router = useRouter();
|
||||
|
||||
function onLogout() {
|
||||
console.log('LOGOUT')
|
||||
setToken('');
|
||||
setLoggedUser(undefined);
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
const { projects } = useProjectsList();
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
|
||||
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
headers: computed(() => {
|
||||
return {
|
||||
Authorization: authorizationHeaderComputed.value
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const selected = ref<TProject>(activeProject.value as TProject);
|
||||
watch(selected, () => {
|
||||
setActiveProject(selected.value._id.toString())
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="CVerticalNavigation h-full w-[20rem] bg-[#111111] flex shadow-[1px_0_10px_#000000] rounded-r-lg" :class="{
|
||||
<div class="CVerticalNavigation h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
|
||||
:class="{
|
||||
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
|
||||
'hidden lg:flex': !isOpen
|
||||
}">
|
||||
<div class="p-4 gap-6 flex flex-col w-full">
|
||||
<div class="py-4 px-2 gap-6 flex flex-col w-full">
|
||||
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<div class="bg-black h-[2.4rem] aspect-[1/1] flex items-center justify-center rounded-lg">
|
||||
<img class="h-[2rem]" :src="'/logo.png'">
|
||||
<div class="flex px-2 flex-col">
|
||||
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-if="projects" v-model="selected" :options="projects">
|
||||
|
||||
<template #option="{ option, active, selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div>
|
||||
<div> {{ option.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ activeProject?.name || '???' }} </div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
|
||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
||||
<i @click="close()" class="fas fa-close"></i>
|
||||
@@ -50,21 +160,110 @@ const { isOpen, close } = useMenu();
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
|
||||
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
|
||||
<div><i class="fas fa-plus"></i></div>
|
||||
<div> Create new project </div>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex-col px-2">
|
||||
|
||||
<div class="flex mb-2 items-center justify-between">
|
||||
<div class="poppins text-[.8rem]">
|
||||
Snapshots
|
||||
</div>
|
||||
<div @click="openSnapshotDialog()"
|
||||
class="poppins text-[.8rem] px-2 rounded-lg outline outline-[2px] outline-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget-lighter">
|
||||
<i class="far fa-plus"></i>
|
||||
Add
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
|
||||
</div>
|
||||
<div class="poppins"> {{ snapshot?.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
|
||||
</div>
|
||||
<div class="poppins"> {{ option.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
|
||||
<div v-if="snapshot" class="flex flex-col text-[.8rem] mt-2">
|
||||
<div class="flex">
|
||||
<div class="grow poppins"> From:</div>
|
||||
<div class="poppins"> {{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="grow poppins"> To:</div>
|
||||
<div class="poppins"> {{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiButton @click="generatePDF()" type="secondary" class="w-full text-center mt-4">
|
||||
Download report
|
||||
</LyxUiButton>
|
||||
|
||||
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
|
||||
<UPopover placement="bottom">
|
||||
<LyxUiButton type="danger" class="w-full text-center">
|
||||
Delete current snapshot
|
||||
</LyxUiButton>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<div class="p-4 bg-lyx-widget">
|
||||
<div class="poppins text-center font-medium">
|
||||
Are you sure?
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<LyxUiButton @click="close()" type="secondary"> Cancel </LyxUiButton>
|
||||
<LyxUiButton type="danger" @click="deleteSnapshot(close)"> Delete </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-lyx-widget-lighter h-[2px] w-full"></div>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<div v-for="section of sections" class="flex flex-col gap-1">
|
||||
|
||||
<div v-for="entry of section.entries">
|
||||
|
||||
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
||||
class="bg-[#111111] text-gray-300 hover:bg-[#1b1b1b] py-2 px-4 rounded-lg" :class="{
|
||||
'text-gray-700 pointer-events-none': entry.disabled,
|
||||
'bg-[#1b1b1b]': route.path == (entry.to || '#')
|
||||
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||
:class="{
|
||||
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
||||
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
||||
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
|
||||
}">
|
||||
|
||||
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
|
||||
tag="div" class="flex" :to="entry.to || '/'">
|
||||
<div class="flex items-center w-[1.8rem] justify-start">
|
||||
tag="div" class="w-full flex items-center" :to="entry.to || '/'">
|
||||
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
|
||||
<i :class="entry.icon"></i>
|
||||
</div>
|
||||
<div class="manrope">
|
||||
@@ -78,6 +277,45 @@ const { isOpen, close } = useMenu();
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
<div class="text-lyx-text-dark poppins text-[.8rem] px-4 pb-3">
|
||||
Litlyx is in Beta version.
|
||||
</div>
|
||||
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
|
||||
<div class="flex justify-end px-2">
|
||||
|
||||
<div class="grow flex gap-3">
|
||||
<NuxtLink to="https://github.com/litlyx/litlyx" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-github"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-discord"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://x.com/litlyx" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-x-twitter"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://dev.to/litlyx-org" target="_blank"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fab fa-dev"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin" v-if="isAdmin"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fas fa-cat"></i>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
||||
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
|
||||
</div>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,10 +323,6 @@ const { isOpen, close } = useMenu();
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.CVerticalNavigation * {
|
||||
font-family: 'Geist';
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<LyxUiCard>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
@@ -23,5 +23,5 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
28
dashboard/components/CustomTab.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type CItem = { label: string, slot: string }
|
||||
const props = defineProps<{ items: CItem[] }>();
|
||||
|
||||
const activeTabIndex = ref<number>(0);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex">
|
||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
||||
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
||||
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||
}">
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<slot :name="props.items[activeTabIndex].slot"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
63
dashboard/components/DatePicker.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<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>
|
||||
@@ -50,12 +50,7 @@ function openExternalLink(link: string) {
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<div class="text-text flex flex-col items-start gap-4 w-full relative">
|
||||
|
||||
<div class="w-full h-full p-4 flex flex-col bg-card rounded-xl gap-8 card-shadow">
|
||||
|
||||
<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">
|
||||
@@ -121,8 +116,7 @@ function openExternalLink(link: string) {
|
||||
|
||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
||||
</div>
|
||||
<span
|
||||
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
||||
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -132,8 +126,7 @@ function openExternalLink(link: string) {
|
||||
<div class="text-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-bold text-[1.1rem]">
|
||||
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
||||
No visits yet
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,15 +140,9 @@ function openExternalLink(link: string) {
|
||||
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { BrowsersAggregated } from '~/server/api/metrics/[project_id]/data/browsers';
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const browsersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
dialogBarData.value = browsersData.data.value || [];
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
browsersData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
|
||||
desc="The browsers most used to search your website." :dataIcons="false" :loading="pending"
|
||||
label="Top Browsers" sub-label="Browsers"></DashboardBarsCard>
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="browsersData.refresh()"
|
||||
:data="browsersData.data.value || []" desc="The browsers most used to search your website."
|
||||
:dataIcons="false" :loading="browsersData.pending.value" label="Top Browsers" sub-label="Browsers">
|
||||
</DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,8 +16,8 @@ const props = defineProps<{
|
||||
|
||||
<template>
|
||||
|
||||
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||
<div class="flex p-4 items-start">
|
||||
<LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||
<div v-if="ready" class="flex p-4 items-start">
|
||||
<div class="flex items-center mt-2 mr-4">
|
||||
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
|
||||
</div>
|
||||
@@ -40,32 +40,16 @@ const props = defineProps<{
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0">
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||
v-if="((props.data?.length || 0) > 0) && ready">
|
||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
||||
:color="props.color">
|
||||
</DashboardEmbedChartCard>
|
||||
</div>
|
||||
</Card>
|
||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
<!-- <div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
||||
|
||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
||||
:style="`background: ${props.color}`">
|
||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
||||
</div>
|
||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center lg:items-end">
|
||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
||||
</div>
|
||||
|
||||
|
||||
</div> -->
|
||||
|
||||
</template>
|
||||
@@ -1,39 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { DevicesAggregated } from '~/server/api/metrics/[project_id]/data/devices';
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const devicesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/devices`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
dialogBarData.value = devicesData.data.value || [];
|
||||
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
devicesData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" :dataIcons="false"
|
||||
desc="The devices most used to access your website." :loading="pending" label="Top Devices"
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []" :dataIcons="false"
|
||||
desc="The devices most used to access your website." :loading="devicesData.pending.value" label="Top Devices"
|
||||
sub-label="Devices"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/visits/events';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/events');
|
||||
}
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
dialogBarData.value = eventsData.data.value || [];
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
eventsData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -39,7 +44,8 @@ function showMore() {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()"
|
||||
desc="Most frequent user events triggered in this project" @dataReload="refresh" :data="events || []"
|
||||
:loading="pending" label="Top Events" sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
|
||||
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()"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
|
||||
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie';
|
||||
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/data/events';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
@@ -20,15 +20,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false },
|
||||
},
|
||||
// r: {
|
||||
// ticks: { display: false },
|
||||
// grid: {
|
||||
// display: true,
|
||||
// drawBorder: false,
|
||||
// color: '#CCCCCC22',
|
||||
// borderDash: [20, 8]
|
||||
// },
|
||||
// }
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -65,15 +56,18 @@ const chartData = ref<ChartData<'doughnut'>>({
|
||||
|
||||
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
onMounted(async () => {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const activeProject = useActiveProject()
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
const eventsData = await $fetch<EventsPie[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
|
||||
chartData.value.labels = eventsData.map(e => {
|
||||
|
||||
|
||||
function transformResponse(input: CustomEventsAggregated[]) {
|
||||
|
||||
chartData.value.labels = input.map(e => {
|
||||
return `${e._id}`;
|
||||
});
|
||||
chartData.value.datasets[0].data = eventsData.map(e => e.count);
|
||||
chartData.value.datasets[0].data = input.map(e => e.count);
|
||||
doughnutChartRef.value?.update();
|
||||
|
||||
if (window.innerWidth < 800) {
|
||||
@@ -81,11 +75,34 @@ onMounted(async () => {
|
||||
chartOptions.value.plugins.legend.display = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: "10"
|
||||
}
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false,transform:transformResponse
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
eventsData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<DoughnutChart v-bind="doughnutChartProps"> </DoughnutChart>
|
||||
<div>
|
||||
<div v-if="eventsData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<DoughnutChart v-if="!eventsData.pending.value" v-bind="doughnutChartProps"> </DoughnutChart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { CountriesAggregated } from '~/server/api/metrics/[project_id]/data/countries';
|
||||
import type { IconProvider } from './BarsCard.vue';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: countries, pending, refresh } = await useFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
||||
return [
|
||||
@@ -19,31 +12,51 @@ function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
|
||||
const customIconStyle = `width: 2rem; padding: 1px;`
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const geolocationData = useFetch(`/api/metrics/${activeProject.value?._id}/data/countries`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
dialogBarData.value = geolocationData.data.value?.map(e => {
|
||||
return { ...e, icon: iconProvider(e._id) }
|
||||
}) || [];
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
geolocationData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="countries || []" :dataIcons="false"
|
||||
:loading="pending" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="geolocationData.refresh()" :data="geolocationData.data.value || []" :dataIcons="false"
|
||||
:loading="geolocationData.pending.value" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
|
||||
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
|
||||
</DashboardBarsCard>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { OssAggregated } from '~/server/api/metrics/[project_id]/data/oss';
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const ossData = useFetch(`/api/metrics/${activeProject.value?._id}/data/oss`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
dialogBarData.value = ossData.data.value || [];
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ossData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
|
||||
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
||||
:loading="pending" label="Top OS" sub-label="OSs"></DashboardBarsCard>
|
||||
:loading="ossData.pending.value" label="Top OS" sub-label="OSs"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
|
||||
import type { IconProvider } from './BarsCard.vue';
|
||||
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
|
||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
|
||||
@@ -21,45 +13,56 @@ function elementTextTransformer(element: string) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const referrersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
const customDialog = useCustomDialog();
|
||||
|
||||
function onShowDetails(referrer: string) {
|
||||
|
||||
customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
||||
}
|
||||
|
||||
|
||||
// const customDialog = useCustomDialog();
|
||||
|
||||
// function onShowDetails(referrer: string) {
|
||||
// customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
||||
// }
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
isShowMore.value = true;
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data.map(e => {
|
||||
dialogBarData.value = referrersData.data.value?.map(e => {
|
||||
return { ...e, icon: iconProvider(e._id) }
|
||||
});
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
}) || [];
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
referrersData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showDetails="onShowDetails" @showMore="showMore()"
|
||||
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider" @dataReload="refresh"
|
||||
:showLink=true :data="events || []" :interactive="true" desc="Where users find your website."
|
||||
:dataIcons="true" :loading="pending" label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
|
||||
<DashboardBarsCard @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 Referrers" sub-label="Referrers"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const data = ref<number[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimeline('sessions', props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
ready.value = true;
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
return {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: props.slice
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
sessionsData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#f56523"></AdvancedLineChart>
|
||||
<div v-if="sessionsData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedLineChart v-if="!sessionsData.pending.value" :data="sessionsData.data.value?.data || []"
|
||||
:labels="sessionsData.data.value?.labels || []" color="#f56523"></AdvancedLineChart>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,27 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import DateService from '@services/DateService';
|
||||
import type { Slice } from '@services/DateService';
|
||||
|
||||
const { data: metricsInfo } = useMetricsData();
|
||||
|
||||
const { snapshot, safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const snapshotFrom = computed(() => new Date(snapshot.value?.from || '0').getTime());
|
||||
const snapshotTo = computed(() => new Date(snapshot.value?.to || Date.now()).getTime());
|
||||
|
||||
const snapshotDays = computed(() => {
|
||||
return (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
|
||||
});
|
||||
|
||||
const avgVisitDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.visitsCount / Math.max(days, 1);
|
||||
if (!visitsData.data.value) return '0.00';
|
||||
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgEventsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.eventsCount / Math.max(days, 1);
|
||||
if (!eventsData.data.value) return '0.00';
|
||||
const counts = eventsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgSessionsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1);
|
||||
if (!sessionsData.data.value) return '0.00';
|
||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
@@ -29,92 +39,105 @@ const avgSessionsDay = computed(() => {
|
||||
const avgSessionDuration = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const avg = metricsInfo.value.avgSessionDuration;
|
||||
|
||||
let hours = 0;
|
||||
let minutes = 0;
|
||||
let seconds = 0;
|
||||
seconds += avg * 60;
|
||||
|
||||
while (seconds > 60) {
|
||||
seconds -= 60;
|
||||
minutes += 1;
|
||||
}
|
||||
|
||||
while (minutes > 60) {
|
||||
minutes -= 60;
|
||||
hours += 1;
|
||||
}
|
||||
|
||||
|
||||
while (seconds > 60) { seconds -= 60; minutes += 1; }
|
||||
while (minutes > 60) { minutes -= 60; hours += 1; }
|
||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||
});
|
||||
|
||||
type Data = {
|
||||
data: number[],
|
||||
labels: string[],
|
||||
trend: number,
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
|
||||
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
|
||||
async function loadData(timelineEndpointName: string, target: Data) {
|
||||
|
||||
const response = await useTimeline(timelineEndpointName as any, 'day');
|
||||
if (!response) return;
|
||||
target.data = response.map(e => e.count);
|
||||
target.labels = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, 'day'));
|
||||
|
||||
const pool = [...response.map(e => e.count)];
|
||||
pool.pop();
|
||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
||||
|
||||
const diffPercent: number = (100 / avg * (response.at(-1)?.count || 0)) - 100;
|
||||
|
||||
target.trend = Math.max(Math.min(diffPercent, 99), -99);
|
||||
|
||||
target.ready = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
await loadData('visits', visitsData);
|
||||
await loadData('events', eventsData);
|
||||
await loadData('sessions', sessionsData);
|
||||
await loadData('sessions_duration', sessionsDurationData);
|
||||
|
||||
const chartSlice = computed(() => {
|
||||
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 30) return 'day' as Slice;
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 90) return 'day' as Slice;
|
||||
return 'month' as Slice;
|
||||
});
|
||||
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
|
||||
const pool = [...input.map(e => e.count)];
|
||||
pool.pop();
|
||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
||||
const diffPercent: number = (100 / avg * (input.at(-1)?.count || 0)) - 100;
|
||||
const trend = Math.max(Math.min(diffPercent, 99), -99);
|
||||
return { data, labels, trend }
|
||||
}
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
function getBody() {
|
||||
return JSON.stringify({
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: chartSlice.value
|
||||
});
|
||||
}
|
||||
|
||||
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const sessionsDurationData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions_duration`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
visitsData.execute();
|
||||
eventsData.execute();
|
||||
sessionsData.execute();
|
||||
sessionsDurationData.execute();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4" v-if="metricsInfo">
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
|
||||
|
||||
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
|
||||
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'"
|
||||
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7">
|
||||
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
|
||||
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
|
||||
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
|
||||
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'"
|
||||
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
|
||||
<DashboardCountCard :ready="!eventsData.pending.value" icon="far fa-flag" text="Total custom events"
|
||||
:value="formatNumberK(eventsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgEventsDay) + '/day'" :trend="eventsData.data.value?.trend"
|
||||
:data="eventsData.data.value?.data" :labels="eventsData.data.value?.labels" color="#1e9b86">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
|
||||
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
|
||||
|
||||
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visits sessions"
|
||||
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
|
||||
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="sessionsDurationData.ready" icon="far fa-timer" text="Avg session time"
|
||||
:value="avgSessionDuration" :trend="sessionsDurationData.trend" :data="sessionsDurationData.data"
|
||||
:labels="sessionsDurationData.labels" color="#f56523">
|
||||
|
||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Avg session time"
|
||||
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
||||
color="#f56523">
|
||||
</DashboardCountCard>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,12 @@ onMounted(() => startWatching());
|
||||
onUnmounted(() => stopWatching());
|
||||
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
||||
alert('Copiato !');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,23 +22,23 @@ function copyProjectId() {
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div> {{ onlineUsers }} Online users</div>
|
||||
<div class="poppins font-medium text-[1.2rem]"> {{ onlineUsers }} Online users</div>
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
||||
<div>Project:</div>
|
||||
<div class="text-text/90"> {{ activeProject?.name || 'Loading...' }} </div>
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
|
||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }} </div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
|
||||
<div>Project id:</div>
|
||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-text/90 text-[.9rem] lg:text-2xl">
|
||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]">
|
||||
{{ activeProject?._id || 'Loading...' }}
|
||||
</div>
|
||||
<div class="flex items-center ml-3">
|
||||
<i @click="copyProjectId()" class="far fa-copy hover:text-text cursor-pointer text-[1.2rem]"></i>
|
||||
<i @click="copyProjectId()" class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,29 +2,45 @@
|
||||
import { onMounted } from 'vue';
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const data = ref<number[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimeline('visits', props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
ready.value = true;
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
return {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: props.slice
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
visitsData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#5655d7"></AdvancedLineChart>
|
||||
<div v-if="visitsData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedLineChart v-if="!visitsData.pending.value" :data="visitsData.data.value?.data || []"
|
||||
:labels="visitsData.data.value?.labels || []" color="#5655d7">
|
||||
</AdvancedLineChart>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,29 +2,57 @@
|
||||
|
||||
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
|
||||
|
||||
const { data: websites, pending, refresh } = useWebsitesData();
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value);
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = websites.value;
|
||||
})
|
||||
const isShowMore = ref<boolean>(false);
|
||||
|
||||
const currentWebsite = ref<string>("");
|
||||
|
||||
const websitesHeaders = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10'
|
||||
}
|
||||
});
|
||||
|
||||
const pagesHeaders = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: isShowMore.value === true ? '200' : '10',
|
||||
'x-website-name': currentWebsite.value
|
||||
}
|
||||
});
|
||||
|
||||
const websitesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/websites`, {
|
||||
method: 'POST', headers: websitesHeaders, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const pagesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
||||
method: 'POST', headers: pagesHeaders, lazy: true, immediate: false
|
||||
});
|
||||
|
||||
const isPagesView = ref<boolean>(false);
|
||||
const isLoading = ref<boolean>(false);
|
||||
|
||||
async function showDetails(website: string) {
|
||||
if (isPagesView.value == true) return;
|
||||
isLoading.value = true;
|
||||
isPagesView.value = true;
|
||||
|
||||
const { data: pagesData, pending } = usePagesData(website, 10);
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = pagesData.value;
|
||||
isLoading.value = false;
|
||||
const currentData = computed(() => {
|
||||
return isPagesView.value ? pagesData : websitesData
|
||||
})
|
||||
|
||||
|
||||
async function showDetails(website: string) {
|
||||
currentWebsite.value = website;
|
||||
pagesData.execute();
|
||||
isPagesView.value = true;
|
||||
}
|
||||
|
||||
async function showGeneral() {
|
||||
websitesData.execute();
|
||||
isPagesView.value = false;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
@@ -33,26 +61,18 @@ function goToView() {
|
||||
router.push('/dashboard/visits');
|
||||
}
|
||||
|
||||
function setDefaultData() {
|
||||
currentViewData.value = websites.value;
|
||||
isPagesView.value = false;
|
||||
}
|
||||
|
||||
async function dataReload() {
|
||||
await refresh();
|
||||
setDefaultData();
|
||||
}
|
||||
|
||||
|
||||
onMounted(()=>{
|
||||
websitesData.execute();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
|
||||
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
|
||||
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
|
||||
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
|
||||
:loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
||||
:sub-label="isPagesView ? 'Page' : 'Website'"
|
||||
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
|
||||
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
|
||||
|
||||
116
dashboard/components/dialog/CreateSnapshot.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { closeDialog } = useCustomDialog();
|
||||
|
||||
import { sub, format, isSameDay, type Duration } from 'date-fns'
|
||||
|
||||
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() })
|
||||
|
||||
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 currentColor = ref<string>("#5680F8");
|
||||
|
||||
const colorpicker = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function showColorPicker() {
|
||||
colorpicker.value?.click();
|
||||
}
|
||||
|
||||
function onColorChange() {
|
||||
currentColor.value = colorpicker.value?.value || '#000000';
|
||||
}
|
||||
|
||||
const snapshotName = ref<string>("");
|
||||
|
||||
const { updateSnapshots } = useSnapshot();
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function confirmSnapshot() {
|
||||
await $fetch("/api/snapshot/create", {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
name: snapshotName.value,
|
||||
color: currentColor.value,
|
||||
from: selected.value.start.toISOString(),
|
||||
to: selected.value.end.toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
await updateSnapshots();
|
||||
closeDialog();
|
||||
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
|
||||
<div class="poppins text-center">
|
||||
Create a snapshot
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex items-center gap-2">
|
||||
<div :style="`background-color: ${currentColor};`" @click="showColorPicker"
|
||||
class="w-6 h-6 rounded-full aspect-[1/1] relative cursor-pointer">
|
||||
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
|
||||
</div>
|
||||
<div class="grow">
|
||||
<LyxUiInput placeholder="Snapshot name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 justify-center flex w-full">
|
||||
|
||||
<UPopover class="w-full" :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="grow"></div>
|
||||
<div class="flex items-center justify-around gap-4">
|
||||
<LyxUiButton @click="closeDialog()" type="secondary" class="w-full text-center">
|
||||
Cancel
|
||||
</LyxUiButton>
|
||||
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
|
||||
:disabled="snapshotName.length == 0">
|
||||
Confirm
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,11 +26,26 @@ async function getMetadataFields() {
|
||||
currentSearchText.value = "";
|
||||
}
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
async function getMetadataFieldGrouped() {
|
||||
if (!selectedMetadataField.value) return;
|
||||
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?name=${selectedEventName.value}&field=${selectedMetadataField.value}`, signHeaders());
|
||||
|
||||
|
||||
const queryParams: Record<string, any> = {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
name: selectedEventName.value,
|
||||
field: selectedMetadataField.value
|
||||
}
|
||||
|
||||
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
|
||||
|
||||
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?${queryParamsString}`, signHeaders());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const metadataFieldGroupedFiltered = computed(() => {
|
||||
if (currentSearchText.value.length == 0) return metadataFieldGrouped.value;
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Slice } from '@services/DateService';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const datasets = ref<any[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: SliceName }>();
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
const slice = computed(() => props.slice);
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimelineDataRaw('events_stacked', props.slice);
|
||||
if (!response) return;
|
||||
const activeProject = useActiveProject();
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
const fixed = fixMetrics(response, props.slice, { advanced: true, advancedGroupKey: 'name' });
|
||||
const body = computed(() => {
|
||||
return {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
slice: slice.value,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function transformResponse(input: { _id: string, name: string, count: number }[]) {
|
||||
|
||||
const fixed = fixMetrics({
|
||||
data: input,
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to
|
||||
}, slice.value, {
|
||||
advanced: true,
|
||||
advancedGroupKey: 'name'
|
||||
});
|
||||
|
||||
const parsedDatasets: any[] = [];
|
||||
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
|
||||
@@ -28,25 +44,32 @@ async function loadData() {
|
||||
if (!target) return;
|
||||
line.data.push(target.value);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
datasets.value = parsedDatasets;
|
||||
labels.value = fixed.labels;
|
||||
ready.value = true;
|
||||
|
||||
return {
|
||||
datasets: parsedDatasets,
|
||||
labels: fixed.labels
|
||||
}
|
||||
}
|
||||
|
||||
const eventsStackedData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events_stacked`, {
|
||||
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse, ...signHeaders()
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
eventsStackedData.execute();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedStackedBarChart v-if="ready" :datasets="datasets" :labels="labels">
|
||||
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value" :datasets="eventsStackedData.data.value?.datasets || []"
|
||||
:labels="eventsStackedData.data.value?.labels || []">
|
||||
</AdvancedStackedBarChart>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,13 +12,28 @@ onMounted(async () => {
|
||||
const userFlowData = ref<any>();
|
||||
const analyzing = ref<boolean>(false);
|
||||
|
||||
async function analyzeEvent() {
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
async function getUserFlowData() {
|
||||
userFlowData.value = undefined;
|
||||
analyzing.value = true;
|
||||
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?name=${selectedEventName.value}`, signHeaders());
|
||||
|
||||
const queryParams: Record<string, any> = {
|
||||
from: safeSnapshotDates.value.from,
|
||||
to: safeSnapshotDates.value.to,
|
||||
name: selectedEventName.value
|
||||
}
|
||||
|
||||
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
|
||||
|
||||
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?${queryParamsString}`, signHeaders());
|
||||
analyzing.value = false;
|
||||
}
|
||||
|
||||
async function analyzeEvent() {
|
||||
getUserFlowData();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -41,13 +56,15 @@ async function analyzeEvent() {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2" v-if="userFlowData">
|
||||
<div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg" v-for="(count, referrer) in userFlowData">
|
||||
<div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg"
|
||||
v-for="(count, referrer) in userFlowData">
|
||||
<div class="w-5 h-5 flex items-center justify-center">
|
||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`" :alt="'referrer'">
|
||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
|
||||
:alt="'referrer'">
|
||||
</div>
|
||||
<div> {{ referrer }} </div>
|
||||
<div class="grow"></div>
|
||||
<div> {{ count }} </div>
|
||||
<div> {{ count.toFixed(2).replace('.', ',') }} % </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
import { onMounted } from 'vue';
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
const data = ref<number[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
// const data = ref<number[]>([]);
|
||||
// const labels = ref<string[]>([]);
|
||||
// const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: Slice, referrer: string }>();
|
||||
// const props = defineProps<{ slice: Slice, referrer: string }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useReferrersTimeline(props.referrer, props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
ready.value = true;
|
||||
}
|
||||
// async function loadData() {
|
||||
// const response = await useReferrersTimeline(props.referrer, props.slice);
|
||||
// if (!response) return;
|
||||
// data.value = response.map(e => e.count);
|
||||
// labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
// ready.value = true;
|
||||
// }
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
// onMounted(async () => {
|
||||
// await loadData();
|
||||
// watch(props, async () => { await loadData(); });
|
||||
// })
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
|
||||
</AdvancedBarChart>
|
||||
<!-- <AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
|
||||
</AdvancedBarChart> -->
|
||||
</div>
|
||||
</template>
|
||||
39
dashboard/components/settings/Account.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
|
||||
]
|
||||
|
||||
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
async function deleteAccount() {
|
||||
const sure = confirm("Are you sure you want to delete this account ?");
|
||||
if (!sure) return;
|
||||
await $fetch("/api/user/delete_account", {
|
||||
...signHeaders(),
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
setToken('');
|
||||
location.href = "/login"
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries">
|
||||
<template #delete>
|
||||
<div
|
||||
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]">
|
||||
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
|
||||
<div @click="deleteAccount()"
|
||||
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]">
|
||||
Delete account
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
101
dashboard/components/settings/General.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'pname', title: 'Name', text: 'Project name' },
|
||||
{ id: 'pid', title: 'Id', text: 'Project id' },
|
||||
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
|
||||
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
|
||||
]
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
||||
|
||||
watch(activeProject, () => {
|
||||
projectNameInputVal.value = activeProject.value?.name || "";
|
||||
})
|
||||
|
||||
const canChange = computed(() => {
|
||||
if (activeProject.value?.name == projectNameInputVal.value) return false;
|
||||
if (projectNameInputVal.value.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
async function changeProjectName() {
|
||||
await $fetch("/api/project/change_name", {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ name: projectNameInputVal.value })
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
if (!activeProject.value) return;
|
||||
const sure = confirm(`Are you sure to delete the project ${activeProject.value.name} ?`);
|
||||
if (!sure) return;
|
||||
|
||||
try {
|
||||
|
||||
await $fetch('/api/project/delete', {
|
||||
method: 'DELETE',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ project_id: activeProject.value._id.toString() })
|
||||
});
|
||||
|
||||
const projectsList = useProjectsList()
|
||||
await projectsList.refresh();
|
||||
|
||||
const firstProjectId = projectsList.data.value?.[0]?._id.toString();
|
||||
if (firstProjectId) {
|
||||
await setActiveProject(firstProjectId);
|
||||
}
|
||||
|
||||
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries" :key="activeProject?.name || 'NONE'">
|
||||
<template #pname>
|
||||
<div class="flex items-center gap-4">
|
||||
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
|
||||
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #pid>
|
||||
<LyxUiCard class="w-full flex items-center">
|
||||
<div class="grow">{{ activeProject?._id.toString() }}</div>
|
||||
<div><i class="far fa-copy"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pscript>
|
||||
<LyxUiCard class="w-full flex items-center">
|
||||
<div class="grow">
|
||||
{{ `
|
||||
<script defer data-project="${activeProject?._id}"
|
||||
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
||||
</div>
|
||||
<div><i class="far fa-copy"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pdelete>
|
||||
<div class="flex justify-end">
|
||||
<LyxUiButton type="danger" @click="deleteProject()">
|
||||
Delete project
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
37
dashboard/components/settings/Template.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
export type SettingsTemplateEntry = {
|
||||
title: string,
|
||||
text: string,
|
||||
id: string
|
||||
}
|
||||
|
||||
type SettingsTemplateProp = {
|
||||
entries: SettingsTemplateEntry[]
|
||||
}
|
||||
|
||||
const props = defineProps<SettingsTemplateProp>();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="mt-10 px-4">
|
||||
<div v-for="(entry, index) of props.entries" class="flex flex-col">
|
||||
<div class="flex">
|
||||
<div class="flex-[2]">
|
||||
<div class="poppins font-medium text-lyx-text">
|
||||
{{ entry.title }}
|
||||
</div>
|
||||
<div class="poppins font-regular text-lyx-text-dark">
|
||||
{{ entry.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-[3]">
|
||||
<slot :name="entry.id"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-widget-lighter w-full my-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
231
dashboard/components/settings/billing.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { data: planData, refresh: planRefresh, pending: planPending } = useFetch('/api/project/plan', {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
const percent = computed(() => {
|
||||
if (!planData.value) return '-';
|
||||
return (100 / planData.value.limit * planData.value.count).toFixed(2) + '%';
|
||||
});
|
||||
|
||||
const color = computed(() => {
|
||||
if (!planData.value) return 'blue';
|
||||
if (planData.value.count >= planData.value.limit) return 'red';
|
||||
return 'blue';
|
||||
});
|
||||
|
||||
const daysLeft = computed(() => {
|
||||
if (!planData.value) return '-';
|
||||
return (-dayjs().diff(planData.value.billing_expire_at, 'days')).toString();
|
||||
});
|
||||
|
||||
const leftPercent = computed(() => {
|
||||
if (!planData.value) return 0;
|
||||
const left = dayjs().diff(planData.value.billing_expire_at, 'days');
|
||||
const total = dayjs(planData.value.billing_start_at).diff(planData.value.billing_expire_at, 'days');
|
||||
const percent = 100 - (100 / total * left);
|
||||
return percent;
|
||||
});
|
||||
|
||||
const prettyExpireDate = computed(() => {
|
||||
if (!planData.value) return '';
|
||||
return dayjs(planData.value.billing_expire_at).format('DD/MM/YYYY');
|
||||
});
|
||||
|
||||
|
||||
const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = useFetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
})
|
||||
|
||||
const showPricingDrawer = ref<boolean>(false);
|
||||
function onPlanUpgradeClick() {
|
||||
showPricingDrawer.value = true;
|
||||
}
|
||||
|
||||
function openInvoice(link: string) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
|
||||
function getPremiumName(type: number) {
|
||||
if (type === 0) return 'FREE';
|
||||
if (type === 1) return 'ACCELERATION';
|
||||
if (type === 2) return 'EXPANSION';
|
||||
return 'CUSTOM';
|
||||
|
||||
}
|
||||
|
||||
|
||||
watch(activeProject, () => {
|
||||
invoicesRefresh();
|
||||
planRefresh();
|
||||
})
|
||||
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
|
||||
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
|
||||
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
|
||||
]
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
|
||||
<Transition name="pdrawer">
|
||||
<PricingDrawer @onCloseClick="showPricingDrawer = false" :currentSub="planData?.premium_type || 0"
|
||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]"
|
||||
v-if=showPricingDrawer>
|
||||
</PricingDrawer>
|
||||
</Transition>
|
||||
|
||||
<div v-if="invoicesPending || planPending"
|
||||
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
<SettingsTemplate v-if="!invoicesPending && !planPending" :entries="entries">
|
||||
<template #plan>
|
||||
<LyxUiCard v-if="planData" class="flex flex-col w-full">
|
||||
<div class="flex flex-col gap-6 px-8 grow">
|
||||
<div class="flex justify-between flex-col sm:flex-row">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-3 items-center">
|
||||
<div class="poppins font-semibold text-[1.1rem]">
|
||||
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
|
||||
</div>
|
||||
<div
|
||||
class="flex lato text-[.7rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
|
||||
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem]">
|
||||
Our free plan for testing the product.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="poppins font-semibold text-[2rem]"> $0 </div>
|
||||
<div class="poppins text-text-sub mt-2"> per month </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins"> Billing period:</div>
|
||||
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
|
||||
<div class="grow w-full md:w-auto">
|
||||
<UProgress color="green" :min="0" :max="100" :value="leftPercent"></UProgress>
|
||||
</div>
|
||||
<div class="poppins"> {{ daysLeft }} days left </div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
Subscription: {{ planData.subscription_status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
|
||||
</div>
|
||||
<div class="flex justify-between px-8 flex-col sm:flex-row">
|
||||
<div class="flex gap-2 text-text-sub text-[.9rem]">
|
||||
<div class="poppins"> Expire date:</div>
|
||||
<div> {{ prettyExpireDate }}</div>
|
||||
</div>
|
||||
<div v-if="!isGuest" @click="onPlanUpgradeClick()"
|
||||
class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]">
|
||||
<div class="poppins"> Upgrade plan </div>
|
||||
<i class="fas fa-arrow-up-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #usage>
|
||||
<LyxUiCard v-if="planData" class="flex flex-col w-full">
|
||||
<div class="flex flex-col gap-6 px-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins font-semibold text-[1.1rem]">
|
||||
Usage
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem]">
|
||||
Check the usage limits of your project.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins"> Usage:</div>
|
||||
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
|
||||
<div class="grow w-full md:w-auto">
|
||||
<UProgress :color="color" :min="0" :max="planData.limit" :value="planData.count">
|
||||
</UProgress>
|
||||
</div>
|
||||
<div class="poppins"> {{ percent }}</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
{{ formatNumberK(planData.count) }} / {{ formatNumberK(planData.limit) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #invoices>
|
||||
|
||||
<CardTitled v-if="!isGuest" title="Invoices"
|
||||
:sub="(invoices && invoices.length == 0) ? 'No invoices yet' : ''" class="p-4 mt-8 w-full">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg"
|
||||
v-for="invoice of invoices">
|
||||
|
||||
<div> <i class="fal fa-file-invoice"></i> </div>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:justify-around md:grow items-center gap-2">
|
||||
<div> {{ new Date(invoice.date).toLocaleString() }} </div>
|
||||
<div> € {{ invoice.cost / 100 }} </div>
|
||||
<div> {{ invoice.id }} </div>
|
||||
<div
|
||||
class="flex items-center lato text-[.8rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
|
||||
{{ invoice.status }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<i @click="openInvoice(invoice.link)"
|
||||
class="far fa-download cursor-pointer hover:text-white/80"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</CardTitled>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pdrawer-enter-active,
|
||||
.pdrawer-leave-active {
|
||||
transition: all .5s ease-in-out;
|
||||
}
|
||||
|
||||
.pdrawer-enter-from,
|
||||
.pdrawer-leave-to {
|
||||
transform: translateX(100%)
|
||||
}
|
||||
|
||||
.pdrawer-enter-to,
|
||||
.pdrawer-leave-from {
|
||||
transform: translateX(0)
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const columns = [
|
||||
{ key: 'me', label: '' },
|
||||
@@ -13,7 +15,7 @@ const columns = [
|
||||
// { key: 'pending', label: 'Pending' },
|
||||
]
|
||||
|
||||
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', signHeaders());
|
||||
const { data: members, refresh: refreshMembers, pending: pendingMembers } = useFetch('/api/project/members/list', signHeaders());
|
||||
|
||||
const showAddMember = ref<boolean>(false);
|
||||
|
||||
@@ -67,32 +69,35 @@ async function addMember() {
|
||||
} catch (ex: any) { }
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
watch(activeProject, () => {
|
||||
refreshMembers();
|
||||
})
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'add', title: 'Add member', text: 'Add new member to project' },
|
||||
{ id: 'members', title: 'Members', text: 'Manage members of current project' },
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home w-full h-full px-10 lg:px-6 overflow-y-auto pb-[12rem] md:pb-0 py-2">
|
||||
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div v-if="!isGuest" @click="showAddMember = !showAddMember;"
|
||||
class="flex items-center gap-2 bg-menu w-fit px-3 py-2 rounded-lg hover:bg-menu/80 cursor-pointer">
|
||||
<i class="fas fa-plus"></i>
|
||||
<div> Add member </div>
|
||||
<SettingsTemplate :entries="entries">
|
||||
<template #add>
|
||||
<div v-if="!isGuest" class="flex flex-col">
|
||||
<div class="flex gap-4 items-center">
|
||||
<LyxUiInput class="px-4 py-1 w-full" placeholder="User email" v-model="addMemberEmail"></LyxUiInput>
|
||||
<LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div v-if="showAddMember" class="flex gap-4 items-center">
|
||||
<input v-model="addMemberEmail" class="focus:outline-none bg-menu px-4 py-1 rounded-lg" type="text"
|
||||
placeholder="user email">
|
||||
<div @click="addMember" class="bg-menu w-fit py-1 px-4 rounded-lg hover:bg-menu/80 cursor-pointer">
|
||||
Add
|
||||
<div class="poppins text-[.8rem] mt-2 text-lyx-text-darker">
|
||||
User should have been registered to Litlyx
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #members>
|
||||
|
||||
|
||||
<UTable :rows="members || []" :columns="columns">
|
||||
<template #me-data="e">
|
||||
@@ -108,9 +113,7 @@ async function addMember() {
|
||||
</template>
|
||||
|
||||
</UTable>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
|
||||
</template>
|
||||
@@ -49,6 +49,11 @@ export function useActiveProject() {
|
||||
|
||||
|
||||
export async function setActiveProject(project_id: string) {
|
||||
changingProject.value = true;
|
||||
await new Promise(e => setTimeout(e, 500));
|
||||
await $fetch<string>(`/api/user/set_active_project?project_id=${project_id}`, signHeaders());
|
||||
await activeProjectId.refresh();
|
||||
changingProject.value = false;
|
||||
}
|
||||
|
||||
export const changingProject = ref<boolean>(false);
|
||||
|
||||
@@ -9,6 +9,11 @@ export function signHeaders(headers?: Record<string, string>) {
|
||||
return { headers: { ...(headers || {}), 'Authorization': 'Bearer ' + token.value } }
|
||||
}
|
||||
|
||||
export const authorizationHeaderComputed = computed(() => {
|
||||
const { token } = useAccessToken()
|
||||
return token.value ? 'Bearer ' + token.value : '';
|
||||
});
|
||||
|
||||
export function useAccessToken() {
|
||||
|
||||
const tokenCookie = useCookie(ACCESS_TOKEN_COOKIE_KEY, { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) })
|
||||
|
||||
43
dashboard/composables/useAlert.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
|
||||
export type Alert = {
|
||||
title: string,
|
||||
text: string,
|
||||
icon: string,
|
||||
ms: number,
|
||||
id: number,
|
||||
remaining: number,
|
||||
transitionStyle: string
|
||||
}
|
||||
|
||||
const alerts = ref<Alert[]>([]);
|
||||
|
||||
const idPool = {
|
||||
id: 0,
|
||||
getId() {
|
||||
return idPool.id++;
|
||||
}
|
||||
}
|
||||
|
||||
function createAlert(title: string, text: string, icon: string, ms: number) {
|
||||
const alert = reactive<Alert>({
|
||||
title, text, icon, ms, id: idPool.getId(), remaining: ms,
|
||||
transitionStyle: 'transition: all 250ms linear;'
|
||||
});
|
||||
alerts.value.push(alert);
|
||||
const timeout = setInterval(() => {
|
||||
alert.remaining -= 250;
|
||||
if (alert.remaining <= 0) {
|
||||
closeAlert(alert.id);
|
||||
clearInterval(timeout);
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function closeAlert(id: number) {
|
||||
alerts.value = alerts.value.filter(e => e.id != id);
|
||||
}
|
||||
|
||||
export function useAlert() {
|
||||
return { alerts, createAlert, closeAlert }
|
||||
}
|
||||
@@ -4,17 +4,42 @@ import type { Component } from "vue";
|
||||
const showDialog = ref<boolean>(false);
|
||||
const dialogParams = ref<any>({});
|
||||
const dialogComponent = ref<Component>();
|
||||
const dialogWidth = ref<string>("100%");
|
||||
const dialogHeight = ref<string>("100%");
|
||||
const dialogClosable = ref<boolean>(true);
|
||||
|
||||
function closeDialog() {
|
||||
showDialog.value = false;
|
||||
}
|
||||
|
||||
export type CustomDialogOptions = {
|
||||
params?: any,
|
||||
width?: string,
|
||||
height?: string,
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
function openDialogEx(component: Component, options?: CustomDialogOptions) {
|
||||
dialogComponent.value = component;
|
||||
dialogParams.value = options?.params || {};
|
||||
showDialog.value = true;
|
||||
dialogWidth.value = options?.width || '100%';
|
||||
dialogHeight.value = options?.height || '100%';
|
||||
dialogClosable.value = options?.closable ?? true;
|
||||
}
|
||||
|
||||
function openDialog(component: Component, params: any) {
|
||||
dialogComponent.value = component;
|
||||
dialogParams.value = params;
|
||||
showDialog.value = true;
|
||||
dialogWidth.value = '100%';
|
||||
dialogHeight.value = '100%';
|
||||
}
|
||||
|
||||
const dialogStyle = computed(() => {
|
||||
return `width: ${dialogWidth.value}; height: ${dialogHeight.value}`;
|
||||
});
|
||||
|
||||
export function useCustomDialog() {
|
||||
return { showDialog, closeDialog, openDialog, dialogParams, dialogComponent };
|
||||
return { showDialog, openDialogEx, closeDialog, openDialog, dialogParams, dialogComponent, dialogStyle, dialogClosable };
|
||||
}
|
||||
80
dashboard/composables/useCustomFetch.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { InternalApi } from 'nitropack';
|
||||
import type { WatchSource, WatchStopHandle } from 'vue';
|
||||
|
||||
|
||||
type NitroFetchRequest = Exclude<keyof InternalApi, `/_${string}` | `/api/_${string}`> | (string & {});
|
||||
|
||||
export type CustomFetchOptions = {
|
||||
watchProps?: WatchSource[],
|
||||
lazy?: boolean,
|
||||
method?: string,
|
||||
getBody?: () => Record<string, any>,
|
||||
watchKey?: string
|
||||
}
|
||||
|
||||
type OnResponseCallback<TData> = (data: Ref<TData | undefined>) => any
|
||||
type OnRequestCallback = () => any
|
||||
|
||||
|
||||
const watchStopHandles: Record<string, WatchStopHandle> = {}
|
||||
|
||||
export function useCustomFetch<T>(url: NitroFetchRequest, getHeaders: () => Record<string, string>, options?: CustomFetchOptions) {
|
||||
|
||||
const pending = ref<boolean>(false);
|
||||
const data = ref<T | undefined>();
|
||||
const error = ref<Error | undefined>();
|
||||
|
||||
let onResponseCallback: OnResponseCallback<T> = () => { }
|
||||
let onRequestCallback: OnRequestCallback = () => { }
|
||||
|
||||
const onResponse = (callback: OnResponseCallback<T>) => {
|
||||
onResponseCallback = callback;
|
||||
}
|
||||
|
||||
const onRequest = (callback: OnRequestCallback) => {
|
||||
onRequestCallback = callback;
|
||||
}
|
||||
|
||||
const execute = async () => {
|
||||
onRequestCallback();
|
||||
pending.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
|
||||
data.value = await $fetch<T>(url, {
|
||||
headers: getHeaders(),
|
||||
method: (options?.method || 'GET') as any,
|
||||
body: options?.getBody ? JSON.stringify(options.getBody()) : undefined
|
||||
});
|
||||
|
||||
onResponseCallback(data);
|
||||
} catch (err) {
|
||||
error.value = err as Error;
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.lazy !== true) {
|
||||
execute();
|
||||
}
|
||||
|
||||
if (options?.watchProps) {
|
||||
|
||||
const watchStop = watch(options.watchProps, () => {
|
||||
execute();
|
||||
});
|
||||
|
||||
const key = options?.watchKey || `${url}`;
|
||||
if (watchStopHandles[key]) watchStopHandles[key]();
|
||||
watchStopHandles[key] = watchStop;
|
||||
|
||||
console.log('Watchers:', Object.keys(watchStopHandles).length);
|
||||
|
||||
|
||||
}
|
||||
|
||||
const refresh = execute;
|
||||
|
||||
return { pending, execute, data, error, refresh, onResponse, onRequest };
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Slice } from "@services/DateService";
|
||||
import DateService from "@services/DateService";
|
||||
import type { MetricsCounts } from "~/server/api/metrics/[project_id]/counts";
|
||||
import type { BrowsersAggregated } from "~/server/api/metrics/[project_id]/data/browsers";
|
||||
import type { CountriesAggregated } from "~/server/api/metrics/[project_id]/data/countries";
|
||||
import type { DevicesAggregated } from "~/server/api/metrics/[project_id]/data/devices";
|
||||
import type { CustomEventsAggregated } from "~/server/api/metrics/[project_id]/data/events";
|
||||
import type { OssAggregated } from "~/server/api/metrics/[project_id]/data/oss";
|
||||
import type { ReferrersAggregated } from "~/server/api/metrics/[project_id]/data/referrers";
|
||||
import type { VisitsWebsiteAggregated } from "~/server/api/metrics/[project_id]/data/websites";
|
||||
import type { MetricsTimeline } from "~/server/api/metrics/[project_id]/timeline/generic";
|
||||
|
||||
@@ -13,86 +19,151 @@ export function useMetricsData() {
|
||||
return metricsInfo;
|
||||
}
|
||||
|
||||
export function useFirstInteractionData() {
|
||||
const activeProject = useActiveProject();
|
||||
const metricsInfo = useFetch<boolean>(`/api/metrics/${activeProject.value?._id}/first_interaction`, signHeaders());
|
||||
return metricsInfo;
|
||||
}
|
||||
|
||||
|
||||
export async function useTimelineAdvanced(endpoint: string, slice: Slice, fromDate?: string, toDate?: string, customBody: Object = {}) {
|
||||
|
||||
const { from, to } = DateService.prepareDateRange(
|
||||
fromDate || DateService.getDefaultRange(slice).from,
|
||||
toDate || DateService.getDefaultRange(slice).to,
|
||||
slice
|
||||
);
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const response = await $fetch(
|
||||
`/api/metrics/${activeProject.value?._id}/timeline/${endpoint}`, {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ slice, from, to, ...customBody })
|
||||
});
|
||||
|
||||
return response as { _id: string, count: number }[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
export async function useTimeline(endpoint: 'visits' | 'sessions' | 'referrers', slice: Slice, fromDate?: string, toDate?: string) {
|
||||
return await useTimelineAdvanced(endpoint, slice, fromDate, toDate, {});
|
||||
}
|
||||
|
||||
export async function useReferrersTimeline(referrer: string, slice: Slice, fromDate?: string, toDate?: string) {
|
||||
return await useTimelineAdvanced('referrers', slice, fromDate, toDate, { referrer });
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function useTimelineDataRaw(timelineEndpointName: string, slice: SliceName) {
|
||||
const activeProject = useActiveProject();
|
||||
// const { safeSnapshotDates, snapshot } = useSnapshot()
|
||||
// const activeProject = useActiveProject();
|
||||
|
||||
const response = await $fetch<{ data: MetricsTimeline[], from: string, to: string }>(
|
||||
`/api/metrics/${activeProject.value?._id}/timeline/${timelineEndpointName}`, {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ slice }),
|
||||
});
|
||||
// const createFromToHeaders = (headers: Record<string, string> = {}) => ({
|
||||
// 'x-from': safeSnapshotDates.value.from,
|
||||
// 'x-to': safeSnapshotDates.value.to,
|
||||
// ...headers
|
||||
// });
|
||||
|
||||
return response;
|
||||
}
|
||||
// const createFromToBody = (body: Record<string, any> = {}) => ({
|
||||
// from: safeSnapshotDates.value.from,
|
||||
// to: safeSnapshotDates.value.to,
|
||||
// ...body
|
||||
// });
|
||||
|
||||
export async function useTimelineData(timelineEndpointName: string, slice: SliceName) {
|
||||
const response = await useTimelineDataRaw(timelineEndpointName, slice);
|
||||
if (!response) return;
|
||||
const fixed = fixMetrics(response, slice);
|
||||
return fixed;
|
||||
}
|
||||
|
||||
export function usePagesData(website: string, limit: number = 10) {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
||||
...signHeaders({
|
||||
'x-query-limit': limit.toString(),
|
||||
'x-website-name': website
|
||||
}),
|
||||
key: `pages_data:${website}:${limit}`,
|
||||
lazy: true
|
||||
});
|
||||
// export function useFirstInteractionData() {
|
||||
// const activeProject = useActiveProject();
|
||||
// const metricsInfo = useFetch<boolean>(`/api/metrics/${activeProject.value?._id}/first_interaction`, signHeaders());
|
||||
// return metricsInfo;
|
||||
// }
|
||||
|
||||
return res;
|
||||
|
||||
}
|
||||
// export function useTimelineAdvanced<T>(endpoint: string, slice: Ref<Slice>, customBody: Object = {}) {
|
||||
// const response = useCustomFetch<T>(
|
||||
// `/api/metrics/${activeProject.value?._id}/timeline/${endpoint}`,
|
||||
// () => signHeaders({ 'Content-Type': 'application/json' }).headers, {
|
||||
// method: 'POST',
|
||||
// getBody: () => createFromToBody({ slice: slice.value, ...customBody }),
|
||||
// lazy: true,
|
||||
// watchProps: [snapshot, slice]
|
||||
// });
|
||||
// return response;
|
||||
// }
|
||||
|
||||
export function useWebsitesData(limit: number = 10) {
|
||||
const activeProject = useActiveProject();
|
||||
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`, {
|
||||
...signHeaders({ 'x-query-limit': limit.toString() }),
|
||||
key: `websites_data:${limit}`,
|
||||
lazy: true
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
// export function useTimeline(endpoint: 'visits' | 'sessions' | 'referrers' | 'events_stacked', slice: Ref<Slice>) {
|
||||
// return useTimelineAdvanced<{ _id: string, count: number }[]>(endpoint, slice);
|
||||
// }
|
||||
|
||||
// export async function useReferrersTimeline(referrer: string, slice: Ref<Slice>) {
|
||||
// return await useTimelineAdvanced<{ _id: string, count: number }[]>('referrers', slice, { referrer });
|
||||
// }
|
||||
|
||||
// export function useEventsStackedTimeline(slice: Ref<Slice>) {
|
||||
// return useTimelineAdvanced<{ _id: string, name: string, count: number }[]>('events_stacked', slice);
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// export async function useTimelineDataRaw(timelineEndpointName: string, slice: SliceName) {
|
||||
// const activeProject = useActiveProject();
|
||||
|
||||
// const response = await $fetch<{ data: MetricsTimeline[], from: string, to: string }>(
|
||||
// `/api/metrics/${activeProject.value?._id}/timeline/${timelineEndpointName}`, {
|
||||
// method: 'POST',
|
||||
// ...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
// body: JSON.stringify({ slice }),
|
||||
// });
|
||||
|
||||
// return response;
|
||||
// }
|
||||
|
||||
// export async function useTimelineData(timelineEndpointName: string, slice: SliceName) {
|
||||
// const response = await useTimelineDataRaw(timelineEndpointName, slice);
|
||||
// if (!response) return;
|
||||
// const fixed = fixMetrics(response, slice);
|
||||
// return fixed;
|
||||
// }
|
||||
|
||||
// export function usePagesData(website: string, limit: number = 10) {
|
||||
// const activeProject = useActiveProject();
|
||||
|
||||
// const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
||||
// ...signHeaders({
|
||||
// 'x-query-limit': limit.toString(),
|
||||
// 'x-website-name': website
|
||||
// }),
|
||||
// key: `pages_data:${website}:${limit}`,
|
||||
// lazy: true
|
||||
// });
|
||||
|
||||
// return res;
|
||||
|
||||
// }
|
||||
|
||||
|
||||
|
||||
|
||||
// export function useWebsitesData(limit: number = 10) {
|
||||
// const res = useCustomFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useEventsData(limit: number = 10) {
|
||||
// const res = useCustomFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/events`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useReferrersData(limit: number = 10) {
|
||||
// const res = useCustomFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useBrowsersData(limit: number = 10) {
|
||||
// const res = useCustomFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useOssData(limit: number = 10) {
|
||||
// const res = useCustomFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useGeolocationData(limit: number = 10) {
|
||||
// const res = useCustomFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
// export function useDevicesData(limit: number = 10) {
|
||||
// const res = useCustomFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`,
|
||||
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||
// { lazy: false, watchProps: [snapshot] }
|
||||
// );
|
||||
// return res;
|
||||
// }
|
||||
|
||||
76
dashboard/composables/useSnapshot.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { TProjectSnapshot } from "@schema/ProjectSnapshot";
|
||||
|
||||
const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
|
||||
...signHeaders(),
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
watch(activeProject, async () => {
|
||||
await remoteSnapshots.refresh();
|
||||
snapshot.value = isLiveDemo() ? snapshots.value[0] : snapshots.value[1];
|
||||
});
|
||||
|
||||
const snapshots = computed(() => {
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const getDefaultSnapshots: () => TProjectSnapshot[] = () => [
|
||||
{
|
||||
project_id: activeProject.value?._id as any,
|
||||
_id: 'default0' as any,
|
||||
name: 'All',
|
||||
from: new Date(activeProject.value?.created_at || 0),
|
||||
to: new Date(Date.now()),
|
||||
color: '#CCCCCC'
|
||||
},
|
||||
{
|
||||
project_id: activeProject.value?._id as any,
|
||||
_id: 'default1' as any,
|
||||
name: 'Last month',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
|
||||
to: new Date(Date.now()),
|
||||
color: '#00CC00'
|
||||
},
|
||||
{
|
||||
project_id: activeProject.value?._id as any,
|
||||
_id: 'default2' as any,
|
||||
name: 'Last week',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
||||
to: new Date(Date.now()),
|
||||
color: '#0F02D2'
|
||||
},
|
||||
{
|
||||
project_id: activeProject.value?._id as any,
|
||||
_id: 'default3' as any,
|
||||
name: 'Last day',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24),
|
||||
to: new Date(Date.now()),
|
||||
color: '#CC11CC'
|
||||
}
|
||||
]
|
||||
|
||||
return [
|
||||
...getDefaultSnapshots(),
|
||||
...(remoteSnapshots.data.value || [])
|
||||
];
|
||||
})
|
||||
|
||||
const snapshot = ref<TProjectSnapshot>(isLiveDemo() ? snapshots.value[0] : snapshots.value[1]);
|
||||
|
||||
const safeSnapshotDates = computed(() => {
|
||||
const from = new Date(snapshot.value?.from || 0).toISOString();
|
||||
const to = new Date(snapshot.value?.to || Date.now()).toISOString();
|
||||
return { from, to }
|
||||
})
|
||||
|
||||
async function updateSnapshots() {
|
||||
await remoteSnapshots.refresh();
|
||||
}
|
||||
|
||||
export function useSnapshot() {
|
||||
if (remoteSnapshots.status.value === 'idle') {
|
||||
remoteSnapshots.execute();
|
||||
}
|
||||
return { snapshot, snapshots, safeSnapshotDates, updateSnapshots }
|
||||
}
|
||||
@@ -2,60 +2,21 @@
|
||||
|
||||
import type { Section } from '~/components/CVerticalNavigation.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
import { Lit } from 'litlyx-js';
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: 'General',
|
||||
entries: [
|
||||
{ label: 'Projects', icon: 'far fa-table-layout', to: '/project_selector' },
|
||||
{ label: 'Members', icon: 'far fa-users', to: '/members' },
|
||||
{ label: 'Admin', icon: 'fas fa-cat', adminOnly: true, to: '/admin' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Project',
|
||||
entries: [
|
||||
{ label: 'Dashboard', to: '/', icon: 'far fa-home' },
|
||||
{ label: 'Events', to: '/events', icon: 'far fa-bolt' },
|
||||
{ label: 'Analyst', to: '/analyst', icon: 'far fa-microchip-ai' },
|
||||
{ label: 'Report', to: '/report', icon: 'far fa-notes' },
|
||||
// { label: 'AI', to: '/dashboard/settings', icon: 'far fa-robot brightness-[.4]' },
|
||||
// { label: 'Visits', to: '/dashboard/visits', icon: 'far fa-eye' },
|
||||
// { label: 'Events', to: '/dashboard/events', icon: 'far fa-line-chart' },
|
||||
]
|
||||
},
|
||||
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
|
||||
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
|
||||
{ label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' },
|
||||
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
|
||||
{
|
||||
title: 'Non si vede',
|
||||
entries: [
|
||||
{
|
||||
label: 'Docs', to: 'https://docs.litlyx.com', icon: 'far fa-book-open', external: true,
|
||||
label: 'Docs', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
|
||||
action() { Lit.event('docs_clicked') },
|
||||
},
|
||||
{
|
||||
label: 'Github', to: 'https://github.com/litlyx/litlyx', icon: 'fab fa-github', external: true,
|
||||
action() { Lit.event('git_clicked') },
|
||||
},
|
||||
{ label: 'Billing', to: '/plans', icon: 'far fa-wallet' },
|
||||
{ label: 'Book a demo', to: '/book_demo', icon: 'far fa-calendar' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
entries: [
|
||||
{
|
||||
label: 'Logout',
|
||||
icon: 'far fa-arrow-right-from-bracket',
|
||||
action: () => {
|
||||
console.log('LOGOUT')
|
||||
setToken('');
|
||||
setLoggedUser(undefined);
|
||||
router.push('/login');
|
||||
}
|
||||
},
|
||||
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -92,7 +53,7 @@ const { isOpen, close, open } = useMenu();
|
||||
</CVerticalNavigation>
|
||||
|
||||
|
||||
<div class="overflow-hidden w-full bg-bg relative h-full">
|
||||
<div class="overflow-hidden w-full bg-lyx-background-light relative h-full">
|
||||
|
||||
<div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]">
|
||||
<i
|
||||
|
||||
@@ -9,6 +9,12 @@ const gooleSignInConfig: any = {
|
||||
}
|
||||
|
||||
export default defineNuxtConfig({
|
||||
postcss: {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
},
|
||||
colorMode: {
|
||||
preference: 'dark',
|
||||
},
|
||||
@@ -58,4 +64,6 @@ export default defineNuxtConfig({
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
components: true,
|
||||
extends: ['../lyx-ui']
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||
"chart.js": "^3.9.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"google-auth-library": "^9.9.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -29,6 +30,7 @@
|
||||
"redis": "^4.6.13",
|
||||
"sass": "^1.75.0",
|
||||
"stripe": "^15.8.0",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vue": "^3.4.21",
|
||||
"vue-chart-3": "^3.1.8",
|
||||
"vue-router": "^4.3.0"
|
||||
|
||||
@@ -22,6 +22,7 @@ type TProjectsGrouped = {
|
||||
project_name: string,
|
||||
total_visits: number,
|
||||
total_events: number,
|
||||
total_sessions: number
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -47,7 +48,8 @@ const projectsGrouped = computed(() => {
|
||||
premium: project.premium,
|
||||
project_name: project.project_name,
|
||||
total_events: project.total_events,
|
||||
total_visits: project.total_visits
|
||||
total_visits: project.total_visits,
|
||||
total_sessions: project.total_sessions
|
||||
});
|
||||
|
||||
} else {
|
||||
@@ -61,7 +63,8 @@ const projectsGrouped = computed(() => {
|
||||
premium_type: project.premium_type,
|
||||
project_name: project.project_name,
|
||||
total_events: project.total_events,
|
||||
total_visits: project.total_visits
|
||||
total_visits: project.total_visits,
|
||||
total_sessions: project.total_sessions
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -71,6 +74,12 @@ const projectsGrouped = computed(() => {
|
||||
|
||||
}
|
||||
|
||||
result.sort((sa, sb) => {
|
||||
const ca = sa.projects.reduce((a, e) => a + (e.total_visits + e.total_events), 0);
|
||||
const cb = sb.projects.reduce((a, e) => a + (e.total_visits + e.total_events), 0);
|
||||
return cb - ca;
|
||||
})
|
||||
|
||||
return result;
|
||||
|
||||
});
|
||||
@@ -107,7 +116,6 @@ const totalEvents = computed(() => {
|
||||
return projects.value?.reduce((a, e) => a + e.total_events, 0) || 0;
|
||||
});
|
||||
|
||||
|
||||
const details = ref<any>();
|
||||
const showDetails = ref<boolean>(false);
|
||||
async function getProjectDetails(project_id: string) {
|
||||
@@ -188,17 +196,17 @@ async function resetCount(project_id: string) {
|
||||
<div> {{ project.total_visits }} </div>
|
||||
<div> Events: </div>
|
||||
<div> {{ project.total_events }} </div>
|
||||
<div> Sessions: </div>
|
||||
<div> {{ project.total_sessions }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="bg-[#272727] hover:bg-[#313131] cursor-pointer px-8 py-2 mt-3 rounded-lg"
|
||||
@click="getProjectDetails(project._id)">
|
||||
Get details
|
||||
</div>
|
||||
<div class="bg-[#272727] hover:bg-[#313131] cursor-pointer px-8 py-2 mt-3 rounded-lg"
|
||||
@click="resetCount(project._id)">
|
||||
Reset counts
|
||||
</div>
|
||||
<div class="flex gap-4 items-center mt-4">
|
||||
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
|
||||
Payment details
|
||||
</LyxUiButton>
|
||||
<LyxUiButton type="danger" @click="resetCount(project._id)">
|
||||
Refresh counts
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,11 @@ const selectLabelsEvents = [
|
||||
];
|
||||
const eventsStackedSelectIndex = ref<number>(0);
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { snapshot } = useSnapshot();
|
||||
|
||||
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -15,9 +20,9 @@ const eventsStackedSelectIndex = ref<number>(0);
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
|
||||
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row">
|
||||
<CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
||||
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-[4] w-full" title="Events" sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||
@@ -29,27 +34,19 @@ const eventsStackedSelectIndex = ref<number>(0);
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<div class="bg-card p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
||||
Top events
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
Displays key events.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-[2] w-full h-full" title="Top events"
|
||||
sub="Displays key events.">
|
||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||
</CardTitled>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<EventsUserFlow></EventsUserFlow>
|
||||
<EventsUserFlow :key="refreshKey"></EventsUserFlow>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<EventsMetadataAnalyzer></EventsMetadataAnalyzer>
|
||||
<EventsMetadataAnalyzer :key="refreshKey"></EventsMetadataAnalyzer>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -27,15 +27,18 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||
alert('Copiato !');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
|
||||
|
||||
const createScriptText = () => {
|
||||
@@ -48,18 +51,17 @@ function copyScript() {
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(createScriptText());
|
||||
alert('Copiato !');
|
||||
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
const { data: firstInteraction, pending, refresh } = useFirstInteractionData();
|
||||
const firstInteractionUrl = computed(() => {
|
||||
return `/api/metrics/${activeProject.value?._id}/first_interaction`
|
||||
});
|
||||
|
||||
|
||||
watch(pending, () => {
|
||||
if (pending.value === true) return;
|
||||
if (firstInteraction.value === false) {
|
||||
setTimeout(() => { refresh(); }, 2000);
|
||||
}
|
||||
})
|
||||
const firstInteraction = useFetch<boolean>(firstInteractionUrl, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
const selectLabels = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
@@ -68,32 +70,49 @@ const selectLabels = [
|
||||
];
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
const { snapshot } = useSnapshot();
|
||||
|
||||
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="dashboard w-full h-full overflow-y-auto pb-20 md:pt-4 lg:pt-0">
|
||||
|
||||
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction">
|
||||
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value">
|
||||
|
||||
<div class="w-full px-4 py-2">
|
||||
|
||||
|
||||
<div v-if="limitsInfo && limitsInfo.limited"
|
||||
class="bg-orange-600 justify-center flex gap-2 py-2 px-4 font-semibold text-[1.2rem] rounded-lg">
|
||||
<div class="poppins text-text"> Limit reached </div>
|
||||
<NuxtLink to="/plans" class="poppins text-[#393972] underline cursor-pointer">
|
||||
Upgrade project
|
||||
</NuxtLink>
|
||||
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 has stopped to collect yur data. Please upgrade the plan for a minimal data loss.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<LyxUiButton type="outline"> Upgrade </LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<DashboardTopSection></DashboardTopSection>
|
||||
<DashboardTopCards></DashboardTopCards>
|
||||
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
||||
|
||||
|
||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
||||
|
||||
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits.">
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Visits trends"
|
||||
sub="Shows trends in page visits.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
|
||||
:options="selectLabels">
|
||||
@@ -105,7 +124,8 @@ const selectLabels = [
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions"
|
||||
sub="Shows trends in sessions.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
||||
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
||||
@@ -119,14 +139,13 @@ const selectLabels = [
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardWebsitesBarCard></DashboardWebsitesBarCard>
|
||||
<DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<DashboardEventsBarCard></DashboardEventsBarCard>
|
||||
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,10 +154,10 @@ const selectLabels = [
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardReferrersBarCard></DashboardReferrersBarCard>
|
||||
<DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<DashboardBrowsersBarCard></DashboardBrowsersBarCard>
|
||||
<DashboardBrowsersBarCard :key="refreshKey"></DashboardBrowsersBarCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,10 +165,10 @@ const selectLabels = [
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardOssBarCard></DashboardOssBarCard>
|
||||
<DashboardOssBarCard :key="refreshKey"></DashboardOssBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<DashboardGeolocationBarCard></DashboardGeolocationBarCard>
|
||||
<DashboardGeolocationBarCard :key="refreshKey"></DashboardGeolocationBarCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +176,7 @@ const selectLabels = [
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<DashboardDevicesBarCard></DashboardDevicesBarCard>
|
||||
<DashboardDevicesBarCard :key="refreshKey"></DashboardDevicesBarCard>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
</div>
|
||||
@@ -166,10 +185,10 @@ const selectLabels = [
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="!firstInteraction && activeProject" class="mt-[36vh] flex flex-col gap-6">
|
||||
<div v-if="!firstInteraction.data.value && activeProject" class="mt-[20vh] lg:mt-[36vh] flex flex-col gap-6">
|
||||
<div class="flex gap-4 items-center justify-center">
|
||||
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
|
||||
<div class="text-text/90 poppins text-[1.4rem] font-bold">
|
||||
<div class="text-text/90 poppins text-[1.3rem] font-semibold">
|
||||
Waiting for your first Visit or Event
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +219,11 @@ const selectLabels = [
|
||||
|
||||
</div>
|
||||
|
||||
<div></div>
|
||||
<NuxtLink to="https://docs.litlyx.com" target="_blank"
|
||||
class="flex justify-center poppins text-[1.2rem] text-accent gap-2 items-center">
|
||||
<div> <i class="far fa-book"></i> </div>
|
||||
<div class="poppins"> Go to docs </div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const selectLabelsEvents = [
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
|
||||
|
||||
const { snapshot } = useSnapshot();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ const selectLabelsEvents = [
|
||||
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
||||
<CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
||||
<!-- <CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||
@@ -128,7 +128,7 @@ const selectLabelsEvents = [
|
||||
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
||||
</EventsStackedBarChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</CardTitled> -->
|
||||
|
||||
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { data: planData } = useFetch('/api/project/plan', signHeaders());
|
||||
|
||||
const percent = computed(() => {
|
||||
if (!planData.value) return '-';
|
||||
return (100 / planData.value.limit * planData.value.count).toFixed(2) + '%';
|
||||
});
|
||||
|
||||
const color = computed(() => {
|
||||
if (!planData.value) return 'blue';
|
||||
if (planData.value.count >= planData.value.limit) return 'red';
|
||||
return 'blue';
|
||||
});
|
||||
|
||||
const daysLeft = computed(() => {
|
||||
if (!planData.value) return '-';
|
||||
return (-dayjs().diff(planData.value.billing_expire_at, 'days')).toString();
|
||||
});
|
||||
|
||||
const leftPercent = computed(() => {
|
||||
if (!planData.value) return 0;
|
||||
const left = dayjs().diff(planData.value.billing_expire_at, 'days');
|
||||
const total = dayjs(planData.value.billing_start_at).diff(planData.value.billing_expire_at, 'days');
|
||||
const percent = 100 - (100 / total * left);
|
||||
return percent;
|
||||
});
|
||||
|
||||
const prettyExpireDate = computed(() => {
|
||||
if (!planData.value) return '';
|
||||
return dayjs(planData.value.billing_expire_at).format('DD/MM/YYYY');
|
||||
});
|
||||
|
||||
|
||||
const { data: invoices } = await useFetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, signHeaders())
|
||||
|
||||
const showPricingDrawer = ref<boolean>(false);
|
||||
function onPlanUpgradeClick() {
|
||||
showPricingDrawer.value = true;
|
||||
}
|
||||
|
||||
function openInvoice(link: string) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
|
||||
function getPremiumName(type: number) {
|
||||
if (type === 0) return 'FREE';
|
||||
if (type === 1) return 'ACCELERATION';
|
||||
if (type === 2) return 'EXPANSION';
|
||||
return 'CUSTOM';
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-full h-full p-8 overflow-y-auto pb-40 lg:pb-0 relative overflow-x-hidden">
|
||||
|
||||
<Transition name="pdrawer">
|
||||
<PricingDrawer @onCloseClick="showPricingDrawer = false" :currentSub="planData?.premium_type || 0"
|
||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]"
|
||||
v-if=showPricingDrawer>
|
||||
</PricingDrawer>
|
||||
</Transition>
|
||||
|
||||
<div @click="showPricingDrawer = false" v-if="showPricingDrawer"
|
||||
class="barrier absolute left-0 top-0 w-full h-full z-[19] bg-black/40 backdrop-blur-[1px]">
|
||||
</div>
|
||||
|
||||
<div class="poppins font-semibold text-[1.8rem]">
|
||||
Billing
|
||||
</div>
|
||||
|
||||
<div class="poppins text-[1.3rem] text-text-sub">
|
||||
Manage your billing cycle for the project
|
||||
<span class="font-bold">
|
||||
{{ activeProject?.name || '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="my-4 mb-10 w-full bg-gray-400/30 h-[1px]">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-start gap-8">
|
||||
<Card v-if="planData" class="px-0 pt-6 pb-4 w-[35rem] flex flex-col">
|
||||
<div class="flex flex-col gap-6 px-8 grow">
|
||||
<div class="flex justify-between flex-col sm:flex-row">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-3 items-center">
|
||||
<div class="poppins font-semibold text-[1.1rem]">
|
||||
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
|
||||
</div>
|
||||
<div
|
||||
class="flex lato text-[.7rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
|
||||
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem]">
|
||||
Our free plan for testing the product.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="poppins font-semibold text-[2rem]"> $0 </div>
|
||||
<div class="poppins text-text-sub mt-2"> per month </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins"> Billing period:</div>
|
||||
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
|
||||
<div class="grow w-full md:w-auto">
|
||||
<UProgress color="green" :min="0" :max="100" :value="leftPercent"></UProgress>
|
||||
</div>
|
||||
<div class="poppins"> {{ daysLeft }} days left </div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
Subscription: {{ planData.subscription_status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
|
||||
</div>
|
||||
<div class="flex justify-between px-8 flex-col sm:flex-row">
|
||||
<div class="flex gap-2 text-text-sub text-[.9rem]">
|
||||
<div class="poppins"> Expire date:</div>
|
||||
<div> {{ prettyExpireDate }}</div>
|
||||
</div>
|
||||
<div v-if="!isGuest" @click="onPlanUpgradeClick()"
|
||||
class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]">
|
||||
<div class="poppins"> Upgrade plan </div>
|
||||
<i class="fas fa-arrow-up-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-if="planData" class="px-0 pt-6 pb-4 w-[35rem] flex flex-col">
|
||||
<div class="flex flex-col gap-6 px-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins font-semibold text-[1.1rem]">
|
||||
Usage
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem]">
|
||||
Check the usage limits of your project.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="poppins"> Usage:</div>
|
||||
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
|
||||
<div class="grow w-full md:w-auto">
|
||||
<UProgress :color="color" :min="0" :max="planData.limit" :value="planData.count">
|
||||
</UProgress>
|
||||
</div>
|
||||
<div class="poppins"> {{ percent }}</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
{{ formatNumberK(planData.count) }} / {{ formatNumberK(planData.limit) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
<CardTitled v-if="!isGuest" title="Invoices" :sub="(invoices && invoices.length == 0) ? 'No invoices yet' : ''"
|
||||
class="p-4 mt-8 max-w-[72rem]">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg" v-for="invoice of invoices">
|
||||
|
||||
<div> <i class="fal fa-file-invoice"></i> </div>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:justify-around md:grow items-center gap-2">
|
||||
<div> {{ new Date(invoice.date).toLocaleString() }} </div>
|
||||
<div> € {{ invoice.cost / 100 }} </div>
|
||||
<div> {{ invoice.id }} </div>
|
||||
<div
|
||||
class="flex items-center lato text-[.8rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
|
||||
{{ invoice.status }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<i @click="openInvoice(invoice.link)"
|
||||
class="far fa-download cursor-pointer hover:text-white/80"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</CardTitled>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pdrawer-enter-active,
|
||||
.pdrawer-leave-active {
|
||||
transition: all .5s ease-in-out;
|
||||
}
|
||||
|
||||
.pdrawer-enter-from,
|
||||
.pdrawer-leave-to {
|
||||
transform: translateX(100%)
|
||||
}
|
||||
|
||||
.pdrawer-enter-to,
|
||||
.pdrawer-leave-from {
|
||||
transform: translateX(0)
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { projects, refresh } = useProjectsList();
|
||||
@@ -103,9 +102,13 @@ async function deleteAccount() {
|
||||
<i class="fas fa-plus text-[1rem] text-text-sub/80"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-text font-semibold manrope text-[1.3rem]"> Create new project</div>
|
||||
<div class="text-text font-semibold manrope text-[1.3rem]">
|
||||
Create new project
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="text-text/85 mt-8 poppis text-[1.2rem]" v-if="projects.length == 0">
|
||||
|
||||
@@ -31,7 +31,9 @@ async function generatePDF() {
|
||||
|
||||
<div class="home w-full h-full px-10 lg:px-0 overflow-y-auto pb-[12rem] md:pb-0">
|
||||
|
||||
<div class="flex flex-col items-center justify-center mt-20 gap-20">
|
||||
<DialogCreateSnapshot></DialogCreateSnapshot>
|
||||
|
||||
<!-- <div class="flex flex-col items-center justify-center mt-20 gap-20">
|
||||
|
||||
<div class="flex flex-col items-center justify-center gap-10">
|
||||
<div class="poppins text-[2.4rem] font-bold text-text">
|
||||
@@ -84,7 +86,7 @@ async function generatePDF() {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
49
dashboard/pages/settings.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const items = [
|
||||
{ label: 'General', slot: 'general' },
|
||||
{ label: 'Members', slot: 'members' },
|
||||
{ label: 'Billing', slot: 'billing' },
|
||||
{ label: 'Account', slot: 'account' }
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-10 py-8 h-dvh overflow-y-auto hide-scrollbars">
|
||||
<div class="poppins font-semibold text-[1.3rem]"> Settings </div>
|
||||
|
||||
<CustomTab :items="items" class="mt-8">
|
||||
<template #general>
|
||||
<SettingsGeneral></SettingsGeneral>
|
||||
</template>
|
||||
<template #members>
|
||||
<SettingsMembers></SettingsMembers>
|
||||
</template>
|
||||
<template #billing>
|
||||
<SettingsBilling></SettingsBilling>
|
||||
</template>
|
||||
<template #account>
|
||||
<SettingsAccount></SettingsAccount>
|
||||
</template>
|
||||
</CustomTab>
|
||||
|
||||
<!-- <UTabs :items="items" class="mt-8">
|
||||
<template #general>
|
||||
<SettingsGeneral></SettingsGeneral>
|
||||
</template>
|
||||
<template #members>
|
||||
<SettingsMembers></SettingsMembers>
|
||||
</template>
|
||||
<template #billing>
|
||||
<SettingsBilling></SettingsBilling>
|
||||
</template>
|
||||
<template #account>
|
||||
<SettingsAccount></SettingsAccount>
|
||||
</template>
|
||||
</UTabs> -->
|
||||
</div>
|
||||
</template>
|
||||
106
dashboard/pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
chart.js:
|
||||
specifier: ^3.9.1
|
||||
version: 3.9.1
|
||||
date-fns:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
dayjs:
|
||||
specifier: ^1.11.11
|
||||
version: 1.11.11
|
||||
@@ -56,6 +59,9 @@ importers:
|
||||
stripe:
|
||||
specifier: ^15.8.0
|
||||
version: 15.8.0
|
||||
v-calendar:
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2(@popperjs/core@2.11.8)(vue@3.4.27(typescript@5.4.2))
|
||||
vue:
|
||||
specifier: ^3.4.21
|
||||
version: 3.4.27(typescript@5.4.2)
|
||||
@@ -1125,6 +1131,9 @@ packages:
|
||||
'@types/jsonwebtoken@9.0.6':
|
||||
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
|
||||
|
||||
'@types/lodash@4.17.7':
|
||||
resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==}
|
||||
|
||||
'@types/node-fetch@2.6.11':
|
||||
resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==}
|
||||
|
||||
@@ -1140,6 +1149,9 @@ packages:
|
||||
'@types/pdfkit@0.13.4':
|
||||
resolution: {integrity: sha512-ixGNDHYJCCKuamY305wbfYSphZ2WPe8FPkjn8oF4fHV+PgPV4V+hecPh2VOS2h4RNtpSB3zQcR4sCpNvvrEb1A==}
|
||||
|
||||
'@types/resize-observer-browser@0.1.11':
|
||||
resolution: {integrity: sha512-cNw5iH8JkMkb3QkCoe7DaZiawbDQEUX8t7iuQaRTyLOyQCR2h+ibBD4GJt7p5yhUHrlOeL7ZtbxNHeipqNsBzQ==}
|
||||
|
||||
'@types/resolve@1.20.2':
|
||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||
|
||||
@@ -1373,20 +1385,20 @@ packages:
|
||||
'@vue/reactivity@3.4.27':
|
||||
resolution: {integrity: sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==}
|
||||
|
||||
'@vue/reactivity@3.4.28':
|
||||
resolution: {integrity: sha512-B5uvZK0ArgBMkjK8RA9l5XP+PuQ/x99oqrcHRc78wa0pWyDje5X/isGihuiuSr0nFZTA5guoy78sJ6J8XxZv1A==}
|
||||
'@vue/reactivity@3.4.34':
|
||||
resolution: {integrity: sha512-ua+Lo+wBRlBEX9TtgPOShE2JwIO7p6BTZ7t1KZVPoaBRfqbC7N3c8Mpzicx173fXxx5VXeU6ykiHo7WgLzJQDA==}
|
||||
|
||||
'@vue/runtime-core@3.4.27':
|
||||
resolution: {integrity: sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==}
|
||||
|
||||
'@vue/runtime-core@3.4.28':
|
||||
resolution: {integrity: sha512-Corp5aAn5cm9h2cse6w5vRlnlfpy8hBRrsgCzHSoUohStlbqBXvI/uopPVkCivPCgY4fJZhXOufYYJ3DXzpN/w==}
|
||||
'@vue/runtime-core@3.4.34':
|
||||
resolution: {integrity: sha512-PXhkiRPwcPGJ1BnyBZFI96GfInCVskd0HPNIAZn7i3YOmLbtbTZpB7/kDTwC1W7IqdGPkTVC63IS7J2nZs4Ebg==}
|
||||
|
||||
'@vue/runtime-dom@3.4.27':
|
||||
resolution: {integrity: sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==}
|
||||
|
||||
'@vue/runtime-dom@3.4.28':
|
||||
resolution: {integrity: sha512-y9lDMMFf2Y5GpYdE8+IuavVl95D1GY1Zp8jU1vZhQ3Z4ga3f0Ym+XxRhcFtqaQAm9u82GwB7zDpBxafWDRq4pw==}
|
||||
'@vue/runtime-dom@3.4.34':
|
||||
resolution: {integrity: sha512-dXqIe+RqFAK2Euak4UsvbIupalrhc67OuQKpD7HJ3W2fv8jlqvI7szfBCsAEcE8o/wyNpkloxB6J8viuF/E3gw==}
|
||||
|
||||
'@vue/server-renderer@3.4.27':
|
||||
resolution: {integrity: sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==}
|
||||
@@ -1396,8 +1408,8 @@ packages:
|
||||
'@vue/shared@3.4.27':
|
||||
resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==}
|
||||
|
||||
'@vue/shared@3.4.28':
|
||||
resolution: {integrity: sha512-2b+Vuv5ichZQZPmRJfniHQkBSNigmRsRkr17bkYqBFy3J88T4lB7dRbAX/rx8qr9v0cr8Adg6yP872xhxGmh0w==}
|
||||
'@vue/shared@3.4.34':
|
||||
resolution: {integrity: sha512-x5LmiRLpRsd9KTjAB8MPKf0CDPMcuItjP0gbNqFCIgL1I8iYp4zglhj9w9FPCdIbHG2M91RVeIbArFfFTz9I3A==}
|
||||
|
||||
'@vueuse/components@10.10.0':
|
||||
resolution: {integrity: sha512-HiA10NQ9HJAGnju+8ZK4TyA8LIc0a6BnJmVWDa/k+TRhaYCVacSDU04k0BQ2otV+gghUDdwu98upf6TDRXpoeg==}
|
||||
@@ -1965,6 +1977,18 @@ packages:
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
date-fns-tz@2.0.1:
|
||||
resolution: {integrity: sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==}
|
||||
peerDependencies:
|
||||
date-fns: 2.x
|
||||
|
||||
date-fns@2.30.0:
|
||||
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
|
||||
engines: {node: '>=0.11'}
|
||||
|
||||
date-fns@3.6.0:
|
||||
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
|
||||
|
||||
dayjs@1.11.11:
|
||||
resolution: {integrity: sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==}
|
||||
|
||||
@@ -4533,6 +4557,12 @@ packages:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
|
||||
v-calendar@3.1.2:
|
||||
resolution: {integrity: sha512-QDWrnp4PWCpzUblctgo4T558PrHgHzDtQnTeUNzKxfNf29FkCeFpwGd9bKjAqktaa2aJLcyRl45T5ln1ku34kg==}
|
||||
peerDependencies:
|
||||
'@popperjs/core': ^2.0.0
|
||||
vue: ^3.2.0
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
@@ -4729,6 +4759,11 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
vue-screen-utils@1.0.0-beta.13:
|
||||
resolution: {integrity: sha512-EJ/8TANKhFj+LefDuOvZykwMr3rrLFPLNb++lNBqPOpVigT2ActRg6icH9RFQVm4nHwlHIHSGm5OY/Clar9yIg==}
|
||||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
vue-template-compiler@2.7.16:
|
||||
resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==}
|
||||
|
||||
@@ -6168,6 +6203,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.12.12
|
||||
|
||||
'@types/lodash@4.17.7': {}
|
||||
|
||||
'@types/node-fetch@2.6.11':
|
||||
dependencies:
|
||||
'@types/node': 18.19.33
|
||||
@@ -6189,6 +6226,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.12.12
|
||||
|
||||
'@types/resize-observer-browser@0.1.11': {}
|
||||
|
||||
'@types/resolve@1.20.2': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
@@ -6614,7 +6653,7 @@ snapshots:
|
||||
'@volar/language-core': 1.11.1
|
||||
'@volar/source-map': 1.11.1
|
||||
'@vue/compiler-dom': 3.4.27
|
||||
'@vue/shared': 3.4.28
|
||||
'@vue/shared': 3.4.34
|
||||
computeds: 0.0.1
|
||||
minimatch: 9.0.4
|
||||
muggle-string: 0.3.1
|
||||
@@ -6627,19 +6666,19 @@ snapshots:
|
||||
dependencies:
|
||||
'@vue/shared': 3.4.27
|
||||
|
||||
'@vue/reactivity@3.4.28':
|
||||
'@vue/reactivity@3.4.34':
|
||||
dependencies:
|
||||
'@vue/shared': 3.4.28
|
||||
'@vue/shared': 3.4.34
|
||||
|
||||
'@vue/runtime-core@3.4.27':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.4.27
|
||||
'@vue/shared': 3.4.27
|
||||
|
||||
'@vue/runtime-core@3.4.28':
|
||||
'@vue/runtime-core@3.4.34':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.4.28
|
||||
'@vue/shared': 3.4.28
|
||||
'@vue/reactivity': 3.4.34
|
||||
'@vue/shared': 3.4.34
|
||||
|
||||
'@vue/runtime-dom@3.4.27':
|
||||
dependencies:
|
||||
@@ -6647,11 +6686,11 @@ snapshots:
|
||||
'@vue/shared': 3.4.27
|
||||
csstype: 3.1.3
|
||||
|
||||
'@vue/runtime-dom@3.4.28':
|
||||
'@vue/runtime-dom@3.4.34':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.4.28
|
||||
'@vue/runtime-core': 3.4.28
|
||||
'@vue/shared': 3.4.28
|
||||
'@vue/reactivity': 3.4.34
|
||||
'@vue/runtime-core': 3.4.34
|
||||
'@vue/shared': 3.4.34
|
||||
csstype: 3.1.3
|
||||
|
||||
'@vue/server-renderer@3.4.27(vue@3.4.27(typescript@5.4.2))':
|
||||
@@ -6662,7 +6701,7 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.4.27': {}
|
||||
|
||||
'@vue/shared@3.4.28': {}
|
||||
'@vue/shared@3.4.34': {}
|
||||
|
||||
'@vueuse/components@10.10.0(vue@3.4.27(typescript@5.4.2))':
|
||||
dependencies:
|
||||
@@ -7244,6 +7283,16 @@ snapshots:
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
date-fns-tz@2.0.1(date-fns@2.30.0):
|
||||
dependencies:
|
||||
date-fns: 2.30.0
|
||||
|
||||
date-fns@2.30.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.6
|
||||
|
||||
date-fns@3.6.0: {}
|
||||
|
||||
dayjs@1.11.11: {}
|
||||
|
||||
db0@0.1.4: {}
|
||||
@@ -10163,6 +10212,17 @@ snapshots:
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
v-calendar@3.1.2(@popperjs/core@2.11.8)(vue@3.4.27(typescript@5.4.2)):
|
||||
dependencies:
|
||||
'@popperjs/core': 2.11.8
|
||||
'@types/lodash': 4.17.7
|
||||
'@types/resize-observer-browser': 0.1.11
|
||||
date-fns: 2.30.0
|
||||
date-fns-tz: 2.0.1(date-fns@2.30.0)
|
||||
lodash: 4.17.21
|
||||
vue: 3.4.27(typescript@5.4.2)
|
||||
vue-screen-utils: 1.0.0-beta.13(vue@3.4.27(typescript@5.4.2))
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
dependencies:
|
||||
spdx-correct: 3.2.0
|
||||
@@ -10340,8 +10400,8 @@ snapshots:
|
||||
|
||||
vue-chart-3@3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2)):
|
||||
dependencies:
|
||||
'@vue/runtime-core': 3.4.28
|
||||
'@vue/runtime-dom': 3.4.28
|
||||
'@vue/runtime-core': 3.4.34
|
||||
'@vue/runtime-dom': 3.4.34
|
||||
chart.js: 3.9.1
|
||||
csstype: 3.1.3
|
||||
lodash-es: 4.17.21
|
||||
@@ -10366,6 +10426,10 @@ snapshots:
|
||||
'@vue/devtools-api': 6.6.1
|
||||
vue: 3.4.27(typescript@5.4.2)
|
||||
|
||||
vue-screen-utils@1.0.0-beta.13(vue@3.4.27(typescript@5.4.2)):
|
||||
dependencies:
|
||||
vue: 3.4.27(typescript@5.4.2)
|
||||
|
||||
vue-template-compiler@2.7.16:
|
||||
dependencies:
|
||||
de-indent: 1.0.2
|
||||
|
||||
BIN
dashboard/public/logo_32.png
Normal file
|
After Width: | Height: | Size: 568 B |
@@ -14,7 +14,8 @@ export type AdminProjectsList = {
|
||||
created_at: Date
|
||||
},
|
||||
total_visits: number,
|
||||
total_events: number
|
||||
total_events: number,
|
||||
total_sessions: number
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
@@ -54,6 +55,9 @@ export default defineEventHandler(async event => {
|
||||
},
|
||||
total_events: {
|
||||
$arrayElemAt: ["$counts.events", 0]
|
||||
},
|
||||
total_sessions: {
|
||||
$arrayElemAt: ["$counts.sessions", 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
import { ProjectCountModel } from "@schema/ProjectsCounts";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
@@ -13,8 +14,9 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const events = await EventModel.countDocuments({ project_id });
|
||||
const visits = await VisitModel.countDocuments({ project_id });
|
||||
const sessions = await SessionModel.countDocuments({ project_id });
|
||||
|
||||
await ProjectCountModel.updateOne({ project_id, events, visits }, {}, { upsert: true });
|
||||
await ProjectCountModel.updateOne({ project_id, events, visits, sessions }, {}, { upsert: true });
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -36,12 +36,12 @@ export default defineEventHandler(async event => {
|
||||
$group: {
|
||||
_id: "$project_id",
|
||||
events: { $sum: "$events" },
|
||||
visits: { $sum: "$visits" }
|
||||
visits: { $sum: "$visits" },
|
||||
sessions: { $sum: "$sessions" },
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
const sessionsVisitsCount: any[] = await Redis.useCache({
|
||||
key: `counts:${project_id}:sessions_count`,
|
||||
exp: COUNTS_SESSIONS_EXPIRE_TIME
|
||||
|
||||
@@ -22,12 +22,26 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `browsers:${project_id}:${numLimit}`,
|
||||
key: `browsers:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const browsers: BrowsersAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$browser", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -21,13 +21,26 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `countries:${project_id}:${numLimit}`,
|
||||
key: `countries:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const countries: CountriesAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, country: { $ne: null } }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
country: { $ne: null },
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$country", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -20,13 +20,26 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `devices:${project_id}:${numLimit}`,
|
||||
key: `devices:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const devices: DevicesAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id, device: { $ne: null } }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
device: { $ne: null },
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$device", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
49
dashboard/server/api/metrics/[project_id]/data/events.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { DATA_EXPIRE_TIME, Redis } from "~/server/services/CacheService";
|
||||
|
||||
export type CustomEventsAggregated = {
|
||||
_id: string,
|
||||
count: number
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const project_id = getRequestProjectId(event);
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `events:${project_id}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
|
||||
const events: CustomEventsAggregated[] = await EventModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id, created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$name", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]);
|
||||
|
||||
return events;
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
@@ -22,13 +22,25 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `oss:${project_id}:${numLimit}`,
|
||||
key: `oss:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const oss: OssAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$os", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -22,13 +22,25 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `referrers:${project_id}:${numLimit}`,
|
||||
key: `referrers:${project_id}:${numLimit}:${from}:${to}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const referrers: ReferrersAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$referrer", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -22,12 +22,25 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
const numLimit = parseInt(limit || '10');
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `websites:${project_id}:${numLimit}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
const websites: VisitsWebsiteAggregated[] = await VisitModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$website", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
@@ -15,12 +15,24 @@ export default defineEventHandler(async event => {
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const { name: eventName } = getQuery(event);
|
||||
if (!eventName) return [];
|
||||
const { name: eventName, from, to } = getQuery(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!eventName) return setResponseStatus(event, 400, 'name is required');
|
||||
|
||||
|
||||
|
||||
const allEvents = await EventModel.find({ project_id: project_id, name: eventName }, { flowHash: 1 });
|
||||
const allEvents = await EventModel.find({
|
||||
project_id: project_id,
|
||||
name: eventName,
|
||||
created_at: {
|
||||
$gte: new Date(from.toString()),
|
||||
$lte: new Date(to.toString()),
|
||||
}
|
||||
}, { flowHash: 1 });
|
||||
|
||||
|
||||
const allFlowHashes = new Map<string, number>();
|
||||
|
||||
allEvents.forEach(e => {
|
||||
@@ -71,6 +83,17 @@ export default defineEventHandler(async event => {
|
||||
grouped[referrer]++;
|
||||
}
|
||||
|
||||
|
||||
const eventsCount = allEvents.length;
|
||||
|
||||
const allGroupedValue = Object.keys(grouped)
|
||||
.map(key => grouped[key])
|
||||
.reduce((a, e) => a + e, 0);
|
||||
|
||||
for (const key in grouped) {
|
||||
grouped[key] = 100 / allGroupedValue * grouped[key];
|
||||
}
|
||||
|
||||
return grouped;
|
||||
|
||||
});
|
||||
|
||||
@@ -15,11 +15,24 @@ export default defineEventHandler(async event => {
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const { name: eventName, field } = getQuery(event);
|
||||
if (!eventName || !field) return [];
|
||||
const { name: eventName, field, from, to } = getQuery(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!eventName) return setResponseStatus(event, 400, 'name is required');
|
||||
if (!field) return setResponseStatus(event, 400, 'field is required');
|
||||
|
||||
|
||||
const aggregation: PipelineStage[] = [
|
||||
{ $match: { project_id: project._id, name: eventName } },
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id, name: eventName,
|
||||
created_at: {
|
||||
$gte: new Date(from.toString()),
|
||||
$lte: new Date(to.toString()),
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $group: { _id: `$metadata.${field}`, count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]
|
||||
|
||||
@@ -15,8 +15,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:events:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { getTimeline } from "./generic";
|
||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { executeAdvancedTimelineAggregation } from "~/server/services/TimelineService";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const project_id = getRequestProjectId(event);
|
||||
@@ -11,16 +12,23 @@ export default defineEventHandler(async event => {
|
||||
if (!project) return;
|
||||
|
||||
|
||||
const { slice, duration } = await readBody(event);
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
|
||||
return await Redis.useCache({ key: `timeline:events_stacked:${project_id}:${slice}`, exp: TIMELINE_EXPIRE_TIME }, async () => {
|
||||
const timelineStackedEvents = await getTimeline(EventModel, project_id, slice, duration,
|
||||
{},
|
||||
{},
|
||||
{ name: "$_id.name" },
|
||||
{ name: '$name' }
|
||||
);
|
||||
return await Redis.useCache({ key: `timeline:events_stacked:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, exp: TIMELINE_EXPIRE_TIME }, async () => {
|
||||
|
||||
const timelineStackedEvents = await executeAdvancedTimelineAggregation<{ name: String }>({
|
||||
model: EventModel,
|
||||
projectId: project._id,
|
||||
from, to, slice,
|
||||
customProjection: { name: "$_id.name" },
|
||||
customIdGroup: { name: '$name' },
|
||||
})
|
||||
|
||||
return timelineStackedEvents;
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to, referrer } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:referrers:${referrer}:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||
@@ -16,8 +16,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:sessions:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||
@@ -27,8 +27,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:sessions_duration:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||
@@ -17,8 +17,8 @@ export default defineEventHandler(async event => {
|
||||
const { slice, from, to } = await readBody(event);
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!from) return setResponseStatus(event, 400, 'slice is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!slice) return setResponseStatus(event, 400, 'slice is required');
|
||||
|
||||
return await Redis.useCache({
|
||||
key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
|
||||
export type CustomEventsAggregated = {
|
||||
_id: string,
|
||||
count: number
|
||||
}
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const project_id = getRequestProjectId(event);
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
|
||||
const websites: CustomEventsAggregated[] = await EventModel.aggregate([
|
||||
{ $match: { project_id: project._id }, },
|
||||
{ $group: { _id: "$name", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]);
|
||||
|
||||
return websites;
|
||||
|
||||
|
||||
});
|
||||
@@ -3,9 +3,7 @@ import StripeService from '~/server/services/StripeService';
|
||||
import type Event from 'stripe';
|
||||
import { ProjectModel } from '@schema/ProjectSchema';
|
||||
import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM';
|
||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||
import { UserModel } from '@schema/UserSchema';
|
||||
|
||||
|
||||
|
||||
|
||||
30
dashboard/server/api/project/change_name.post.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||
|
||||
const project_id = currentActiveProject.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
if (project.owner.toString() != userData.id) {
|
||||
return setResponseStatus(event, 400, 'You are not the owner');
|
||||
}
|
||||
|
||||
const { name } = await readBody(event);
|
||||
|
||||
project.name = name;
|
||||
await project.save();
|
||||
|
||||
return { ok: true };
|
||||
|
||||
});
|
||||
@@ -39,7 +39,8 @@ export default defineEventHandler(async event => {
|
||||
await ProjectCountModel.create({
|
||||
project_id: project._id,
|
||||
events: 0,
|
||||
visits: 0
|
||||
visits: 0,
|
||||
sessions: 0
|
||||
});
|
||||
|
||||
await ProjectLimitModel.updateOne({ project_id: project._id }, {
|
||||
@@ -76,7 +77,8 @@ export default defineEventHandler(async event => {
|
||||
await ProjectCountModel.create({
|
||||
project_id: project._id,
|
||||
events: 0,
|
||||
visits: 0
|
||||
visits: 0,
|
||||
sessions: 0
|
||||
});
|
||||
|
||||
return project.toJSON() as TProject;
|
||||
|
||||
19
dashboard/server/api/project/snapshots.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
import { ProjectSnapshotModel, TProjectSnapshot } from "@schema/ProjectSnapshot";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||
|
||||
const project_id = currentActiveProject.active_project_id;
|
||||
|
||||
const snapshots = await ProjectSnapshotModel.find({ project_id });
|
||||
|
||||
return snapshots.map(e => e.toJSON()) as TProjectSnapshot[];
|
||||
|
||||
});
|
||||
43
dashboard/server/api/snapshot/create.post.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const { name: newSnapshotName, from, to, color: snapshotColor } = body;
|
||||
|
||||
if (!newSnapshotName) return setResponseStatus(event, 400, 'SnapshotName too short');
|
||||
if (newSnapshotName.length == 0) return setResponseStatus(event, 400, 'SnapshotName too short');
|
||||
|
||||
if (!from) return setResponseStatus(event, 400, 'from is required');
|
||||
if (!to) return setResponseStatus(event, 400, 'to is required');
|
||||
if (!snapshotColor) return setResponseStatus(event, 400, 'color is required');
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { active_project_id: 1 });
|
||||
|
||||
if (!userSettings) return setResponseStatus(event, 500, 'Unkwnown error');
|
||||
|
||||
const currentProjectId = userSettings.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(currentProjectId);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
|
||||
const newSnapshot = await ProjectSnapshotModel.create({
|
||||
name: newSnapshotName,
|
||||
from: new Date(from),
|
||||
to: new Date(to),
|
||||
color: snapshotColor,
|
||||
project_id: currentProjectId
|
||||
});
|
||||
|
||||
return newSnapshot.id;
|
||||
|
||||
|
||||
});
|
||||
35
dashboard/server/api/snapshot/delete.delete.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const { id: snapshotId } = body;
|
||||
|
||||
if (!snapshotId) return setResponseStatus(event, 400, 'id is required');
|
||||
|
||||
const userData = getRequestUser(event);
|
||||
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||
|
||||
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { active_project_id: 1 });
|
||||
|
||||
if (!userSettings) return setResponseStatus(event, 500, 'Unkwnown error');
|
||||
|
||||
const currentProjectId = userSettings.active_project_id;
|
||||
|
||||
const project = await ProjectModel.findById(currentProjectId);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
|
||||
const deletation = await ProjectSnapshotModel.deleteOne({
|
||||
project_id: currentProjectId,
|
||||
_id: snapshotId
|
||||
});
|
||||
|
||||
return { ok: deletation.acknowledged };
|
||||
|
||||
|
||||
});
|
||||
@@ -21,11 +21,14 @@ export class Redis {
|
||||
url: runtimeConfig.REDIS_URL,
|
||||
username: runtimeConfig.REDIS_USERNAME,
|
||||
password: runtimeConfig.REDIS_PASSWORD,
|
||||
database: process.dev ? 1 : 0
|
||||
database: process.dev ? 1 : 0,
|
||||
});
|
||||
|
||||
static async init() {
|
||||
await this.client.connect();
|
||||
this.client.on('error', function (err) {
|
||||
console.error('Redis error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
static async setString(key: string, value: string, exp: number) {
|
||||
|
||||
@@ -16,14 +16,16 @@ export type TimelineAggregationOptions = {
|
||||
export type AdvancedTimelineAggregationOptions = TimelineAggregationOptions & {
|
||||
customMatch?: Record<string, any>,
|
||||
customGroup?: Record<string, any>,
|
||||
customProjection?: Record<string, any>
|
||||
customProjection?: Record<string, any>,
|
||||
customIdGroup?: Record<string, any>
|
||||
}
|
||||
|
||||
export async function executeAdvancedTimelineAggregation(options: AdvancedTimelineAggregationOptions) {
|
||||
export async function executeAdvancedTimelineAggregation<T = {}>(options: AdvancedTimelineAggregationOptions) {
|
||||
|
||||
options.customMatch = options.customMatch || {};
|
||||
options.customGroup = options.customGroup || {};
|
||||
options.customProjection = options.customProjection || {};
|
||||
options.customIdGroup = options.customIdGroup || {};
|
||||
|
||||
const { group, sort, fromParts } = DateService.getQueryDateRange(options.slice);
|
||||
|
||||
@@ -35,7 +37,7 @@ export async function executeAdvancedTimelineAggregation(options: AdvancedTimeli
|
||||
...options.customMatch
|
||||
}
|
||||
},
|
||||
{ $group: { _id: group, count: { $sum: 1 }, ...options.customGroup } },
|
||||
{ $group: { _id: { ...group, ...options.customIdGroup }, count: { $sum: 1 }, ...options.customGroup } },
|
||||
{ $sort: sort },
|
||||
{ $project: { _id: { $dateFromParts: fromParts }, count: "$count", ...options.customProjection } }
|
||||
]
|
||||
@@ -44,7 +46,7 @@ export async function executeAdvancedTimelineAggregation(options: AdvancedTimeli
|
||||
console.log(JSON.stringify(aggregation, null, 2));
|
||||
}
|
||||
|
||||
const timeline: { _id: string, count: number }[] = await options.model.aggregate(aggregation);
|
||||
const timeline: { _id: string, count: number & T }[] = await options.model.aggregate(aggregation);
|
||||
|
||||
return timeline;
|
||||
|
||||
|
||||
@@ -29,6 +29,48 @@ module.exports = {
|
||||
light: '#2c91ed',
|
||||
sub: '#99A7F1',
|
||||
},
|
||||
"lyx-primary": {
|
||||
DEFAULT: '#5680F8',
|
||||
dark: '#222A42',
|
||||
hover: '#2A3450'
|
||||
},
|
||||
"lyx-text": {
|
||||
DEFAULT: '#FFFFFF',
|
||||
dark: '#D4D4D4',
|
||||
darker: '#6A6A6A'
|
||||
},
|
||||
"lyx-widget": {
|
||||
DEFAULT: '#151515',
|
||||
light: '#1E1E1E',
|
||||
lighter: '#262626'
|
||||
},
|
||||
"lyx-background": {
|
||||
DEFAULT: '#0A0A0A',
|
||||
light: '#121212',
|
||||
lighter: '#212121'
|
||||
},
|
||||
"lyx-danger": {
|
||||
DEFAULT: '#F86956',
|
||||
dark: '#4A2D29'
|
||||
},
|
||||
"lyx-chart": {
|
||||
purple: {
|
||||
DEFAULT: '#5655D7',
|
||||
dark: '#282844'
|
||||
},
|
||||
green: {
|
||||
DEFAULT: '#1D9B86',
|
||||
dark: '#213734'
|
||||
},
|
||||
cyan: {
|
||||
DEFAULT: '#4ABDE8',
|
||||
dark: '#273D48'
|
||||
},
|
||||
orange: {
|
||||
DEFAULT: '#F56524',
|
||||
dark: '#492C22'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -60,9 +60,10 @@ export function fixMetrics(result: { data: MetricsTimeline[], from: string, to:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const allKeys = !options.advanced ? [] : Array.from(new Set(result.data.map((e: any) => e[options.advancedGroupKey])).values());
|
||||
|
||||
console.log({allKeys})
|
||||
|
||||
const fixed: any[] = allDates.map(matchDate => {
|
||||
|
||||
if (!options.advanced) {
|
||||
@@ -85,6 +86,7 @@ export function fixMetrics(result: { data: MetricsTimeline[], from: string, to:
|
||||
return returnObject;
|
||||
});
|
||||
|
||||
|
||||
if (slice === 'day' || slice == 'hour') fixed.pop();
|
||||
|
||||
const data = fixed.map(e => e.count);
|
||||
|
||||
@@ -17,7 +17,10 @@ useSeoMeta({
|
||||
ogSiteName: 'Litlyx',
|
||||
ogLocale: 'en_US',
|
||||
ogImageWidth: '1200',
|
||||
ogImageHeight: '630'
|
||||
ogImageHeight: '630',
|
||||
themeColor: '#0A0A0A',
|
||||
appleMobileWebAppCapable: 'yes',
|
||||
appleMobileWebAppStatusBarStyle: 'black-translucent',
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +28,7 @@ useSeoMeta({
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-dvw h-dvh bg-[#151517]">
|
||||
<div class="w-dvw h-dvh bg-lyx-background">
|
||||
<NuxtLayout>
|
||||
<NuxtPage></NuxtPage>
|
||||
</NuxtLayout>
|
||||
|
||||
BIN
landing/assets/menlo/Menlo-Regular.woff
Normal file
34
landing/assets/scss/animations.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ----------------------------------------
|
||||
* animation scale-up-center
|
||||
* ----------------------------------------
|
||||
*/
|
||||
@-webkit-keyframes scale-up-center {
|
||||
0% {
|
||||
-webkit-transform: scale(0.5);
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-up-center {
|
||||
0% {
|
||||
-webkit-transform: scale(0.5);
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.scale-up-center {
|
||||
-webkit-animation: scale-up-center 0.4s cubic-bezier(0.390, 0.575, 0.565, 1.000) both;
|
||||
animation: scale-up-center 0.4s cubic-bezier(0.390, 0.575, 0.565, 1.000) both;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
@import '../font-awesome/css/all.css';
|
||||
@import './utilities.scss';
|
||||
@import './animations.scss';
|
||||
|
||||
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
|
||||
@@ -11,6 +12,17 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&display=swap');
|
||||
|
||||
@font-face {
|
||||
font-family: 'Menlo Regular';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: local('../menlo/Menlo-Regular.woff'), url('../menlo/Menlo-Regular.woff') format('woff');
|
||||
}
|
||||
|
||||
.menlo {
|
||||
font-family: "Menlo Regular";
|
||||
}
|
||||
|
||||
.fas,
|
||||
.far,
|
||||
.fat {
|
||||
|
||||
26
landing/components/Testimonial.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{
|
||||
name: string,
|
||||
sub: string,
|
||||
linkText: string,
|
||||
link: string,
|
||||
text: string
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LyxUiCard>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div>{{ name }}</div>
|
||||
<div>{{ sub }} <NuxtLink class="text-lyx-primary" :to="link" target="_blank">{{ linkText }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
@@ -123,6 +123,15 @@ nuxtApp.hook("page:finish", () => {
|
||||
|
||||
<div class="divider border-b border-gray-500/40"></div>
|
||||
|
||||
<NuxtLink @click="isMenuOpen = false" to="/why-choose-litlyx" class="flex justify-between items-center mr-2">
|
||||
<div class="hover:text-text-sub/90 py-3">
|
||||
Why choose Litlyx
|
||||
</div>
|
||||
<div> <i class="fas fa-chevron-right"></i> </div>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="divider border-b border-gray-500/40"></div>
|
||||
|
||||
<NuxtLink target="_blank" to="https://docs.litlyx.com"
|
||||
class="flex justify-between items-center mr-2">
|
||||
<div class="hover:text-text-sub/90 py-3">
|
||||
@@ -141,6 +150,7 @@ nuxtApp.hook("page:finish", () => {
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
|
||||
<div class="divider border-b border-gray-500/40"></div>
|
||||
|
||||
|
||||
@@ -223,6 +233,7 @@ nuxtApp.hook("page:finish", () => {
|
||||
<NuxtLink target="_blank" to="https://github.com/Litlyx/litlyx"
|
||||
class="hover:text-accent cursor-pointer"> Github </NuxtLink>
|
||||
<NuxtLink to="/pricing" class="hover:text-accent cursor-pointer"> Pricing </NuxtLink>
|
||||
<NuxtLink to="/why-choose-litlyx" class="hover:text-accent cursor-pointer"> Why choose Litlyx </NuxtLink>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-text-sub/60 font-semibold text-[1.3rem]"> Company </div>
|
||||
|
||||
@@ -29,4 +29,6 @@ export default defineNuxtConfig({
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
components: true,
|
||||
extends: ['../lyx-ui']
|
||||
})
|
||||
|
||||
@@ -29,172 +29,369 @@ const mouseStyle = computed(() => {
|
||||
return `top: ${y.value - (blobSize / 2)}px; left: ${x.value - (blobSize / 2)}px; width: ${blobSize}px; height: ${blobSize}px;`
|
||||
});
|
||||
|
||||
|
||||
|
||||
const email = ref<string>("");
|
||||
|
||||
const scriptDeferTokens = ref<string[]>([
|
||||
"<",
|
||||
"script",
|
||||
" defer ",
|
||||
"data-project",
|
||||
"=",
|
||||
"\"project_id\"",
|
||||
" src",
|
||||
"=",
|
||||
"\"https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js\"",
|
||||
">",
|
||||
"<",
|
||||
"/script",
|
||||
">",
|
||||
])
|
||||
|
||||
const snippetIndex = ref<number>(0);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home relative h-full w-full bg-[#151517] overflow-x-hidden">
|
||||
<div class="flex justify-center home relative">
|
||||
|
||||
<div class="w-full h-full fixed left-0 top-0 hidden md:flex">
|
||||
<div :style="mouseStyle" class="absolute w-[30rem] h-[18rem] flex items-center justify-center z-0">
|
||||
<div class="blob opacity-5"></div>
|
||||
</div>
|
||||
<div
|
||||
class="lg:w-[800px] h-full w-full bg-lyx-background px-8 py-10 overflow-x-hidden flex flex-col items-center">
|
||||
|
||||
<div class="headline w-full scale-up-center">
|
||||
Analytics For
|
||||
<span class="text-lyx-primary"> Developers </span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center z-0 overflow-hidden">
|
||||
<HomeBgGrid :size="100" :spacing="18" opacity="0.3" class="w-fit h-fit"></HomeBgGrid>
|
||||
<div class="section !mt-6">
|
||||
|
||||
<div class="paragraph">
|
||||
30 Seconds Setup with One Line of Code.
|
||||
All Your Analytics in a Single AI Powered Dashboard.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex w-full mt-20 justify-center relative z-[10]">
|
||||
<div class="flex flex-col items-center justify-center gap-20 rounded-lg py-6">
|
||||
|
||||
<div class="poppins text-center font-bold text-text lg:leading-[5rem] text-[1.7rem] lg:text-[4rem]">
|
||||
Boost Analytics Collection
|
||||
<br>
|
||||
with <span class="text-accent font-bold poppins"> Minimal Setup </span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 flex-col lg:flex-row lg:gap-10">
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-check text-[1.5rem]"></i>
|
||||
<div class="poppins text-[1.2rem]"> One-Line Code Setup </div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-check text-[1.5rem]"></i>
|
||||
<div class="poppins text-[1.2rem]"> 15+ Techs Supported </div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-check text-[1.5rem]"></i>
|
||||
<div class="poppins text-[1.2rem]"> High customization </div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-check text-[1.5rem]"></i>
|
||||
<div class="poppins text-[1.2rem]"> GDPR Compliance </div>
|
||||
<div class="button-container">
|
||||
<LyxUiButton link="https://dashboard.litlyx.com" target="_blank" class="button" type="primary">
|
||||
Start for free
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 items-center flex-col lg:flex-row lg:w-[80%]">
|
||||
|
||||
<NuxtLink to="https://dashboard.litlyx.com"
|
||||
class="hover:bg-white/90 font-semibold cursor-pointer flex items-center gap-4 text-xl animated-button px-8 py-3 rounded-2xl">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="poppins"> Get Started for Free </div>
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
<div class="mt-8">
|
||||
<img :src="'screenshot.png'" alt="Litlyx dashboard">
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink target="_blank" to="https://dashboard.litlyx.com/live_demo"
|
||||
class="hover:bg-[#1b1b1b] justify-center font-semiboldcursor-pointer w-full flex items-center gap-4 text-xl text-bg-light px-16 py-3 rounded-2xl bg-black border-solid border-[1px] border-white/80 text-white">
|
||||
<div class="poppins"> Live Demo </div>
|
||||
</NuxtLink>
|
||||
<div class="text-center poppins mt-8">
|
||||
Trusted by
|
||||
<NuxtLink class="font-bold" target="_blank" to="https://nuvol.ai">NuvolAI</NuxtLink>,
|
||||
<NuxtLink class="font-bold" target="_blank" to="https://www.nationalgeographic.it">National Geografic
|
||||
(IT)</NuxtLink>,
|
||||
<NuxtLink class="font-bold" target="_blank" to="https://deckx.app">DeckX</NuxtLink>,
|
||||
<NuxtLink class="font-bold" target="_blank" to="https://www.antichicasalicampershop.it">Antichi Casali
|
||||
Camper Shop</NuxtLink>,
|
||||
for Data collection.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="section">
|
||||
|
||||
<div class="subtitle">
|
||||
Collect analytics, <br>easy way
|
||||
</div>
|
||||
|
||||
<div class="paragraph">
|
||||
More than 10 KPIs like Page Visits, Custom Events
|
||||
Referrers and many more
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-center items-center gap-2 text-[.8rem] mt-8">
|
||||
<LyxUiButton :class="{ 'outline outline-[1px] outline-[#CD8700]': snippetIndex == 0 }"
|
||||
@click="snippetIndex = 0" type="secondary"> Javascript
|
||||
</LyxUiButton>
|
||||
<LyxUiButton :class="{ 'outline outline-[1px] outline-[#CD8700]': snippetIndex == 1 }"
|
||||
@click="snippetIndex = 1" type="secondary"> Runtimes </LyxUiButton>
|
||||
<LyxUiButton :class="{ 'outline outline-[1px] outline-[#CD8700]': snippetIndex == 2 }"
|
||||
@click="snippetIndex = 2" type="secondary"> Events </LyxUiButton>
|
||||
</div>
|
||||
|
||||
|
||||
<LyxUiCard class="w-full mt-8 bg-lyx-background py-6 text-center">
|
||||
|
||||
<div v-if="snippetIndex == 0" class="text-[.9rem]">
|
||||
<span class="text-[#808080] menlo">{{ scriptDeferTokens[0] }}</span>
|
||||
<span class="text-[#569CD6] menlo">{{ scriptDeferTokens[1] }}</span>
|
||||
<span class="text-[#9CDCFE] menlo">{{ scriptDeferTokens[2] }}</span>
|
||||
<span class="text-[#9CDCFE] text-nowrap menlo">{{ scriptDeferTokens[3] }}</span>
|
||||
<span class="text-[#D4D4D4] menlo">{{ scriptDeferTokens[4] }}</span>
|
||||
<span class="text-[#CD9178] menlo">{{ scriptDeferTokens[5] }}</span>
|
||||
<span class="text-[#9CDCFE] menlo">{{ scriptDeferTokens[6] }}</span>
|
||||
<span class="text-[#D4D4D4] menlo">{{ scriptDeferTokens[7] }}</span>
|
||||
<span class="text-[#CE9178] break-words menlo">{{ scriptDeferTokens[8] }}</span>
|
||||
<span class="text-[#808080] menlo">{{ scriptDeferTokens[9] }}</span>
|
||||
<span class="text-[#808080] menlo">{{ scriptDeferTokens[10] }}</span>
|
||||
<span class="text-[#569CD6] menlo">{{ scriptDeferTokens[11] }}</span>
|
||||
<span class="text-[#808080] menlo">{{ scriptDeferTokens[12] }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="snippetIndex == 1" class="text-[.9rem]">
|
||||
<span class="text-[#9CDCFE] menlo">Lit</span>
|
||||
<span class="text-[#D3D3D3] menlo">.</span>
|
||||
<span class="text-[#DCDCAA] menlo">init</span>
|
||||
<span class="text-[#D3D3D3] menlo">(</span>
|
||||
<span class="text-[#CE9178] menlo">'project_id'</span>
|
||||
<span class="text-[#D3D3D3] menlo">);</span>
|
||||
</div>
|
||||
|
||||
<div v-if="snippetIndex == 2" class="text-[.9rem]">
|
||||
<span class="text-[#9CDCFE] menlo">Lit</span>
|
||||
<span class="text-[#D3D3D3] menlo">.</span>
|
||||
<span class="text-[#DCDCAA] menlo">event</span>
|
||||
<span class="text-[#D3D3D3] menlo">(</span>
|
||||
<span class="text-[#CE9178] menlo">'event_name'</span>
|
||||
<span class="text-[#D3D3D3] menlo">);</span>
|
||||
</div>
|
||||
|
||||
</LyxUiCard>
|
||||
|
||||
|
||||
|
||||
<div class="poppins text-center mt-6 text-[1.1rem]">
|
||||
That’s It! You are <span class="font-bold"> Ready </span> to Collect data.
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<LyxUiButton link="https://dashboard.litlyx.com" target="_blank" class="button" type="primary">
|
||||
Start for free
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="section">
|
||||
<div class="subtitle">
|
||||
Plug in <br> everywhere.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-10 z-[10] relative">
|
||||
<div class="bg-[#1d1d1f] rounded-[1rem] overflow-hidden w-[96%] lg:w-[60%] ">
|
||||
<img :src="'screen_1.png'" alt="Litlyx dashboard" class="">
|
||||
<div class="paragraph">
|
||||
Seamless Integrations with popular
|
||||
<span class="text-[#FFCA27]">JS</span>/<span class="text-[#017ACB]">TS</span>
|
||||
runtimes like Nuxt, Deno, Next, Vue, React and many more.
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
<img :src="'techs-new.png'" alt="Techs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-center mt-20 z-[10] relative items-center flex-col gap-2">
|
||||
<div class="poppins text-[1.2rem]"> Trusted by</div>
|
||||
<NuxtLink to="https://nuvol.ai/" target="_blank" class="max-w-[18rem] bg-text-sub p-4 rounded-lg">
|
||||
<img class="w-full h-full" alt="Partner_1" :src="'nuvolai.png'">
|
||||
</NuxtLink>
|
||||
<div class="section">
|
||||
|
||||
<div class="subtitle">
|
||||
Transform DB's data in beautiful charts
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-20 z-[10] relative items-center flex-col gap-6">
|
||||
<Code></Code>
|
||||
<div class="paragraph">
|
||||
Easily connect all your databases to your dashboard like Supabase, MongoDB, Cassandra and more.
|
||||
</div>
|
||||
|
||||
<div class="my-10 flex justify-center">
|
||||
<img class="lg:h-[35rem]" :src="'db-connect.png'" alt="DB-CONNECT">
|
||||
</div>
|
||||
|
||||
<div class="paragraph">
|
||||
We don't only collect Analytics.
|
||||
We Agglomerate your Existing data! Showing
|
||||
Beautiful Charts!
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
<img :src="'placeholder.jpg'" alt="Placeholder">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-center mt-40 z-[10] relative items-center flex-col gap-6">
|
||||
<div class="poppins font-bold text-text text-center text-[2.2rem] lg:text-[3rem]">
|
||||
+15 Supported technologies
|
||||
</div>
|
||||
<div class="poppins text-[1.2rem] text-text-sub text-center w-[90%] lg:w-[40%]">
|
||||
Seamless Integrations with popular JS/TS frameworks, including React, Angular, Vue, Node, Next and many
|
||||
more.
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center py-10 lg:gap-8 lg:px-20">
|
||||
<HomeTechCard name="Node.js" icon="tech/4.png"></HomeTechCard>
|
||||
<HomeTechCard name="React" icon="tech/15.png"></HomeTechCard>
|
||||
<HomeTechCard name="Vue.js" icon="tech/8.png"></HomeTechCard>
|
||||
<HomeTechCard name="Next.js" icon="tech/7.png"></HomeTechCard>
|
||||
<HomeTechCard name="Nuxt" icon="tech/12.png"></HomeTechCard>
|
||||
<HomeTechCard name="Angular" icon="tech/9.png"></HomeTechCard>
|
||||
<HomeTechCard name="Firebase" icon="tech/1.png"></HomeTechCard>
|
||||
<HomeTechCard name="NestJS" icon="tech/2.png"></HomeTechCard>
|
||||
<HomeTechCard name="Deno" icon="tech/11.png"></HomeTechCard>
|
||||
<HomeTechCard name="Lambda" icon="tech/14.png"></HomeTechCard>
|
||||
<HomeTechCard name="Fastify" icon="tech/5.png"></HomeTechCard>
|
||||
<HomeTechCard name="Netlify" icon="tech/13.png"></HomeTechCard>
|
||||
<HomeTechCard name="Bun" icon="tech/3.png"></HomeTechCard>
|
||||
<HomeTechCard name="Svelte" icon="tech/6.png"></HomeTechCard>
|
||||
<HomeTechCard name="Solid" icon="tech/10.png"></HomeTechCard>
|
||||
<div class="button-container">
|
||||
<LyxUiButton link="https://dashboard.litlyx.com/live_demo" target="_blank" class="button"
|
||||
type="outline">
|
||||
Go to live demo
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-40 z-[10] relative items-center flex-col gap-6">
|
||||
<OpenSource></OpenSource>
|
||||
|
||||
<div class="section">
|
||||
|
||||
<div class="subtitle">
|
||||
An AI data analyst available 24/7
|
||||
</div>
|
||||
|
||||
<div class="paragraph">
|
||||
Take metrics-driven decision with Lit our AI agent. Generate charts chatting with Lit.
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
<img class="scale-125 lg:scale-100 lg:h-[35rem]" :src="'ai-chat.png'" alt="Ai-Chat">
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<LyxUiButton link="https://dashboard.litlyx.com" target="_blank" class="button" type="primary">
|
||||
Start for free
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
|
||||
|
||||
<div class="subtitle">
|
||||
Our users loves Litlyx's simplicity
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<div class="mt-10 flex flex-col gap-4 lg:grid lg:grid-cols-2">
|
||||
<Testimonial class="w-full h-full" name="Victoria - CEO" sub="Founder" link-text="@DeckX"
|
||||
link="https://deckx.app"
|
||||
text="Just one word: WOW! Easy to use. If I need to check my metrics, I open Litlyx. I love it.">
|
||||
</Testimonial>
|
||||
<Testimonial class="w-full h-full" name="Alessio - CEO" sub="Founder" link-text="@NuvolAI"
|
||||
link="https://nuvol.ai"
|
||||
text="I instantly loved Litlyx because it is simple. I integrated their universal script in PHP; it was seamless. I will start to track events very soon!
|
||||
One of my clients said to me, 'We open only Litlyx to keep an eye on referrers from our campaigns.">
|
||||
</Testimonial>
|
||||
<Testimonial class="w-full h-full" name="Liam - Business Owner" sub=""
|
||||
link-text="@Antichi Casali Camper Shop" link="https://www.antichicasalicampershop.it"
|
||||
text="We needed to track metrics, but we didn’t know what to use. Than Alessio presented us Litlyx. We was Enthusiasts and paid the 9,99 subscription. We are happy about the service they provide for our online Ecommerce selling Camper equipments.">
|
||||
</Testimonial>
|
||||
|
||||
</div>
|
||||
</ClientOnly>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="subtitle">
|
||||
Powered by <br> open-source
|
||||
</div>
|
||||
|
||||
<div class="paragraph">
|
||||
Completely self-hostable with Docker.
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
<img class="w-[40%]" :src="'docker.png'" alt="Self-Host">
|
||||
</div>
|
||||
|
||||
<div class="button-container flex-col items-center gap-4 !mt-10">
|
||||
<LyxUiButton link="https://github.com/Litlyx/litlyx" target="_blank" class="button"
|
||||
type="secondary">
|
||||
Leave a Star on Github!
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-center mt-20 z-[10] relative items-center flex-col gap-6">
|
||||
<Analyst></Analyst>
|
||||
<div class="section">
|
||||
<div class="subtitle">
|
||||
Why choose Litlyx
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex justify-center mt-20 z-[10] relative items-center flex-col gap-6">
|
||||
<Testimonials>
|
||||
</Testimonials>
|
||||
</div> -->
|
||||
<div class="paragraph">
|
||||
Litlyx vs Plausible vs Google Analytics
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-40 z-[10] relative items-center flex-col gap-6">
|
||||
<div class="poppins font-bold text-[2.2rem] lg:text-[3rem] text-text"> Why Choose Litlyx </div>
|
||||
<div ref="autoscroll"
|
||||
class="flex gap-8 flex-row lg:flex-col overflow-x-auto overflow-y-hidden lg:overflow-hidden w-full hide-scroll px-6">
|
||||
<div class="flex justify-center gap-8">
|
||||
<HomeCard title="1-Minute Setup" text="Effortlessly set up and start collecting KPIs in seconds."
|
||||
icon="far fa-clock">
|
||||
</HomeCard>
|
||||
<HomeCard title="Real-Time Insights" text="Immediately visualize visits & events on your Dashboard."
|
||||
icon="far fa-line-chart">
|
||||
</HomeCard>
|
||||
<HomeCard title="Custom Events" text="Tailor your user experience tracking with custom events."
|
||||
icon="far fa-tools">
|
||||
</HomeCard>
|
||||
</div>
|
||||
<div class="flex justify-center gap-8">
|
||||
<HomeCard title="Start for Free" text="Try Litlyx with 3k FREE Visits & Events for your website."
|
||||
icon="far fa-gift">
|
||||
</HomeCard>
|
||||
<HomeCard title="Open-Source" text="Litlyx is transparent, Self-Hostable & Open-Source."
|
||||
icon="far fa-globe">
|
||||
</HomeCard>
|
||||
<HomeCard title="Cost-Effective" text="Get more for less with Litlyx, without breaking the bank."
|
||||
icon="far fa-wallet">
|
||||
</HomeCard>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<LyxUiButton link="/why-choose-litlyx" target="_blank" class="button" type="outline">
|
||||
Read more
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="subtitle">
|
||||
Update me!
|
||||
</div>
|
||||
|
||||
<div class="paragraph">
|
||||
Litlyx is in beta version. We will keep you updated with our latest news.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-8">
|
||||
<LyxUiInput placeholder="Insert email" v-model="email" class="text-[1.1rem] w-full py-2 px-3">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<LyxUiButton class="button" type="primary">
|
||||
Keep me updated
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.section {
|
||||
@apply mt-20
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply font-medium text-[1rem] px-6 py-2
|
||||
}
|
||||
|
||||
.button-container {
|
||||
@apply flex justify-center mt-8
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-family: 'Poppins' !important;
|
||||
@apply font-semibold text-[3rem] text-center leading-[3.5rem]
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
font-family: 'Poppins' !important;
|
||||
@apply text-center text-lyx-text-dark mt-4 text-[1.1rem]
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'Poppins' !important;
|
||||
@apply text-center font-semibold text-[1.8rem]
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.headline {
|
||||
@apply text-[4rem]
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@apply text-[2.3rem]
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
@apply text-[1.5rem]
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply mt-32
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.footer * {
|
||||
font-family: "Poppins";
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ definePageMeta({ layout: 'header' });
|
||||
exclusively with crucial services required for app functionality.
|
||||
</div>
|
||||
<div>
|
||||
LitLyx Analytics (SaaS) adheres strictly to GDPR, CCPA, PECR, and other relevant privacy standards both on
|
||||
LitLyx Analytics (SaaS) adheres strictly to GDPR and other relevant privacy standards both on
|
||||
our site and within our analytics tool. We prioritize the confidentiality of your information—it belongs to
|
||||
you, not us. This policy details the types of data we gather, the handling process, and your rights over
|
||||
your data. We are committed to never selling your data—this has always been our stance.
|
||||
|
||||
384
landing/pages/why-choose-litlyx.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({ layout: 'header' });
|
||||
|
||||
|
||||
const featureStyle = 'font-bold poppins text-lyx-text';
|
||||
const cellStyle = 'poppins text-lyx-text'
|
||||
const activeStyle = 'poppins !text-lyx-text border-solid border-[1px] border-green-400 bg-green-400/5'
|
||||
|
||||
|
||||
const columns = [{
|
||||
key: 'feature',
|
||||
label: 'Feature'
|
||||
}, {
|
||||
key: 'litlyx',
|
||||
label: 'Litlyx'
|
||||
}, {
|
||||
key: 'plausible',
|
||||
label: 'Plausible'
|
||||
}, {
|
||||
key: 'google',
|
||||
label: 'Google Analytics'
|
||||
}]
|
||||
|
||||
const comparisons = ref<any[]>([
|
||||
{
|
||||
feature: { text: 'Setup Time', class: featureStyle },
|
||||
litlyx: { text: '30 seconds', class: activeStyle },
|
||||
plausible: { text: 'A few Minutes', class: cellStyle },
|
||||
google: { text: 'Hours', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Real-Time Collection', class: featureStyle },
|
||||
litlyx: { text: 'Instant', class: activeStyle },
|
||||
plausible: { text: 'each 5 minutes', class: cellStyle },
|
||||
google: { text: 'from few minutes, to hours', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Documentation', class: featureStyle },
|
||||
litlyx: { text: 'Small, Easy & Modern', class: activeStyle },
|
||||
plausible: { text: 'Longer than necessary', class: cellStyle },
|
||||
google: { text: 'Mstodon & Complex', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Modern UI', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'Yes', class: activeStyle },
|
||||
google: { text: 'Yes', class: activeStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Developers Centric Approach', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'Partially', class: cellStyle },
|
||||
google: { text: 'No', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Website Loading Time', class: featureStyle },
|
||||
litlyx: { text: '3 ms', class: activeStyle },
|
||||
plausible: { text: '3 ms', class: activeStyle },
|
||||
google: { text: '5-15 ms', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Library Size', class: featureStyle },
|
||||
litlyx: { text: '3 kb', class: '' },
|
||||
plausible: { text: '2 kb', class: activeStyle },
|
||||
google: { text: 'avg. 50 kb', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Custom Events Support', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'Yes', class: activeStyle },
|
||||
google: { text: 'Yes', class: activeStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Native Js Runtimes', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'No', class: cellStyle },
|
||||
google: { text: 'No', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Custom Events metadata Support', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'No', class: cellStyle },
|
||||
google: { text: 'Limited', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Raw Data Export', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'No', class: cellStyle },
|
||||
google: { text: 'No', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Data Ownership', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'Yes', class: activeStyle },
|
||||
google: { text: 'No', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Open-Source', class: featureStyle },
|
||||
litlyx: { text: 'Self-Hostable', class: activeStyle },
|
||||
plausible: { text: 'Self-Hostable', class: activeStyle },
|
||||
google: { text: 'No', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Privacy-focused', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'Yes', class: activeStyle },
|
||||
google: { text: 'No', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Cookies Stored', class: featureStyle },
|
||||
litlyx: { text: '0 Cookies', class: activeStyle },
|
||||
plausible: { text: '0 Cookies', class: activeStyle },
|
||||
google: { text: 'Yes, many', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'EU standard', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'Yes', class: activeStyle },
|
||||
google: { text: 'Limited', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Ad Blockers', class: featureStyle },
|
||||
litlyx: { text: 'Impossible to block', class: activeStyle },
|
||||
plausible: { text: 'Possible, but hard!', class: cellStyle },
|
||||
google: { text: 'Possible', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'AI Data Analyst Assistant', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'No', class: cellStyle },
|
||||
google: { text: 'No', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Report Generation', class: featureStyle },
|
||||
litlyx: { text: 'Detailed reports in seconds', class: activeStyle },
|
||||
plausible: { text: 'Basic Reports', class: cellStyle },
|
||||
google: { text: 'Extensive, but complex to do', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'User-Friendly interface', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'Yes', class: activeStyle },
|
||||
google: { text: 'Moderate Complexity', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Agglomeration from third parties DB’s', class: featureStyle },
|
||||
litlyx: { text: 'Yes', class: activeStyle },
|
||||
plausible: { text: 'No', class: cellStyle },
|
||||
google: { text: 'No', class: cellStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Free Options (Cloud)', class: featureStyle },
|
||||
litlyx: { text: 'Free-Forever plan to start.', class: activeStyle },
|
||||
plausible: { text: '30 days free trial.', class: cellStyle },
|
||||
google: { text: 'Free forever. No Ownership.', class: activeStyle }
|
||||
},
|
||||
{
|
||||
feature: { text: 'Cost Effective (Cloud)', class: featureStyle },
|
||||
litlyx: { text: 'Basic plan: €4,99/mo for 50k visits/events', class: activeStyle },
|
||||
plausible: { text: 'Basic plan: €9,00/mo for 10k page visits', class: cellStyle },
|
||||
google: { text: 'Offers Enterprise only Premium prices (really expansive)', class: cellStyle }
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-8 lg:px-40">
|
||||
|
||||
<div class="section">
|
||||
<div class="title">
|
||||
Why Choose Litlyx
|
||||
</div>
|
||||
|
||||
<div class="paragraph">
|
||||
Our product is simple and focuses on the developer's experience. Developers love free stuff, so Litlyx
|
||||
offers a generous Free-Forever plan to get started. Developers don't want to waste time setting up
|
||||
complex analytics to track key metrics for their websites. So, we asked ourselves, 'What would
|
||||
developers find easy and cool?' As a team of developers, we created the simplest, most optimized, and
|
||||
developer-friendly way to do analytics.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="title">
|
||||
Litlyx vs Plausible vs Google Analytics Comparison Table
|
||||
</div>
|
||||
|
||||
<div class="paragraph">
|
||||
Comparisons between the most common softwares used for web analytics collection and Litlyx.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="section">
|
||||
|
||||
<div class="hidden lg:flex lg:justify-center w-full lg:flex-nowrap">
|
||||
<UTable :rows="comparisons" :columns="columns" :ui="{
|
||||
td: {
|
||||
base: 'border-x-[1px] border-solid border-lyx-widget-lighter'
|
||||
},
|
||||
th: {
|
||||
base: 'border-[1px] border-solid border-lyx-widget-lighter bg-lyx-widget'
|
||||
}
|
||||
}">
|
||||
|
||||
<template #litlyx-data="{ row }">
|
||||
{{ row.litlyx.text }}
|
||||
</template>
|
||||
<template #feature-data="{ row }">
|
||||
{{ row.feature.text }}
|
||||
</template>
|
||||
<template #plausible-data="{ row }">
|
||||
{{ row.plausible.text }}
|
||||
</template>
|
||||
<template #google-data="{ row }">
|
||||
{{ row.google.text }}
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden text-lyx-text-dark">
|
||||
[Table available only on desktop]
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="section">
|
||||
<div class="title">
|
||||
Google Analytics vs. Litlyx
|
||||
</div>
|
||||
<ul class="list-disc text-lyx-text-dark mt-4">
|
||||
<li>
|
||||
Ease of Use: Google Analytics can be complex, with many features that may be overwhelming when
|
||||
reading its documentation. Litlyx is simple and easy to use.
|
||||
</li>
|
||||
<li>
|
||||
Setup: Google Analytics requires more time to set up, whereas Litlyx is quick and easy to set up
|
||||
(just 30 seconds).
|
||||
</li>
|
||||
<li>
|
||||
Free Plan: Google Analytics offers a free plan, but you do not own your data. Litlyx offers a
|
||||
Free-Forever plan with generous limits, and you can handle raw data.
|
||||
</li>
|
||||
<li>
|
||||
Developer Focus: Google Analytics is for general users, with libraries written by third parties.
|
||||
Litlyx is built with developers in mind, supporting all tech stacks natively.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="section">
|
||||
<div class="title">
|
||||
Plausible vs. Litlyx <span class="text-[.9rem]">(we ❤️ Plausible)</span>
|
||||
</div>
|
||||
<ul class="list-disc text-lyx-text-dark mt-4">
|
||||
<li>
|
||||
Ease of Use: Plausible is simple and user-friendly. Litlyx is also simple and easy to use. Both
|
||||
Plausible and Litlyx are awesome!
|
||||
</li>
|
||||
<li>
|
||||
Setup: Plausible setup is quick and easy. Litlyx setup is equally quick and straightforward.
|
||||
</li>
|
||||
<li>
|
||||
Free Plan: Plausible does not offer a free plan, giving you a 30-day free trial before you need to
|
||||
pay. Litlyx offers a generous Free-Forever plan.
|
||||
</li>
|
||||
<li>
|
||||
|
||||
Developer Focus: Plausible is designed for general users with a focus on privacy, whereas Litlyx is
|
||||
specifically designed for developers.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="section">
|
||||
<div class="title">
|
||||
Who can use Litlyx?
|
||||
</div>
|
||||
<div class="paragraph">
|
||||
We built Litlyx with developers in mind. If you want really good metrics, you should go through your
|
||||
development team. We created the simplest setup a developer can ask for, supporting all JavaScript and
|
||||
TypeScript runtimes. Litlyx can be used even by startup founders or business owners who have access to
|
||||
their landing page. We offer extensive support to guide you through the implementation. You can contact
|
||||
us anytime for information at help@litlyx.com. With Litlyx, you will not be alone.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="title">
|
||||
We are far from perfection...
|
||||
</div>
|
||||
<div class="paragraph">
|
||||
We are far from perfect. We are human, and we embrace that. Our imperfections drive us to always improve
|
||||
and innovate. We learn from our mistakes and use those lessons to create better solutions. By
|
||||
acknowledging our humanity, we connect more deeply with our users, understanding their needs and
|
||||
challenges. Our commitment to growth and improvement ensures that we constantly strive to deliver the
|
||||
best possible experience for developers.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="section">
|
||||
<div class="title">
|
||||
Is Impossible to Beat a giant, but...
|
||||
</div>
|
||||
<div class="paragraph">
|
||||
We know we can’t compete with Google directly—it’s like trying to challenge a giant. But even giants
|
||||
have weaknesses. Startups with bold ideas can find ways to disrupt the status quo and we see this many
|
||||
times in history. We’re here to support all innovators by providing a first-class developer experience
|
||||
with our product. Our goal is to empower every size companies to make a significant impact in the
|
||||
industry taking matrics driven decisions with semplicity.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="title">
|
||||
Help us improve!
|
||||
</div>
|
||||
<div class="paragraph">
|
||||
If you choose us today, you can help us improve and become first-class in analytics. We can’t do this
|
||||
alone; we need you! Your feedback and suggestions are very valuable to us. By working together, we can
|
||||
refine our product and create an exceptional experience for developers. Join us on this journey, and
|
||||
let's build something amazing together. Your input will help us reach new heights and set the standard
|
||||
of excellence next-generation developers deserve.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
|
||||
<div class="title text-center">
|
||||
Ready to start with Litlyx?
|
||||
</div>
|
||||
|
||||
<div class="button-container flex-col items-center gap-6">
|
||||
<LyxUiButton link="https://dashboard.litlyx.com/live_demo" target="_blank" class="button" type="outline">
|
||||
Go to Live Demo
|
||||
</LyxUiButton>
|
||||
<LyxUiButton link="https://dashboard.litlyx.com" target="_blank" class="button" type="primary">
|
||||
Start for free now
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.title {
|
||||
font-family: 'Poppins' !important;
|
||||
@apply font-semibold text-[1.8rem]
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'Poppins' !important;
|
||||
@apply font-medium text-[1.4rem]
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
font-family: 'Poppins' !important;
|
||||
@apply text-lyx-text-dark mt-4 text-[1.1rem]
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply mt-20
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply font-medium text-[1rem] px-6 py-2
|
||||
}
|
||||
|
||||
.button-container {
|
||||
@apply flex justify-center mt-8
|
||||
}
|
||||
|
||||
</style>
|
||||
BIN
landing/public/ai-chat.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
landing/public/db-connect.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
landing/public/docker.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
landing/public/placeholder.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
landing/public/screenshot.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
landing/public/selfhost.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
landing/public/techs-new.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
@@ -19,7 +19,49 @@ module.exports = {
|
||||
light: '#2c91ed',
|
||||
sub: '#99A7F1',
|
||||
},
|
||||
"lyx-primary": {
|
||||
DEFAULT: '#5680F8',
|
||||
dark: '#222A42',
|
||||
hover: '#2A3450'
|
||||
},
|
||||
"lyx-text": {
|
||||
DEFAULT: '#FFFFFF',
|
||||
dark: '#D4D4D4',
|
||||
darker: '#6A6A6A'
|
||||
},
|
||||
"lyx-widget": {
|
||||
DEFAULT: '#151515',
|
||||
light: '#1E1E1E',
|
||||
lighter: '#262626'
|
||||
},
|
||||
"lyx-background": {
|
||||
DEFAULT: '#0A0A0A',
|
||||
light: '#121212',
|
||||
lighter: '#212121'
|
||||
},
|
||||
"lyx-danger": {
|
||||
DEFAULT: '#F86956',
|
||||
dark: '#4A2D29'
|
||||
},
|
||||
"lyx-chart": {
|
||||
purple: {
|
||||
DEFAULT: '#5655D7',
|
||||
dark: '#282844'
|
||||
},
|
||||
green: {
|
||||
DEFAULT: '#1D9B86',
|
||||
dark: '#213734'
|
||||
},
|
||||
cyan: {
|
||||
DEFAULT: '#4ABDE8',
|
||||
dark: '#273D48'
|
||||
},
|
||||
orange: {
|
||||
DEFAULT: '#F56524',
|
||||
dark: '#492C22'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||