19 Commits

Author SHA1 Message Date
Emily
9de299d841 adjust dashboard 2024-11-13 15:44:20 +01:00
Emily
2929b229c4 add domain wipe 2024-11-11 16:54:02 +01:00
Emily
f06d7d78fc add code redeem 2024-11-08 15:14:09 +01:00
Emily
4d7cfbb7b9 adding support for appsumo codes 2024-11-07 17:06:53 +01:00
Emily
b4c0620f17 adjust admin dashboard 2024-11-06 17:25:48 +01:00
Emily
b8c2e40f7a set 20 max projects 2024-11-06 16:29:25 +01:00
Emily
e866a1c22b add updated_at 2024-11-06 16:29:19 +01:00
Emily
f86a399840 fix snapshots 2024-11-01 15:47:43 +01:00
antonio
36c4406af2 changed asset 2024-10-29 18:03:34 +01:00
Antonio Verdiglione
b2afd585bb Merge pull request #19 from fr0st-iwnl/main
fix: correct language display in admin dashboard
2024-10-29 17:58:35 +01:00
Antonio Verdiglione
24ae9d0e0d Merge branch 'main' into main 2024-10-29 17:58:24 +01:00
Emily
b479ca1bbf committo tutto 2024-10-29 17:51:32 +01:00
Emily
0a748346c5 Fix responsiveness + other things 2024-10-29 15:51:30 +01:00
fr0st-iwnl
fa7880552a fix: language correction 2024-10-29 04:22:38 +02:00
Emily
06fb8bfab0 Merge branch 'main' of https://github.com/Litlyx/litlyx 2024-10-15 13:30:38 +02:00
Emily
a876d77d42 fix trends + stacked chart 2024-10-15 13:30:36 +02:00
Antonio Verdiglione
e6bb58693f Merge pull request #18 from eltociear/patch-2
docs: update README.md
2024-10-14 14:22:03 +02:00
Emily
00e63cc80b fix funnel chart + live demo 2024-10-14 14:14:23 +02:00
Ikko Eltociear Ashimine
e43f138945 docs: update README.md
enviroments -> environments
2024-10-11 08:18:09 +09:00
65 changed files with 1185 additions and 302 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
steps steps
PROCESS_EVENT PROCESS_EVENT
**/node_modules/
docker docker
dev dev
docker-compose.admin.yml docker-compose.admin.yml

View File

@@ -43,7 +43,7 @@ You can install Litlyx using `npm`, `pnpm`, `yarn` or any modern package manager
npm i litlyx-js npm i litlyx-js
``` ```
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works in serverless enviroments with Cloud (or Edge) Functions. Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works in serverless environments with Cloud (or Edge) Functions.
<p align="center"> <p align="center">
<img src="assets/tech.png" /> <img src="assets/tech.png" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -67,6 +67,9 @@ const { visible } = usePricingDrawer();
</div> </div>
</div> </div>
<UModals />
<NuxtLayout> <NuxtLayout>
<NuxtPage></NuxtPage> <NuxtPage></NuxtPage>
</NuxtLayout> </NuxtLayout>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined; export type IconProvider = (e: { _id: string, count: string } & any) => ['img' | 'icon', string] | undefined;
type Props = { type Props = {
@@ -80,7 +80,7 @@ function openExternalLink(link: string) {
</div> </div>
</div> </div>
<div> <div class="h-full flex flex-col">
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4"> <div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center"> <div v-if="isDetailView" class="flex items-center justify-center">
@@ -111,13 +111,13 @@ function openExternalLink(link: string) {
:style="'width:' + 100 / maxData * element.count + '%;'"></div> :style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4"> <div class="flex px-2 py-1 relative items-center gap-4">
<div v-if="iconProvider && iconProvider(element._id) != undefined" <div v-if="iconProvider && iconProvider(element) != undefined"
class="flex items-center h-[1.3rem]"> class="flex items-center h-[1.3rem]">
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full" <img v-if="iconProvider(element)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element._id)?.[1]"> :style="customIconStyle" :src="iconProvider(element)?.[1]">
<i v-else :class="iconProvider(element._id)?.[1]"></i> <i v-else :class="iconProvider(element)?.[1]"></i>
</div> </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 }} {{ elementTextTransformer?.(element._id) || element._id }}
@@ -125,15 +125,14 @@ function openExternalLink(link: string) {
</div> </div>
</div> </div>
</div> </div>
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{ <div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
formatNumberK(element.count) }} </div> formatNumberK(element.count) }} </div>
</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-light text-[1.1rem]">
No data yet No data yet
</div> </div>
</div> </div>
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 "> <div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow">
<div @click="$emit('showMore')" <div @click="$emit('showMore')"
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]"> class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
Show more Show more

View File

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

View File

@@ -1,6 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { IconProvider } from './Base.vue';
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
if (e._id === 'desktop') return ['icon','far fa-desktop'];
if (e._id === 'tablet') return ['icon','far fa-tablet'];
if (e._id === 'mobile') return ['icon','far fa-mobile'];
if (e._id === 'smarttv') return ['icon','far fa-tv'];
if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
return ['icon', 'far fa-question']
}
function transform(data: { _id: string, count: number }[]) { function transform(data: { _id: string, count: number }[]) {
console.log(data); console.log(data);
return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : e._id })) return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : e._id }))
@@ -34,9 +46,9 @@ async function showMore() {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []" <BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []"
:dataIcons="false" desc="The devices most used to access your website." :loading="devicesData.pending.value" :iconProvider="iconProvider" :dataIcons="true" desc="The devices most used to access your website."
label="Top Devices" sub-label="Devices"></BarCardBase> :loading="devicesData.pending.value" label="Devices" sub-label="Devices"></BarCardBase>
</div> </div>
</template> </template>

View File

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

View File

@@ -31,6 +31,6 @@ async function showMore() {
<div class="flex flex-col gap-2 h-full"> <div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []" <BarCardBase @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false" desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
:loading="ossData.pending.value" label="Top OS" sub-label="OSs"></BarCardBase> :loading="ossData.pending.value" label="OS" sub-label="OSs"></BarCardBase>
</div> </div>
</template> </template>

View File

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

View File

@@ -56,7 +56,7 @@ const { createAlert } = useAlert()
async function deleteSnapshot(close: () => any) { async function deleteSnapshot(close: () => any) {
await $fetch("/api/snapshot/delete", { await $fetch("/api/snapshot/delete", {
method: 'DELETE', method: 'DELETE',
...signHeaders({ 'Content-Type': 'application/json' }), headers: useComputedHeaders({ useSnapshotDates: false }).value,
body: JSON.stringify({ body: JSON.stringify({
id: snapshot.value._id.toString(), id: snapshot.value._id.toString(),
}) })
@@ -71,11 +71,7 @@ async function generatePDF() {
try { try {
const res = await $fetch<Blob>('/api/project/generate_pdf', { const res = await $fetch<Blob>('/api/project/generate_pdf', {
...signHeaders({ headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'x-snapshot-name': snapshot.value.name } }).value,
'x-snapshot-name': snapshot.value.name,
'x-from': snapshot.value.from.toISOString(),
'x-to': snapshot.value.to.toISOString(),
}),
responseType: 'blob' responseType: 'blob'
}); });
@@ -144,11 +140,20 @@ const pricingDrawer = usePricingDrawer();
<LyxUiButton to="/project_creation" v-if="projectList && (projectList.length < (maxProjects || 1))" <LyxUiButton to="/project_creation" v-if="projectList && (projectList.length < (maxProjects || 1))"
type="outlined" class="w-full py-1 mt-2 text-[.8rem]"> type="outlined" class="w-full py-1 mt-2 text-[.8rem]">
<div class="flex items-center gap-2 justify-center"> <div class="flex items-center gap-2 justify-center">
<div><i class="fas fa-plus"></i></div> <div><i class="fas fa-plus text-[.7rem]"></i></div>
<div> Create new project </div> <div class="poppins"> New Project </div>
</div> </div>
</LyxUiButton> </LyxUiButton>
<LyxUiButton v-if="projectList && (projectList.length >= (maxProjects || 1))" type="outlined"
class="w-full py-1 mt-2 text-[.7rem]">
<div class="flex items-center gap-2 justify-center">
<div><i class="text-lyx-text-darker far fa-lock"></i></div>
<div class="text-lyx-text-darker"> Projects limit reached </div>
</div>
</LyxUiButton>
</div> </div>
@@ -203,12 +208,12 @@ const pricingDrawer = usePricingDrawer();
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2"> <div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
<div class="flex gap-1 items-center justify-center text-lyx-text-dark"> <div class="flex gap-1 items-center justify-center text-lyx-text-dark">
<div class="poppins"> <div class="poppins">
{{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim().replace(/\//g, '-') {{ new Date(snapshot.from).toLocaleString().split(',')[0].trim()
}} }}
</div> </div>
<div class="poppins"> to </div> <div class="poppins"> to </div>
<div class="poppins"> <div class="poppins">
{{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim().replace(/\//g, '-') }} {{ new Date(snapshot.to).toLocaleString().split(',')[0].trim() }}
</div> </div>
</div> </div>
@@ -243,7 +248,7 @@ const pricingDrawer = usePricingDrawer();
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }"> <div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
<div v-if="(!entry.adminOnly || (userRoles.isAdmin && !isAdminHidden))" <div v-if="(!entry.adminOnly || (userRoles.isAdmin.value && !isAdminHidden))"
class="bg-lyx-background w-full cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center" class="bg-lyx-background w-full cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
:class="{ :class="{
'!text-lyx-text-darker pointer-events-none': entry.disabled, '!text-lyx-text-darker pointer-events-none': entry.disabled,
@@ -259,7 +264,7 @@ const pricingDrawer = usePricingDrawer();
<div class="manrope grow"> <div class="manrope grow">
{{ entry.label }} {{ entry.label }}
</div> </div>
<div v-if="entry.premiumOnly && !userRoles.isPremium" class="flex items-center"> <div v-if="entry.premiumOnly && !userRoles.isPremium.value" class="flex items-center">
<i class="fal fa-lock"></i> <i class="fal fa-lock"></i>
</div> </div>
</NuxtLink> </NuxtLink>
@@ -293,7 +298,8 @@ const pricingDrawer = usePricingDrawer();
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark"> class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-dev"></i> <i class="fab fa-dev"></i>
</NuxtLink> --> </NuxtLink> -->
<NuxtLink to="/admin" v-if="userRoles.isAdmin"
<NuxtLink to="/admin" v-if="userRoles.isAdmin.value"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark"> class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fas fa-cat"></i> <i class="fas fa-cat"></i>
</NuxtLink> </NuxtLink>

View File

@@ -7,6 +7,8 @@ import 'highlight.js/styles/stackoverflow-dark.css';
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import CardTitled from './CardTitled.vue'; import CardTitled from './CardTitled.vue';
import { Lit } from 'litlyx-js';
const props = defineProps<{ const props = defineProps<{
firstInteraction: boolean, firstInteraction: boolean,
refreshInteraction: () => any refreshInteraction: () => any
@@ -19,6 +21,7 @@ onMounted(() => {
function copyProjectId() { function copyProjectId() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP'); if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText(project.value?._id?.toString() || ''); navigator.clipboard.writeText(project.value?._id?.toString() || '');
Lit.event('no_visit_copy_id');
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000); createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
} }
@@ -36,6 +39,7 @@ function copyScript() {
].join('') ].join('')
} }
Lit.event('no_visit_copy_script');
navigator.clipboard.writeText(createScriptText()); navigator.clipboard.writeText(createScriptText());
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000); createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
} }
@@ -53,6 +57,7 @@ const scriptText = computed(() => {
function reloadPage() { function reloadPage() {
location.reload(); location.reload();
} }
</script> </script>
<template> <template>
@@ -76,26 +81,32 @@ function reloadPage() {
<div class="flex items-center justify-center mt-10"> <div class="flex items-center justify-center mt-10">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="flex gap-6">
<div> <div class="flex gap-6 xl:flex-row flex-col">
<CardTitled class="h-full" title="Tutorial" sub="Coming soon. For now enjoy our launch video.">
<div class="flex items-center justify-center h-full"> <div class="h-full w-full">
<iframe width="560" height="315" <CardTitled class="h-full w-full xl:min-w-[500px]" title="Tutorial"
sub="Coming soon. For now enjoy our launch video.">
<div class="flex items-center justify-center h-full w-full">
<iframe class="w-full h-full min-h-[400px]"
src="https://www.youtube.com/embed/GntyWMR7jsY?si=YGGkQwrk6-Iqmn8w" title="Litlyx" src="https://www.youtube.com/embed/GntyWMR7jsY?si=YGGkQwrk6-Iqmn8w" title="Litlyx"
frameborder="0" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div> </div>
</CardTitled> </CardTitled>
</div> </div>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div> <div class="w-full">
<CardTitled title="Quick Integration" <CardTitled title="Quick Integration"
sub="Start tracking web analytics in one line. (works everywhere js is supported)"> sub="Start tracking web analytics in one line. (works everywhere js is supported)">
<div class="flex flex-col items-end gap-4"> <div class="flex flex-col items-end gap-4">
<div class="w-full"> <div class="w-full xl:text-[1rem] text-[.8rem]">
<pre><code class="language-html">{{ scriptText }}</code></pre> <pre><code class="language-html">{{ scriptText }}</code></pre>
</div> </div>
<LyxUiButton type="secondary" @click="copyScript()"> <LyxUiButton type="secondary" @click="copyScript()">
@@ -122,7 +133,7 @@ function reloadPage() {
<CardTitled class="w-full h-full" title="Documentation" <CardTitled class="w-full h-full" title="Documentation"
sub="Learn how to use Litlyx in every tech stack"> sub="Learn how to use Litlyx in every tech stack">
<div class="flex flex-col items-end"> <div class="flex flex-col items-end">
<div class="flex justify-center w-full"> <div class="justify-center w-full hidden xl:flex">
<svg width="680" height="100" viewBox="0 0 680 100" fill="none" <svg width="680" height="100" viewBox="0 0 680 100" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_473_1361" fill="white"> <mask id="path-1-inside-1_473_1361" fill="white">
@@ -250,7 +261,9 @@ function reloadPage() {
</defs> </defs>
</svg> </svg>
</div> </div>
<LyxUiButton type="secondary" to="https://docs.litlyx.com"> Visit documentation <LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com">
Visit documentation
</LyxUiButton> </LyxUiButton>
</div> </div>
</CardTitled> </CardTitled>
@@ -263,7 +276,7 @@ function reloadPage() {
<!-- <div class="flex justify-center gap-10 flex-col lg:flex-row items-center lg:items-stretch px-10"> <!-- <div class="flex justify-center gap-10 flex-col xl:flex-row items-center xl:items-stretch px-10">
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full"> <div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
<div class="poppins font-semibold"> Copy your project_id: </div> <div class="poppins font-semibold"> Copy your project_id: </div>
@@ -273,7 +286,7 @@ function reloadPage() {
</div> </div>
</div> </div>
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full lg:max-w-[40vw]"> <div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full xl:max-w-[40vw]">
<div class="poppins font-semibold"> <div class="poppins font-semibold">
Start logging visits in 1 click | Plug anywhere ! Start logging visits in 1 click | Plug anywhere !
</div> </div>

View File

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

View File

@@ -251,7 +251,7 @@ const legendClasses = ref<string[]>([
</SelectButton> </SelectButton>
</template> </template>
<div class="flex gap-6 w-full justify-between"> <div class="flex gap-6 w-full justify-between lg:flex-row flex-col">
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo"> <LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
<div class="flex items-center gap-2 px-10"> <div class="flex items-center gap-2 px-10">
<i class="far fa-sparkles text-yellow-400"></i> <i class="far fa-sparkles text-yellow-400"></i>

View File

@@ -21,14 +21,24 @@ const chartSlice = computed(() => {
function transformResponse(input: { _id: string, count: number }[]) { function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count || 0); const data = input.map(e => e.count || 0);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value)); const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
const pool = [...input.map(e => e.count || 0)]; const pool = [...input.map(e => e.count || 0)];
pool.pop();
const avg = pool.reduce((a, e) => a + e, 0) / pool.length; const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
const diffPercent: number = (100 / avg * (input.at(-1)?.count || 0)) - 100;
const targets = input.slice(Math.floor(input.length / 4 * 3));
const targetAvg = targets.reduce((a, e) => a + e.count, 0) / targets.length;
const diffPercent: number = (100 / avg * (targetAvg)) - 100;
const trend = Math.max(Math.min(diffPercent, 99), -99); const trend = Math.max(Math.min(diffPercent, 99), -99);
return { data, labels, trend } return { data, labels, trend }
} }
const visitsData = useFetch('/api/timeline/visits', { const visitsData = useFetch('/api/timeline/visits', {
@@ -94,7 +104,7 @@ const avgSessionDuration = computed(() => {
<template> <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"> <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.pending.value" icon="far fa-earth" text="Total page visits" <DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total visits"
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend" :avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7"> :data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
@@ -106,7 +116,7 @@ const avgSessionDuration = computed(() => {
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visits sessions" <DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visitors"
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')" :value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend" :avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8"> :data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
@@ -114,7 +124,7 @@ const avgSessionDuration = computed(() => {
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" <DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
text="Total avg session time" :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend" text="Visit duration" :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels" :data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
color="#f56523"> color="#f56523">
</DashboardCountCard> </DashboardCountCard>

View File

@@ -42,7 +42,7 @@ const { createAlert } = useAlert()
async function confirmSnapshot() { async function confirmSnapshot() {
await $fetch("/api/snapshot/create", { await $fetch("/api/snapshot/create", {
method: 'POST', method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }), headers: useComputedHeaders({ useSnapshotDates: false }).value,
body: JSON.stringify({ body: JSON.stringify({
name: snapshotName.value, name: snapshotName.value,
color: currentColor.value, color: currentColor.value,

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel'])
const props = defineProps<{
buttonType: string,
message: string,
deleteData: { isAll: boolean, visits: boolean, sessions: boolean, events: boolean, domain: string }
}>();
const isDone = ref<boolean>(false);
const canDelete = ref<boolean>(false);
async function deleteData() {
try {
if (props.deleteData.isAll) {
await $fetch('/api/settings/delete_all', {
method: 'DELETE',
headers: useComputedHeaders({ useSnapshotDates: false }).value,
})
} else {
await $fetch('/api/settings/delete_domain', {
method: 'DELETE',
headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'Content-Type': 'application/json' } }).value,
body: JSON.stringify({
domain: props.deleteData.domain,
visits: props.deleteData.visits,
sessions: props.deleteData.sessions,
events: props.deleteData.events,
})
})
}
} catch (ex) {
alert('Something went wrong');
console.error(ex);
}
isDone.value = true;
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'bg-lyx-widget',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="font-semibold text-[1.2rem]"> {{ isDone ? "Data Deletion Scheduled" : "Are you sure ?" }}</div>
<div v-if="!isDone">
{{ message }}
</div>
<div v-if="isDone">
Your data deletion request is being processed and will be reflected in your project dashboard within a
few minutes.
</div>
<div class="grow"></div>
<div v-if="!isDone">
<UCheckbox v-model="canDelete" label="Confirm data delete"></UCheckbox>
</div>
<div v-if="!isDone" class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton>
<LyxUiButton :disabled="!canDelete" @click="canDelete ? deleteData() : () => { }" :type="buttonType"> Confirm </LyxUiButton>
</div>
<div v-if="isDone" class="flex justify-end w-full">
<LyxUiButton type="secondary" @click="emit('success')"> Dismiss </LyxUiButton>
</div>
</div>
</UModal>
</template>

View File

@@ -111,7 +111,7 @@ onMounted(async () => {
}); });
const eventsData = useFetch(`/api/data/events`, { const eventsData = useFetch(`/api/data/events`, {
headers: useComputedHeaders(), lazy: true, immediate: false headers: useComputedHeaders(), lazy: true
}); });
const enabledEvents = ref<string[]>([]); const enabledEvents = ref<string[]>([]);
@@ -140,7 +140,7 @@ async function onEventCheck(eventName: string) {
<template> <template>
<CardTitled title="Funnel" <CardTitled title="Funnel"
sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience"> sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience">
<div class="flex gap-2 justify-between"> <div class="flex gap-2 justify-between lg:flex-row flex-col">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="min-w-[20rem] text-lyx-text-darker"> <div class="min-w-[20rem] text-lyx-text-darker">
Select two or more events Select two or more events

View File

@@ -104,7 +104,7 @@ const canSearch = computed(() => {
</USelectMenu> </USelectMenu>
</div> </div>
<div class="text-lyx-text-darker poppins mt-4 flex items-center gap-4"> <div class="text-lyx-text-darker poppins mt-4 flex items-center gap-4 lg:flex-row flex-col">
<div class="w-[10rem]"> <div class="w-[10rem]">
Search results: {{ metadataFieldGroupedFiltered.length }} Search results: {{ metadataFieldGroupedFiltered.length }}
</div> </div>
@@ -119,13 +119,13 @@ const canSearch = computed(() => {
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-2 mt-4"> <div class="flex flex-wrap gap-2 lg:mt-4 mt-10">
<div class="bg-lyx-widget-light text-lyx-text-dark px-3 py-2 rounded-md w-fit" <div class="bg-lyx-widget-light text-lyx-text-dark px-3 py-2 rounded-md w-fit"
v-for="item of metadataFieldGroupedFiltered"> v-for="item of metadataFieldGroupedFiltered">
<div class="flex gap-2"> <div class="flex gap-2 items-center">
<div> {{ item._id || 'OLD_EVENTS' }} </div> <div> {{ item._id || 'OLD_EVENTS' }} </div>
<div> {{ item.count }} </div> <div class="px-1"> {{ item.count }} </div>
</div> </div>
</div> </div>

View File

@@ -14,10 +14,9 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
data: input, data: input,
from: input[0]._id, from: input[0]._id,
to: safeSnapshotDates.value.to to: safeSnapshotDates.value.to
}, slice.value, { },
advanced: true, slice.value,
advancedGroupKey: 'name' { advanced: true, advancedGroupKey: 'name' });
});
const parsedDatasets: any[] = []; const parsedDatasets: any[] = [];
@@ -62,6 +61,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
datasets: parsedDatasets, datasets: parsedDatasets,
labels: fixed.labels labels: fixed.labels
} }
} }
const errorData = ref<{ errored: boolean, text: string }>({ const errorData = ref<{ errored: boolean, text: string }>({
@@ -83,7 +83,7 @@ function onResponse(e: any) {
const eventsStackedData = useFetch(`/api/timeline/events_stacked`, { const eventsStackedData = useFetch(`/api/timeline/events_stacked`, {
lazy: true, immediate: false, lazy: true, immediate: false,
transform: transformResponse, transform: transformResponse,
headers: useComputedHeaders({slice}), headers: useComputedHeaders({ slice }),
onResponseError, onResponseError,
onResponse onResponse
}); });

View File

@@ -62,7 +62,13 @@ async function analyzeEvent() {
</div> </div>
</div> </div>
<div v-if="analyzing"> Analyzing... </div> <div v-if="analyzing">
<div
class="backdrop-blur-[1px] z-[20] w-full h-full flex items-center justify-center font-bold rockmann">
<i
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
</div>
<div class="flex flex-col gap-2" v-if="userFlowData"> <div class="flex flex-col gap-2" v-if="userFlowData">
<div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-widget-light rounded-lg" <div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-widget-light rounded-lg"

View File

@@ -47,7 +47,7 @@ async function onUpgradeClick() {
<div class="flex flex-col gap-3 text-center pt-3"> <div class="flex flex-col gap-3 text-center pt-3">
<div v-if="data.active" <div v-if="data.active"
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-sm"> class="absolute right-6 top-3 poppins text-[.75rem] bg-transparent border-[#262626] border-solid border-[1px] px-3 py-[.1rem] rounded-sm">
Active Active
</div> </div>
<div v-if="!data.active && data.title === 'Growth'" <div v-if="!data.active && data.title === 'Growth'"

View File

@@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { TApiSettings } from '@schema/ApiSettingsSchema';
import type { SettingsTemplateEntry } from './Template.vue';
const { project } = useProject();
const entries: SettingsTemplateEntry[] = [
{ id: 'acodes', title: 'Appsumo codes', text: 'Redeem appsumo codes' },
]
const { createAlert } = useAlert()
const currentCode = ref<string>("");
const redeeming = ref<boolean>(false);
const valid_codes = useFetch('/api/pay/valid_codes', signHeaders({ 'x-pid': project.value?._id.toString() ?? '' }));
async function redeemCode() {
redeeming.value = true;
try {
const res = await $fetch<TApiSettings>('/api/pay/redeem_appsumo_code', {
method: 'POST', ...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ code: currentCode.value })
});
createAlert('Success', 'Code redeem success.', 'far fa-check', 5000);
valid_codes.refresh();
} catch (ex: any) {
createAlert('Error', ex?.response?.statusText || 'Unexpected error. Contact support.', 'far fa-error', 5000);
} finally {
currentCode.value = '';
}
redeeming.value = false;
}
</script>
<template>
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
<template #acodes>
<div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" placeholder="Appsumo code" v-model="currentCode"></LyxUiInput>
<LyxUiButton v-if="!redeeming" :disabled="currentCode.length == 0" @click="redeemCode()" type="primary">
Redeem
</LyxUiButton>
<div v-if="redeeming">
Redeeming...
</div>
</div>
<div class="text-lyx-text-darker mt-1 text-[.9rem] poppins">
Redeemed codes: {{ valid_codes.data.value?.count || '0' }}
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,154 @@
<script lang="ts" setup>
import DeleteDomainData from '../dialog/DeleteDomainData.vue';
import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [
{ id: 'delete_dns', title: 'Delete domain data', text: 'Delete data of a specific domain from this project' },
{ id: 'delete_data', title: 'Delete project data', text: 'Delete all data from this project' },
]
const domains = useFetch('/api/settings/domains', {
headers: useComputedHeaders({ useSnapshotDates: false }),
transform: (e) => {
if (!e) return [];
return e.sort((a, b) => {
return a.count - b.count;
}).map(e => {
return { id: e._id, label: `${e._id} - ${e.count} visits` }
})
}
})
const selectedDomain = ref<{ id: string, label: string }>();
const selectedVisits = ref<boolean>(true);
const selectedSessions = ref<boolean>(true);
const selectedEvents = ref<boolean>(true);
const domainCounts = useFetch(() => `/api/settings/domain_counts?domain=${selectedDomain.value?.id}`, {
headers: useComputedHeaders({ useSnapshotDates: false }),
})
const { setToken } = useAccessToken();
const modal = useModal();
function openDeleteDomainDataDialog() {
modal.open(DeleteDomainData, {
preventClose: true,
deleteData: {
isAll: false,
domain: selectedDomain.value?.id as string,
visits: selectedVisits.value,
sessions: selectedSessions.value,
events: selectedEvents.value,
},
buttonType: 'primary',
message: 'This action is irreversable and will wipe all the data from the selected domain.',
onSuccess: () => {
modal.close()
},
onCancel: () => {
modal.close()
},
});
}
function openDeleteAllDomainDataDialog() {
modal.open(DeleteDomainData, {
preventClose: true,
deleteData: {
isAll: true,
domain: '',
visits: false,
sessions: false,
events: false,
},
buttonType: 'danger',
message: 'This action is irreversable and will wipe all the data from the entire project.',
onSuccess: () => {
modal.close()
},
onCancel: () => {
modal.close()
},
});
}
const visitsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Visits loading...';
if (domainCounts.data.value?.error === true) return 'Visits (too many to compute)';
return 'Visits ' + (domainCounts.data.value?.visits ?? '');
})
const eventsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Events loading...';
if (domainCounts.data.value?.error === true) return 'Events (too many to compute)';
return 'Events ' + (domainCounts.data.value?.events ?? '');
})
const sessionsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Sessions loading...';
if (domainCounts.data.value?.error === true) return 'Sessions (too many to compute)';
return 'Sessions ' + (domainCounts.data.value?.sessions ?? '');
})
</script>
<template>
<SettingsTemplate :entries="entries">
<template #delete_dns>
<div class="flex flex-col">
<!-- <div class="text-[.9rem] text-lyx-text-darker"> Select a domain </div> -->
<USelectMenu placeholder="Select a domain" :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'
}
}" :options="domains.data.value ?? []" v-model="selectedDomain"></USelectMenu>
<div v-if="selectedDomain" class="flex flex-col gap-2 mt-4">
<div class="text-[.9rem] text-lyx-text-dark"> Select data to delete </div>
<div class="flex flex-col gap-1">
<UCheckbox :ui="{ color: 'actionable-visits-color-checkbox' }" v-model="selectedVisits"
:label="visitsLabel" />
<UCheckbox :ui="{ color: 'actionable-sessions-color-checkbox' }" v-model="selectedSessions"
:label="sessionsLabel" />
<UCheckbox :ui="{ color: 'actionable-events-color-checkbox' }" v-model="selectedEvents"
:label="eventsLabel" />
</div>
<LyxUiButton class="mt-2" v-if="selectedVisits || selectedSessions || selectedEvents"
@click="openDeleteDomainDataDialog()" type="outline">
Delete data
</LyxUiButton>
<div class="text-lyx-text-dark">
This action will delete all data from the project creation date.
</div>
</div>
</div>
</template>
<template #delete_data>
<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"> This operation will reset this project to it's initial state (0
visits 0 events 0 sessions)</div>
<div @click="openDeleteAllDomainDataDialog()"
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 all data
</div>
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -198,11 +198,16 @@ function copyProjectId() {
<script defer data-project="${project?._id}" <script defer data-project="${project?._id}"
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }} src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
</div> </div>
<div><i class="far fa-copy" @click="copyScript()"></i></div> <div class="hidden lg:flex"><i class="far fa-copy" @click="copyScript()"></i></div>
</LyxUiCard> </LyxUiCard>
<div class="flex justify-end w-full">
<LyxUiButton type="outline" class="flex lg:hidden mt-4">
Copy script
</LyxUiButton>
</div>
</template> </template>
<template #pdelete> <template #pdelete>
<div class="flex justify-end" v-if="!isGuest"> <div class="flex lg:justify-end" v-if="!isGuest">
<LyxUiButton type="danger" @click="deleteProject()"> <LyxUiButton type="danger" @click="deleteProject()">
Delete project Delete project
</LyxUiButton> </LyxUiButton>

View File

@@ -16,10 +16,10 @@ const props = defineProps<SettingsTemplateProp>();
<template> <template>
<div class="mt-10 px-4"> <div class="mt-10 px-4 xl:pb-0 pb-[10rem]">
<div v-for="(entry, index) of props.entries" class="flex flex-col"> <div v-for="(entry, index) of props.entries" class="flex flex-col">
<div class="flex"> <div class="flex xl:flex-row flex-col gap-4 xl:gap-0">
<div class="flex-[2]"> <div class="xl:flex-[2]">
<div class="poppins font-medium text-lyx-text"> <div class="poppins font-medium text-lyx-text">
{{ entry.title }} {{ entry.title }}
</div> </div>
@@ -27,7 +27,7 @@ const props = defineProps<SettingsTemplateProp>();
{{ entry.text }} {{ entry.text }}
</div> </div>
</div> </div>
<div class="flex-[3]"> <div class="xl:flex-[3]">
<slot :name="entry.id"></slot> <slot :name="entry.id"></slot>
</div> </div>
</div> </div>

View File

@@ -164,7 +164,7 @@ const { visible } = usePricingDrawer();
{{ planData.premium ? 'Premium plan' : 'Basic plan' }} {{ planData.premium ? 'Premium plan' : 'Basic plan' }}
</div> </div>
<div <div
class="flex lato text-[.7rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-sm"> class="flex lato text-[.7rem] bg-transparent border-[#262626] border-[1px] px-[.6rem] rounded-sm">
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }} {{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
</div> </div>
</div> </div>
@@ -190,7 +190,7 @@ const { visible } = usePricingDrawer();
</div> </div>
<div class="my-4 w-full bg-gray-400/30 h-[1px]"> <div class="my-4 w-full bg-gray-400/30 h-[1px]">
</div> </div>
<div class="flex justify-between px-8 flex-col sm:flex-row"> <div class="flex justify-between px-8 flex-col lg:flex-row gap-2 lg:gap-0 items-center">
<div class="flex gap-2 text-text-sub text-[.9rem]"> <div class="flex gap-2 text-text-sub text-[.9rem]">
<div class="poppins"> Expire date:</div> <div class="poppins"> Expire date:</div>
<div> {{ prettyExpireDate }}</div> <div> {{ prettyExpireDate }}</div>

View File

@@ -0,0 +1,42 @@
const countryMap: Record<string, string> = {
RW: "Rwanda", SO: "Somalia", YE: "Yemen", IQ: "Iraq", SA: "Saudi Arabia", IR: "Iran", CY: "Cyprus", TZ: "Tanzania",
SY: "Syria", AM: "Armenia", KE: "Kenya", CD: "Congo", DJ: "Djibouti", UG: "Uganda", CF: "Central African Republic",
SC: "Seychelles", JO: "Jordan", LB: "Lebanon", KW: "Kuwait", OM: "Oman", QA: "Qatar", BH: "Bahrain", AE: "United Arab Emirates",
IL: "Israel", TR: "Türkiye", ET: "Ethiopia", ER: "Eritrea", EG: "Egypt", SD: "Sudan", GR: "Greece", BI: "Burundi",
EE: "Estonia", LV: "Latvia", AZ: "Azerbaijan", LT: "Lithuania", SJ: "Svalbard and Jan Mayen", GE: "Georgia", MD: "Moldova",
BY: "Belarus", FI: "Finland", AX: "Åland Islands", UA: "Ukraine", MK: "North Macedonia", HU: "Hungary", BG: "Bulgaria",
AL: "Albania", PL: "Poland", RO: "Romania", XK: "Kosovo", ZW: "Zimbabwe", ZM: "Zambia", KM: "Comoros", MW: "Malawi",
LS: "Lesotho", BW: "Botswana", MU: "Mauritius", SZ: "Eswatini", RE: "Réunion", ZA: "South Africa", YT: "Mayotte",
MZ: "Mozambique", MG: "Madagascar", AF: "Afghanistan", PK: "Pakistan", BD: "Bangladesh", TM: "Turkmenistan", TJ: "Tajikistan",
LK: "Sri Lanka", BT: "Bhutan", IN: "India", MV: "Maldives", IO: "British Indian Ocean Territory", NP: "Nepal", MM: "Myanmar",
UZ: "Uzbekistan", KZ: "Kazakhstan", KG: "Kyrgyzstan", TF: "French Southern Territories", HM: "Heard and McDonald Islands",
CC: "Cocos (Keeling) Islands", PW: "Palau", VN: "Vietnam", TH: "Thailand", ID: "Indonesia", LA: "Laos", TW: "Taiwan",
PH: "Philippines", MY: "Malaysia", CN: "China", HK: "Hong Kong", BN: "Brunei", MO: "Macao", KH: "Cambodia", KR: "South Korea",
JP: "Japan", KP: "North Korea", SG: "Singapore", CK: "Cook Islands", TL: "Timor-Leste", RU: "Russia", MN: "Mongolia",
AU: "Australia", CX: "Christmas Island", MH: "Marshall Islands", FM: "Federated States of Micronesia", PG: "Papua New Guinea",
SB: "Solomon Islands", TV: "Tuvalu", NR: "Nauru", VU: "Vanuatu", NC: "New Caledonia", NF: "Norfolk Island", NZ: "New Zealand",
FJ: "Fiji", LY: "Libya", CM: "Cameroon", SN: "Senegal", CG: "Congo Republic", PT: "Portugal", LR: "Liberia", CI: "Ivory Coast", GH: "Ghana",
GQ: "Equatorial Guinea", NG: "Nigeria", BF: "Burkina Faso", TG: "Togo", GW: "Guinea-Bissau", MR: "Mauritania", BJ: "Benin", GA: "Gabon",
SL: "Sierra Leone", ST: "São Tomé and Príncipe", GI: "Gibraltar", GM: "Gambia", GN: "Guinea", TD: "Chad", NE: "Niger", ML: "Mali",
EH: "Western Sahara", TN: "Tunisia", ES: "Spain", MA: "Morocco", MT: "Malta", DZ: "Algeria", FO: "Faroe Islands", DK: "Denmark",
IS: "Iceland", GB: "United Kingdom", CH: "Switzerland", SE: "Sweden", NL: "The Netherlands", AT: "Austria", BE: "Belgium",
DE: "Germany", LU: "Luxembourg", IE: "Ireland", MC: "Monaco", FR: "France", AD: "Andorra", LI: "Liechtenstein", JE: "Jersey",
IM: "Isle of Man", GG: "Guernsey", SK: "Slovakia", CZ: "Czechia", NO: "Norway", VA: "Vatican City", SM: "San Marino",
IT: "Italy", SI: "Slovenia", ME: "Montenegro", HR: "Croatia", BA: "Bosnia and Herzegovina", AO: "Angola", NA: "Namibia",
SH: "Saint Helena", BV: "Bouvet Island", BB: "Barbados", CV: "Cabo Verde", GY: "Guyana", GF: "French Guiana", SR: "Suriname",
PM: "Saint Pierre and Miquelon", GL: "Greenland", PY: "Paraguay", UY: "Uruguay", BR: "Brazil", FK: "Falkland Islands",
GS: "South Georgia and the South Sandwich Islands", JM: "Jamaica", DO: "Dominican Republic", CU: "Cuba", MQ: "Martinique",
BS: "Bahamas", BM: "Bermuda", AI: "Anguilla", TT: "Trinidad and Tobago", KN: "St Kitts and Nevis", DM: "Dominica",
AG: "Antigua and Barbuda", LC: "Saint Lucia", TC: "Turks and Caicos Islands", AW: "Aruba", VG: "British Virgin Islands",
VC: "St Vincent and Grenadines", MS: "Montserrat", MF: "Saint Martin", BL: "Saint Barthélemy", GP: "Guadeloupe",
GD: "Grenada", KY: "Cayman Islands", BZ: "Belize", SV: "El Salvador", GT: "Guatemala", HN: "Honduras", NI: "Nicaragua",
CR: "Costa Rica", VE: "Venezuela", EC: "Ecuador", CO: "Colombia", PA: "Panama", HT: "Haiti", AR: "Argentina", CL: "Chile",
BO: "Bolivia", PE: "Peru", MX: "Mexico", PF: "French Polynesia", PN: "Pitcairn Islands", KI: "Kiribati", TK: "Tokelau",
TO: "Tonga", WF: "Wallis and Futuna", WS: "Samoa", NU: "Niue", MP: "Northern Mariana Islands", GU: "Guam", PR: "Puerto Rico",
VI: "U.S. Virgin Islands", UM: "U.S. Outlying Islands", AS: "American Samoa", CA: "Canada", US: "United States",
PS: "Palestine", RS: "Serbia", AQ: "Antarctica", SX: "Sint Maarten", CW: "Curaçao", BQ: "Bonaire", SS: "South Sudan"
}
export function getCountryName(iso: string) {
return countryMap[iso] as string | undefined;
}

View File

@@ -16,7 +16,7 @@ export type CustomDialogOptions = {
params?: any, params?: any,
width?: string, width?: string,
height?: string, height?: string,
closable?: boolean closable?: boolean,
} }
function openDialogEx(component: Component, options?: CustomDialogOptions) { function openDialogEx(component: Component, options?: CustomDialogOptions) {

View File

@@ -15,7 +15,7 @@ const sections: Section[] = [
entries: [ entries: [
{ label: 'Web Analytics', to: '/', icon: 'fal fa-table-layout' }, { label: 'Web Analytics', to: '/', icon: 'fal fa-table-layout' },
{ label: 'Custom Events', to: '/events', icon: 'fal fa-square-bolt' }, { label: 'Custom Events', to: '/events', icon: 'fal fa-square-bolt' },
{ label: 'AI Analyst', to: '/analyst', icon: 'fal fa-sparkles' }, { label: 'Ask AI', to: '/analyst', icon: 'fal fa-sparkles' },
{ label: 'Security', to: '/security', icon: 'fal fa-shield' }, { label: 'Security', to: '/security', icon: 'fal fa-shield' },
// { label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true }, // { label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
// { label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true }, // { label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },

View File

@@ -4,109 +4,85 @@ import type { AdminProjectsList } from '~/server/api/admin/projects';
definePageMeta({ layout: 'dashboard' }); definePageMeta({ layout: 'dashboard' });
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch('/api/admin/counts', signHeaders());
type TProjectsGrouped = { const timeRange = ref<number>(9);
user: {
name: string, function setTimeRange(n: number) {
email: string, timeRange.value = n;
given_name: string,
picture: string,
created_at: Date
},
projects: {
_id: string,
premium: boolean,
premium_type: number,
created_at: Date,
project_name: string,
total_visits: number,
total_events: number,
total_sessions: number
}[]
} }
const projectsGrouped = computed(() => { const timeRangeTimestamp = computed(()=>{
if (timeRange.value == 1) return Date.now() - 1000 * 60 * 60 * 24;
if (!projects.value) return []; if (timeRange.value == 2) return Date.now() - 1000 * 60 * 60 * 24 * 7;
if (timeRange.value == 3) return Date.now() - 1000 * 60 * 60 * 24 * 30;
const result: TProjectsGrouped[] = []; return 0;
})
for (const project of projects.value) {
if (!project.user) continue;
const target = result.find(e => e.user.email == project.user.email); const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch(()=> `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
if (target) {
target.projects.push({
_id: project._id,
created_at: project.created_at,
premium_type: project.premium_type,
premium: project.premium,
project_name: project.project_name,
total_events: project.total_events,
total_visits: project.total_visits,
total_sessions: project.total_sessions
});
} else {
const item: TProjectsGrouped = {
user: project.user,
projects: [{
_id: project._id,
created_at: project.created_at,
premium: project.premium,
premium_type: project.premium_type,
project_name: project.project_name,
total_events: project.total_events,
total_visits: project.total_visits,
total_sessions: project.total_sessions
}]
}
result.push(item);
}
}
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;
});
function onHideClicked() { function onHideClicked() {
isAdminHidden.value = true; isAdminHidden.value = true;
} }
const projectsAggregated = computed(() => {
return projectsAggregatedResponseData.value?.sort((a, b) => {
const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
return sumVisitsB - sumVisitsA;
}).filter(e=>{
return new Date(e.created_at).getTime() >= timeRangeTimestamp.value
});
})
const premiumCount = computed(() => { const premiumCount = computed(() => {
let premiums = 0; let premiums = 0;
projects.value?.forEach(e => { projectsAggregated.value?.forEach(e => {
if (e.premium) premiums++; e.projects.forEach(p => {
if (p.premium) premiums++;
});
}) })
return premiums; return premiums;
}) })
const activeProjects = computed(() => {
let actives = 0;
projectsAggregated.value?.forEach(e => {
e.projects.forEach(p => {
if (!p.counts) return;
if (!p.counts.updated_at) return;
const updated_at = new Date(p.counts.updated_at).getTime();
if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return;
actives++;
});
})
return actives;
});
const totalVisits = computed(() => { const totalVisits = computed(() => {
return projects.value?.reduce((a, e) => a + e.total_visits, 0) || 0; return projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
}, 0) || 0;
}); });
const totalEvents = computed(() => { const totalEvents = computed(() => {
return projects.value?.reduce((a, e) => a + e.total_events, 0) || 0; return projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0);
}, 0) || 0;
}); });
const details = ref<any>(); const details = ref<any>();
const showDetails = ref<boolean>(false); const showDetails = ref<boolean>(false);
async function getProjectDetails(project_id: string) { async function getProjectDetails(project_id: string) {
@@ -118,6 +94,30 @@ async function resetCount(project_id: string) {
await $fetch(`/api/admin/reset_count?project_id=${project_id}`, signHeaders()); await $fetch(`/api/admin/reset_count?project_id=${project_id}`, signHeaders());
} }
function dateDiffDays(a: string) {
return (Date.now() - new Date(a).getTime()) / (1000 * 60 * 60 * 24)
}
function getLogBg(last_logged_at?: string) {
const day = 1000 * 60 * 60 * 24;
const week = 1000 * 60 * 60 * 24 * 7;
const lastLoggedAtDate = new Date(last_logged_at || 0);
if (lastLoggedAtDate.getTime() > Date.now() - day) {
return 'bg-green-500'
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
return 'bg-yellow-500'
} else {
return 'bg-red-500'
}
}
</script> </script>
@@ -138,13 +138,21 @@ async function resetCount(project_id: string) {
<div @click="onHideClicked()" v-if="!isAdminHidden" <div @click="onHideClicked()" v-if="!isAdminHidden"
class="bg-menu hover:bg-menu/70 cursor-pointer flex gap-2 rounded-lg w-fit px-6 py-4 text-text-sub"> class="bg-menu hover:bg-menu/70 cursor-pointer flex gap-2 rounded-lg w-fit px-6 py-4 text-text-sub">
<div class="text-text-sub/90"> <i class="far fa-eye"></i> </div> <div class="text-text-sub/90"> <i class="far fa-eye"></i> </div>
<div> Nascondi dalla barra </div> <div> Hide from the bar </div>
</div> </div>
<Card class="p-2 flex gap-10 items-center justify-center">
<div :class="{ 'text-red-200': timeRange == 1 }" @click="setTimeRange(1)"> Last day </div>
<div :class="{ 'text-red-200': timeRange == 2 }" @click="setTimeRange(2)"> Last week </div>
<div :class="{ 'text-red-200': timeRange == 3 }" @click="setTimeRange(3)"> Last month </div>
<div :class="{ 'text-red-200': timeRange == 9 }" @click="setTimeRange(9)"> All </div>
</Card>
<Card class="p-4"> <Card class="p-4">
<div class="grid grid-cols-2"> <div class="grid grid-cols-2 gap-1">
<div> <div>
Users: {{ counts?.users }} Users: {{ counts?.users }}
</div> </div>
@@ -154,6 +162,10 @@ async function resetCount(project_id: string) {
<div> <div>
Total visits: {{ formatNumberK(totalVisits) }} Total visits: {{ formatNumberK(totalVisits) }}
</div> </div>
<div>
Active: {{ activeProjects }} |
Dead: {{ (counts?.projects || 0) - activeProjects }}
</div>
<div> <div>
Total events: {{ formatNumberK(totalEvents) }} Total events: {{ formatNumberK(totalEvents) }}
</div> </div>
@@ -162,17 +174,25 @@ async function resetCount(project_id: string) {
</Card> </Card>
<div v-for="item of projectsGrouped" class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full relative"> <div v-for="item of projectsAggregated || []"
class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full relative">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div> {{ item.user.email }} </div> <div> {{ item.email }} </div>
<div> {{ item.user.name }} </div> <div> {{ item.name }} </div>
</div> </div>
<div class="flex justify-evenly flex-col lg:flex-row gap-2 lg:gap-0"> <div class="flex justify-evenly flex-col lg:grid lg:grid-cols-3 gap-2 lg:gap-4">
<div v-for="project of item.projects" <div v-for="project of item.projects"
class="lg:w-[30%] flex flex-col items-center bg-bg p-6 rounded-xl"> class="flex relative flex-col items-center bg-bg p-6 rounded-xl">
<div class="absolute left-2 top-2 flex items-center gap-2">
<div :class="getLogBg(project?.counts?.updated_at)" class="h-3 w-3 rounded-full"> </div>
<div> {{ dateDiffDays(project?.counts?.updated_at || '0').toFixed(0) }} days </div>
</div>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="font-bold"> {{ project.premium ? 'PREMIUM' : 'FREE' }} </div> <div class="font-bold"> {{ project.premium ? 'PREMIUM' : 'FREE' }} </div>
<div class="text-text-sub/90"> <div class="text-text-sub/90">
@@ -181,14 +201,14 @@ async function resetCount(project_id: string) {
</div> </div>
<div class="text-ellipsis line-clamp-1"> {{ project.project_name }} </div> <div class="text-ellipsis line-clamp-1"> {{ project.name }} </div>
<div class="flex gap-2"> <div class="flex gap-2">
<div> Visits: </div> <div> Visits: </div>
<div> {{ project.total_visits }} </div> <div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
<div> Events: </div> <div> Events: </div>
<div> {{ project.total_events }} </div> <div> {{ formatNumberK(project.counts?.events || 0) }} </div>
<div> Sessions: </div> <div> Sessions: </div>
<div> {{ project.total_sessions }} </div> <div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
</div> </div>
<div class="flex gap-4 items-center mt-4"> <div class="flex gap-4 items-center mt-4">

View File

@@ -148,15 +148,15 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
<div class="flex-[5] py-8 flex h-full flex-col items-center relative overflow-y-hidden"> <div class="flex-[5] py-8 flex h-full flex-col items-center relative overflow-y-hidden">
<div class="flex flex-col items-center lg:mt-[20vh] px-8 lg:px-28" <div class="flex flex-col items-center xl:mt-[20vh] px-8 xl:px-28"
v-if="currentChatMessages.length == 0"> v-if="currentChatMessages.length == 0">
<div class="w-[7rem] lg:w-[10rem]"> <div class="w-[7rem] xl:w-[10rem]">
<img :src="'analyst.png'" class="w-full h-full"> <img :src="'analyst.png'" class="w-full h-full">
</div> </div>
<div class="poppins text-[1.2rem] text-center"> <div class="poppins text-[1.2rem] text-center">
Ask me anything about your data Ask me anything about your data
</div> </div>
<div class="flex flex-col lg:grid lg:grid-cols-2 gap-4 mt-6"> <div class="flex flex-col xl:grid xl:grid-cols-2 gap-4 mt-6">
<div v-for="prompt of defaultPrompts" @click="currentText = prompt" <div v-for="prompt of defaultPrompts" @click="currentText = prompt"
class="bg-lyx-widget-light hover:bg-lyx-widget-lighter cursor-pointer p-4 rounded-lg poppins text-center whitespace-pre-wrap flex items-center justify-center text-[.9rem]"> class="bg-lyx-widget-light hover:bg-lyx-widget-lighter cursor-pointer p-4 rounded-lg poppins text-center whitespace-pre-wrap flex items-center justify-center text-[.9rem]">
{{ prompt }} {{ prompt }}
@@ -216,7 +216,7 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
<i class="far fa-arrow-up"></i> <i class="far fa-arrow-up"></i>
</div> </div>
<div @click="menuOpen = !menuOpen" <div @click="menuOpen = !menuOpen"
class="bg-lyx-widget-light lg:hidden hhover:bg-lyx-widget-light cursor-pointer px-4 py-2 rounded-full"> class="bg-lyx-widget-light xl:hidden hhover:bg-lyx-widget-light cursor-pointer px-4 py-2 rounded-full">
<i class="far fa-message"></i> <i class="far fa-message"></i>
</div> </div>
</div> </div>
@@ -225,12 +225,12 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
<div :class="{ <div :class="{
'absolute': menuOpen, 'absolute top-0 left-0 w-full': menuOpen,
'hidden lg:flex': !menuOpen 'hidden xl:flex': !menuOpen
}" class="flex-[2] bg-lyx-background-light p-6 flex flex-col gap-4 h-full overflow-hidden"> }" class="flex-[2] bg-lyx-background-light p-6 flex flex-col gap-4 h-full overflow-hidden">
<div class="gap-2 flex flex-col"> <div class="gap-2 flex flex-col">
<div class="lg:hidden absolute right-4 top-4 text-[1.5rem]"> <div class="xl:hidden absolute right-6 top-2 text-[1.5rem]">
<i @click="menuOpen = false" class="fas fa-close cursor-pointer"></i> <i @click="menuOpen = false" class="fas fa-close cursor-pointer"></i>
</div> </div>
</div> </div>

View File

@@ -19,7 +19,7 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
<div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col"> <div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
<LyxUiCard class="w-full flex justify-between items-center"> <LyxUiCard class="w-full flex justify-between items-center lg:flex-row flex-col gap-6 lg:gap-0">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div> <div>
Total events: {{ eventsData.data.value?.[0]?.count || '0' }} Total events: {{ eventsData.data.value?.[0]?.count || '0' }}
@@ -29,7 +29,7 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
</div> </div>
</div> </div>
<div> <div>
<LyxUiButton type="secondary" to="https://docs.litlyx.com/custom-events"> <LyxUiButton type="secondary" target="_blank" to="https://docs.litlyx.com/custom-events">
Trigger your first event Trigger your first event
</LyxUiButton> </LyxUiButton>
</div> </div>
@@ -40,9 +40,9 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
<BarCardEvents :key="refreshKey"></BarCardEvents> <BarCardEvents :key="refreshKey"></BarCardEvents>
</div> </div>
<div class="flex gap-6 flex-col xl:flex-row h-full"> <div class="flex gap-6 flex-col xl:flex-row xl:h-full">
<CardTitled :key="refreshKey" class="p-4 flex-[4] w-full h-full" title="Events" <CardTitled :key="refreshKey" class="p-4 xl:flex-[4] w-full h-full" title="Events"
sub="Events stacked bar chart."> sub="Events stacked bar chart.">
<template #header> <template #header>
<SelectButton @changeIndex="eventsStackedSelectIndex = $event" <SelectButton @changeIndex="eventsStackedSelectIndex = $event"
@@ -55,7 +55,7 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
</div> </div>
</CardTitled> </CardTitled>
<CardTitled :key="refreshKey" class="p-4 flex-[2] w-full h-full" title="Top events" <CardTitled :key="refreshKey" class="p-4 xl:flex-[2] w-full h-full" title="Top events"
sub="Displays key events."> sub="Displays key events.">
<DashboardEventsChart class="w-full"> </DashboardEventsChart> <DashboardEventsChart class="w-full"> </DashboardEventsChart>
</CardTitled> </CardTitled>

View File

@@ -35,7 +35,7 @@ const showDashboard = computed(() => project.value && firstInteraction.data.valu
<template> <template>
<div class="dashboard w-full h-full overflow-y-auto pb-20 md:pt-4 lg:pt-0"> <div class="dashboard w-full h-full overflow-y-auto overflow-x-hidden pb-[7rem] md:pt-4 lg:pt-0">
<div v-if="showDashboard"> <div v-if="showDashboard">
@@ -55,22 +55,11 @@ const showDashboard = computed(() => project.value && firstInteraction.data.valu
<div class="flex w-full justify-center mt-6 px-6"> <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 w-full gap-6 flex-col xl:flex-row">
<div class="flex-1">
<BarCardWebsites :key="refreshKey"></BarCardWebsites>
</div>
<div class="flex-1"> <div class="flex-1">
<BarCardReferrers :key="refreshKey"></BarCardReferrers> <BarCardReferrers :key="refreshKey"></BarCardReferrers>
</div> </div>
</div>
</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"> <div class="flex-1">
<BarCardBrowsers :key="refreshKey"></BarCardBrowsers> <BarCardWebsites :key="refreshKey"></BarCardWebsites>
</div>
<div class="flex-1">
<BarCardOperatingSystems :key="refreshKey"></BarCardOperatingSystems>
</div> </div>
</div> </div>
</div> </div>
@@ -86,6 +75,17 @@ const showDashboard = computed(() => project.value && firstInteraction.data.valu
</div> </div>
</div> </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">
<BarCardBrowsers :key="refreshKey"></BarCardBrowsers>
</div>
<div class="flex-1">
<BarCardOperatingSystems :key="refreshKey"></BarCardOperatingSystems>
</div>
</div>
</div>
</div> </div>
@@ -97,8 +97,11 @@ const showDashboard = computed(() => project.value && firstInteraction.data.valu
Create your first project... Create your first project...
</div> </div>
<div v-if="justLogged" class="text-[2rem]"> <div v-if="justLogged" class="text-[2rem] w-full h-full flex items-center justify-center">
The page will refresh soon <div
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-[#727272] animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
</div> </div>
</div> </div>

View File

@@ -89,7 +89,7 @@ const selectLabelsEvents = [
<DashboardTopCards :key="refreshKey"></DashboardTopCards> <DashboardTopCards :key="refreshKey"></DashboardTopCards>
</div> </div>
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full"> <div class="mt-6 px-6 hidden lg:flex gap-6 flex-col 2xl:flex-row w-full">
<DashboardActionableChart :key="refreshKey"></DashboardActionableChart> <DashboardActionableChart :key="refreshKey"></DashboardActionableChart>
</div> </div>

View File

@@ -2,6 +2,8 @@
definePageMeta({ layout: 'none' }); definePageMeta({ layout: 'none' });
import { Lit } from 'litlyx-js';
const config = useRuntimeConfig() const config = useRuntimeConfig()
const isNoAuth = ref<boolean>(config.public.AUTH_MODE == 'NO_AUTH'); const isNoAuth = ref<boolean>(config.public.AUTH_MODE == 'NO_AUTH');
@@ -52,6 +54,8 @@ async function handleOnSuccess(response: any) {
body: JSON.stringify({ code: response.code }) body: JSON.stringify({ code: response.code })
}) })
Lit.event('google_login_signup');
if (result.error) return alert('Error during login, please try again'); if (result.error) return alert('Error during login, please try again');
setToken(result.access_token); setToken(result.access_token);
@@ -120,7 +124,7 @@ function goBackToEmailLogin() {
async function signInWithCredentials() { async function signInWithCredentials() {
try { try {
const result = await $fetch<{error:true, message:string} | {error: false, access_token:string}>('/api/auth/login', { const result = await $fetch<{ error: true, message: string } | { error: false, access_token: string }>('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value, password: password.value }) body: JSON.stringify({ email: email.value, password: password.value })
@@ -159,7 +163,7 @@ async function signInWithCredentials() {
<div class="flex h-full"> <div class="flex h-full">
<div class="flex-1 flex flex-col items-center pt-20 lg:pt-[22vh]"> <div class="flex-1 flex flex-col items-center pt-20 xl:pt-[22vh]">
<div class="rotating-thing absolute top-0"></div> <div class="rotating-thing absolute top-0"></div>
@@ -171,9 +175,8 @@ async function signInWithCredentials() {
Sign in Sign in
</div> </div>
<div class="text-text/80 text-[1.2rem] text-center w-[70%] poppins mt-2"> <div class="text-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2">
Track web analytics and custom events Track web analytics and custom events
<br>
with extreme simplicity in under 30 sec. with extreme simplicity in under 30 sec.
<br> <br>
<!-- <div class="font-bold poppins mt-4"> <!-- <div class="font-bold poppins mt-4">
@@ -221,11 +224,12 @@ async function signInWithCredentials() {
<div class="flex items-center"> <div class="flex items-center">
<i class="far fa-envelope"></i> <i class="far fa-envelope"></i>
</div> </div>
Continue with Email Sign in with Email
</div> </div>
<RouterLink tag="div" to="/register" class="mt-4 text-center text-lyx-text-dark underline cursor-pointer z-[100]"> <RouterLink tag="div" to="/register"
class="mt-4 text-center text-lyx-text-dark underline cursor-pointer z-[100]">
You don't have an account ? Sign up You don't have an account ? Sign up
</RouterLink> </RouterLink>

View File

@@ -2,8 +2,6 @@
definePageMeta({ layout: 'none' }); definePageMeta({ layout: 'none' });
const activeProject = useActiveProject();
</script> </script>

View File

@@ -2,6 +2,7 @@
definePageMeta({ layout: 'none' }); definePageMeta({ layout: 'none' });
import { Lit } from 'litlyx-js';
const emailSended = ref<boolean>(false); const emailSended = ref<boolean>(false);
@@ -29,6 +30,9 @@ async function registerAccount() {
body: JSON.stringify({ email: email.value, password: password.value }) body: JSON.stringify({ email: email.value, password: password.value })
}); });
if (res.error === true) return alert(res.message); if (res.error === true) return alert(res.message);
Lit.event('email_signup');
emailSended.value = true; emailSended.value = true;
} catch (ex) { } catch (ex) {
alert('Something went wrong'); alert('Something went wrong');
@@ -45,7 +49,7 @@ async function registerAccount() {
<div class="flex h-full"> <div class="flex h-full">
<div class="flex-1 flex flex-col items-center pt-20 lg:pt-[22vh]"> <div class="flex-1 flex flex-col items-center pt-20 xl:pt-[22vh]">
<div class="rotating-thing absolute top-0"></div> <div class="rotating-thing absolute top-0"></div>
@@ -57,9 +61,8 @@ async function registerAccount() {
Sign up Sign up
</div> </div>
<div class="text-text/80 text-[1.2rem] text-center w-[70%] poppins mt-2"> <div class="text-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2">
Track web analytics and custom events Track web analytics and custom events
<br>
with extreme simplicity in under 30 sec. with extreme simplicity in under 30 sec.
<br> <br>
<!-- <div class="font-bold poppins mt-4"> <!-- <div class="font-bold poppins mt-4">
@@ -114,7 +117,8 @@ async function registerAccount() {
</RouterLink> </RouterLink>
</div> </div>
<div v-if="!emailSended" class="text-[.9rem] poppins mt-20 text-text-sub text-center relative z-[2]"> <div v-if="!emailSended"
class="text-[.9rem] poppins mt-5 xl:mt-20 text-text-sub text-center relative z-[2]">
By continuing you are accepting By continuing you are accepting
<br> <br>
our our

View File

@@ -5,27 +5,36 @@ definePageMeta({ layout: 'dashboard' });
const items = [ const items = [
{ label: 'General', slot: 'general' }, { label: 'General', slot: 'general' },
{ label: 'Data', slot: 'data' },
{ label: 'Members', slot: 'members' }, { label: 'Members', slot: 'members' },
{ label: 'Billing', slot: 'billing' }, { label: 'Billing', slot: 'billing' },
{ label: 'Codes', slot: 'codes' },
{ label: 'Account', slot: 'account' } { label: 'Account', slot: 'account' }
] ]
</script> </script>
<template> <template>
<div class="px-10 py-8 h-dvh overflow-y-auto hide-scrollbars"> <div class="lg:px-10 lg:py-8 h-dvh overflow-y-auto overflow-x-hidden hide-scrollbars">
<div class="poppins font-semibold text-[1.3rem]"> Settings </div>
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Settings </div>
<CustomTab :items="items" class="mt-8"> <CustomTab :items="items" class="mt-8">
<template #general> <template #general>
<SettingsGeneral :key="refreshKey"></SettingsGeneral> <SettingsGeneral :key="refreshKey"></SettingsGeneral>
</template> </template>
<template #data>
<SettingsData :key="refreshKey"></SettingsData>
</template>
<template #members> <template #members>
<SettingsMembers :key="refreshKey"></SettingsMembers> <SettingsMembers :key="refreshKey"></SettingsMembers>
</template> </template>
<template #billing> <template #billing>
<SettingsBilling :key="refreshKey"></SettingsBilling> <SettingsBilling :key="refreshKey"></SettingsBilling>
</template> </template>
<template #codes>
<SettingsCodes :key="refreshKey"></SettingsCodes>
</template>
<template #account> <template #account>
<SettingsAccount :key="refreshKey"></SettingsAccount> <SettingsAccount :key="refreshKey"></SettingsAccount>
</template> </template>

View File

@@ -8,9 +8,16 @@ export default defineEventHandler(async event => {
if (!userData?.logged) return; if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return; if (!userData.user.roles.includes('ADMIN')) return;
const { from } = getQuery(event);
const projectsCount = await ProjectModel.countDocuments({}); const date = new Date(parseInt(from as any));
const usersCount = await UserModel.countDocuments({});
const projectsCount = await ProjectModel.countDocuments({
created_at: { $gte: date }
});
const usersCount = await UserModel.countDocuments({
created_at: { $gte: date }
});
return { users: usersCount, projects: projectsCount } return { users: usersCount, projects: projectsCount }

View File

@@ -1,21 +1,24 @@
import { ProjectModel } from "@schema/ProjectSchema"; import { UserModel } from "@schema/UserSchema";
export type AdminProjectsList = { export type AdminProjectsList = {
premium: boolean,
created_at: Date,
project_name: string,
premium_type: number,
_id: string, _id: string,
user: { name: string,
given_name: string,
created_at: string,
email: string,
projects: {
_id: string,
owner: string,
name: string, name: string,
email: string, premium: boolean,
given_name: string, premium_type: number,
picture: string, customer_id: string,
created_at: Date subscription_id: string,
}, premium_expire_at: string,
total_visits: number, created_at: string,
total_events: number, __v: number,
total_sessions: number counts: { _id: string, project_id: string, events: number, visits: number, sessions: number, updated_at?: string }
}[],
} }
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
@@ -24,40 +27,53 @@ export default defineEventHandler(async event => {
if (!userData?.logged) return; if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return; if (!userData.user.roles.includes('ADMIN')) return;
const data: AdminProjectsList[] = await ProjectModel.aggregate([ const data: AdminProjectsList[] = await UserModel.aggregate([
{ {
$lookup: { $lookup: {
from: "users", from: "projects",
localField: "owner", localField: "_id",
foreignField: "_id", foreignField: "owner",
as: "user" as: "projects"
}
},
{
$unwind: {
path: "$projects",
preserveNullAndEmptyArrays: true
} }
}, },
{ {
$lookup: { $lookup: {
from: "project_counts", from: "project_counts",
localField: "_id", localField: "projects._id",
foreignField: "project_id", foreignField: "project_id",
as: "counts" as: "projects.counts"
} }
}, },
{ {
$project: { $addFields: {
project_name: "$name", "projects.counts": {
premium: 1, $arrayElemAt: ["$projects.counts", 0]
premium_type: 1, }
created_at: 1, }
user: { },
$first: "$user" {
$group: {
_id: "$_id",
name: {
$first: "$name"
}, },
total_visits: { given_name: {
$arrayElemAt: ["$counts.visits", 0] $first: "$given_name"
}, },
total_events: { created_at: {
$arrayElemAt: ["$counts.events", 0] $first: "$created_at"
}, },
total_sessions: { email: {
$arrayElemAt: ["$counts.sessions", 0] $first: "$email"
},
projects: {
$push: "$projects"
} }
} }
} }

View File

@@ -1,7 +1,6 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema"; import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import { ProjectModel } from "@schema/ProjectSchema"; import { ProjectModel } from "@schema/ProjectSchema";
import crypto from 'crypto'; import crypto from 'crypto';

View File

@@ -0,0 +1,51 @@
import { getPlanFromId } from "@data/PREMIUM";
import { PREMIUM_PLAN } from "../../../../shared/data/PREMIUM";
import { canTryAppsumoCode, checkAppsumoCode, useAppsumoCode, useTryAppsumoCode } from "~/server/services/AppsumoService";
import StripeService from '~/server/services/StripeService';
function getPlanToActivate(current_plan_id: number) {
if (current_plan_id === PREMIUM_PLAN.FREE.ID) {
return PREMIUM_PLAN.APPSUMO_INCUBATION;
}
// if (current_plan_id === PREMIUM_PLAN.INCUBATION.ID) {
// return PREMIUM_PLAN.APPSUMO_ACCELERATION;
// }
// if (current_plan_id === PREMIUM_PLAN.ACCELERATION.ID) {
// return PREMIUM_PLAN.APPSUMO_GROWTH;
// }
if (current_plan_id === PREMIUM_PLAN.APPSUMO_INCUBATION.ID) {
return PREMIUM_PLAN.APPSUMO_ACCELERATION;
}
if (current_plan_id === PREMIUM_PLAN.APPSUMO_ACCELERATION.ID) {
return PREMIUM_PLAN.APPSUMO_GROWTH;
}
}
export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false, allowGuests: false, allowLitlyx: false });
if (!data) return;
const { project, pid, user } = data;
const body = await readBody(event);
const { code } = body;
const canTry = await canTryAppsumoCode(pid);
if (!canTry) return setResponseStatus(event, 400, 'You tried too much codes. Please contact support.');
await useTryAppsumoCode(pid, code);
const valid = await checkAppsumoCode(code);
if (!valid) return setResponseStatus(event, 400, 'Code not valid');
const currentPlan = getPlanFromId(project.premium_type);
if (!currentPlan) return setResponseStatus(event, 400, 'Current plan not found');
const planToActivate = getPlanToActivate(currentPlan.ID);
if (!planToActivate) return setResponseStatus(event, 400, 'Cannot use code on current plan');
await StripeService.createSubscription(project.customer_id, planToActivate.ID);
await useAppsumoCode(pid, code);
});

View File

@@ -0,0 +1,14 @@
import { AppsumoCodeTryModel } from "@schema/AppsumoCodeTrySchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false, allowGuests: false, allowLitlyx: false });
if (!data) return;
const { pid } = data;
const tryRes = await AppsumoCodeTryModel.findOne({ project_id: pid }, { valid_codes: 1 });
if (!tryRes) return { count: 0 }
return { count: tryRes.valid_codes.length }
});

View File

@@ -133,7 +133,7 @@ async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
if (!price) return { error: 'Price not found' } if (!price) return { error: 'Price not found' }
const PLAN = getPlanFromPrice(price, StripeService.testMode || false); const PLAN = getPlanFromPrice(price, StripeService.testMode || false);
if (!PLAN) return { error: 'Plan not found' } if (!PLAN) return { error: `Plan not found. Price: ${price}. TestMode: ${StripeService.testMode}` }
await addSubscriptionToProject(project._id.toString(), PLAN, subscription_id, currentSubscription.current_period_start, currentSubscription.current_period_end) await addSubscriptionToProject(project._id.toString(), PLAN, subscription_id, currentSubscription.current_period_start, currentSubscription.current_period_end)

View File

@@ -16,9 +16,7 @@ export default defineEventHandler(async event => {
const userData = getRequestUser(event); const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged'); if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { max_projects: true }); const maxProjects = 20;
const maxProjects = userSettings?.max_projects || 3;
const existingUserProjects = await ProjectModel.countDocuments({ owner: userData.id }); const existingUserProjects = await ProjectModel.countDocuments({ owner: userData.id });
if (existingUserProjects >= maxProjects) return setResponseStatus(event, 400, 'Already have max number of projects'); if (existingUserProjects >= maxProjects) return setResponseStatus(event, 400, 'Already have max number of projects');

View File

@@ -4,7 +4,6 @@ import pdfkit from 'pdfkit';
import { PassThrough } from 'node:stream'; import { PassThrough } from 'node:stream';
import { ProjectModel } from "@schema/ProjectSchema"; import { ProjectModel } from "@schema/ProjectSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import { VisitModel } from '@schema/metrics/VisitSchema'; import { VisitModel } from '@schema/metrics/VisitSchema';
import { EventModel } from '@schema/metrics/EventSchema'; import { EventModel } from '@schema/metrics/EventSchema';
@@ -82,15 +81,13 @@ function createPdf(data: PDFGenerationData) {
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false, allowGuests: true, requireRange: false });
if (!data) return;
const userData = getRequestUser(event); const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged'); if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id }); const project = await ProjectModel.findById(data.project_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) return setResponseStatus(event, 400, 'Project not found');
const snapshotHeader = getHeader(event, 'x-snapshot-name'); const snapshotHeader = getHeader(event, 'x-snapshot-name');

View File

@@ -1,8 +1,7 @@
import { ProjectModel } from "@schema/ProjectSchema"; import { ProjectModel } from "@schema/ProjectSchema";
import { TeamMemberModel } from "@schema/TeamMemberSchema"; import { TeamMemberModel } from "@schema/TeamMemberSchema";
import { UserModel } from "@schema/UserSchema"; import { UserModel } from "@schema/UserSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import StripeService from '~/server/services/StripeService';
export default defineEventHandler(async event => { export default defineEventHandler(async event => {

View File

@@ -0,0 +1,36 @@
import { EventModel } from "@schema/metrics/EventSchema";
import { SessionModel } from "@schema/metrics/SessionSchema";
import { VisitModel } from "@schema/metrics/VisitSchema";
import { Types } from "mongoose";
import { getRequestData } from "~/server/utils/getRequestData";
export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false });
if (!data) return;
const { project_id } = data;
taskDeleteAll(project_id);
return { ok: true }
});
async function taskDeleteAll(project_id: Types.ObjectId) {
console.log('Deletation all started');
const start = Date.now();
await VisitModel.deleteMany({ project_id });
await SessionModel.deleteMany({ project_id });
await EventModel.deleteMany({ project_id });
const s = (Date.now() - start) / 1000;
console.log(`Deletation all done in ${s.toFixed(2)} seconds`);
}

View File

@@ -0,0 +1,104 @@
import { EventModel } from "@schema/metrics/EventSchema";
import { SessionModel } from "@schema/metrics/SessionSchema";
import { VisitModel } from "@schema/metrics/VisitSchema";
import { Types } from "mongoose";
import { getRequestData } from "~/server/utils/getRequestData";
export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false });
if (!data) return;
const { project_id } = data;
const { domain, visits, events, sessions } = await readBody(event);
taskDeleteDomain(project_id, domain, visits, events, sessions);
return { ok: true }
});
async function taskDeleteDomain(project_id: Types.ObjectId, domain: string, deleteVisits: boolean, deleteEvents: boolean, deleteSessions: boolean) {
console.log('Deletation started');
const start = Date.now();
const data = await VisitModel.aggregate([
{
$match: {
project_id,
website: domain
}
},
{
$group: {
_id: "$session",
count: { $sum: 1 }
}
},
{
$lookup: {
from: "events",
let: { sessionId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$session", "$$sessionId"] } } },
{ $match: { project_id } },
{ $project: { _id: 1 } }
],
as: "events"
}
},
{
$lookup: {
from: "sessions",
let: { sessionId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$session", "$$sessionId"] } } },
{ $match: { project_id } },
{ $project: { _id: 1 } }
],
as: "sessions"
}
},
{
$project: {
_id: 1,
count: 1,
"events._id": 1,
"sessions._id": 1
}
}
]) as { _id: string, events: { _id: string }[], sessions: { _id: string }[] }[]
if (deleteSessions === true) {
const sessions = data.flatMap(e => e.sessions).map(e => e._id.toString());
const batchSize = 1000;
for (let i = 0; i < sessions.length; i += batchSize) {
const batch = sessions.slice(i, i + batchSize);
await SessionModel.deleteMany({ _id: { $in: batch } });
}
}
if (deleteEvents === true) {
const events = data.flatMap(e => e.events).map(e => e._id.toString());
const batchSize = 1000;
for (let i = 0; i < events.length; i += batchSize) {
const batch = events.slice(i, i + batchSize);
await EventModel.deleteMany({ _id: { $in: batch } });
}
}
if (deleteVisits === true) {
await VisitModel.deleteMany({ project_id, website: domain })
}
const s = (Date.now() - start) / 1000;
console.log(`Deletation done in ${s.toFixed(2)} seconds`);
}

View File

@@ -0,0 +1,79 @@
import { VisitModel } from "@schema/metrics/VisitSchema";
import { getRequestData } from "~/server/utils/getRequestData";
export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false });
if (!data) return;
const { project_id } = data;
const { domain } = getQuery(event);
try {
const resultData = await VisitModel.aggregate([
{
$match: {
project_id,
website: domain
}
},
{
$group: {
_id: "$session",
count: { $sum: 1 }
}
},
{
$lookup: {
from: "events",
let: { sessionId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$session", "$$sessionId"] } } },
{ $match: { project_id } },
{ $project: { _id: 1 } }
],
as: "events"
}
},
{
$lookup: {
from: "sessions",
let: { sessionId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$session", "$$sessionId"] } } },
{ $match: { project_id } },
{ $project: { _id: 1 } }
],
as: "sessions"
}
},
{
$project: {
_id: 1,
count: 1,
"events._id": 1,
"sessions._id": 1
}
}
], { maxTimeMS: 5000 }) as { _id: string, count: number, events: { _id: string }[], sessions: { _id: string }[] }[]
const visits = resultData.reduce((a, e) => a + e.count, 0);
const sessions = resultData.reduce((a, e) => {
const count = e.sessions.length;
return a + count;
}, 0);
const events = resultData.reduce((a, e) => {
const count = e.events.length;
return a + count;
}, 0);
return { visits, sessions, events, error: false, message: '' };
} catch (ex: any) {
return { error: true, message: ex.message.toString(), visits: -1, sessions: -1, events: -1 }
}
});

View File

@@ -0,0 +1,18 @@
import { VisitModel } from "@schema/metrics/VisitSchema";
import { getRequestData } from "~/server/utils/getRequestData";
export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false });
if (!data) return;
const { project_id } = data;
const result = await VisitModel.aggregate([
{ $match: { project_id } },
{ $group: { _id: "$website", count: { $sum: 1 } } },
]);
return result as { _id: string, count: number }[];
});

View File

@@ -1,10 +1,12 @@
import { ProjectModel } from "@schema/ProjectSchema"; import { ProjectModel } from "@schema/ProjectSchema";
import { ProjectSnapshotModel } from "@schema/ProjectSnapshot"; import { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
import { UserSettingsModel } from "@schema/UserSettings";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false, allowGuests: true, requireRange: false });
if (!data) return;
const body = await readBody(event); const body = await readBody(event);
const { name: newSnapshotName, from, to, color: snapshotColor } = body; const { name: newSnapshotName, from, to, color: snapshotColor } = body;
@@ -19,13 +21,8 @@ export default defineEventHandler(async event => {
const userData = getRequestUser(event); const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged'); 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 project = await ProjectModel.findById(data.project_id);
const currentProjectId = userSettings.active_project_id;
const project = await ProjectModel.findById(currentProjectId);
if (!project) return setResponseStatus(event, 400, 'Project not found'); if (!project) return setResponseStatus(event, 400, 'Project not found');
@@ -34,7 +31,7 @@ export default defineEventHandler(async event => {
from: new Date(from), from: new Date(from),
to: new Date(to), to: new Date(to),
color: snapshotColor, color: snapshotColor,
project_id: currentProjectId project_id: data.project_id
}); });
return newSnapshot.id; return newSnapshot.id;

View File

@@ -1,10 +1,12 @@
import { ProjectModel } from "@schema/ProjectSchema"; import { ProjectModel } from "@schema/ProjectSchema";
import { ProjectSnapshotModel } from "@schema/ProjectSnapshot"; import { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
import { UserSettingsModel } from "@schema/UserSettings";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false, allowGuests: false, requireRange: false });
if (!data) return;
const body = await readBody(event); const body = await readBody(event);
const { id: snapshotId } = body; const { id: snapshotId } = body;
@@ -14,18 +16,11 @@ export default defineEventHandler(async event => {
const userData = getRequestUser(event); const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged'); if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { active_project_id: 1 }); const project = await ProjectModel.findById(data.project_id);
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'); if (!project) return setResponseStatus(event, 400, 'Project not found');
const deletation = await ProjectSnapshotModel.deleteOne({ const deletation = await ProjectSnapshotModel.deleteOne({
project_id: currentProjectId, project_id: data.project_id,
_id: snapshotId _id: snapshotId
}); });

View File

@@ -4,6 +4,7 @@ import { AuthContext } from "~/server/middleware/01-authorization";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const userData: AuthContext = getRequestUser(event) as any; const userData: AuthContext = getRequestUser(event) as any;
if (!userData.logged) return; if (!userData.logged) return;
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { max_projects: 1 }); // const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { max_projects: 1 });
return userSettings?.max_projects || 3; // return userSettings?.max_projects || 3;
return 20;
}); });

View File

@@ -0,0 +1,26 @@
import { AppsumoCodeModel } from '@schema/AppsumoCodeSchema';
import { AppsumoCodeTryModel } from '@schema/AppsumoCodeTrySchema';
export async function canTryAppsumoCode(project_id: string) {
const tries = await AppsumoCodeTryModel.findOne({ project_id });
if (!tries) return true;
if (tries.codes.length >= 30) return false;
return true;
}
export async function useTryAppsumoCode(project_id: string, code: string) {
await AppsumoCodeTryModel.updateOne({ project_id }, { $push: { codes: code } }, { upsert: true });
}
export async function checkAppsumoCode(code: string) {
const target = await AppsumoCodeModel.exists({ code, used_at: { $exists: false } });
return target;
}
export async function useAppsumoCode(project_id: string, code: string) {
await AppsumoCodeTryModel.updateOne({ project_id }, { $push: { valid_codes: code } }, { upsert: true });
await AppsumoCodeModel.updateOne({ code }, { used_at: Date.now() });
}

View File

@@ -168,6 +168,23 @@ class StripeService {
return false; return false;
} }
async createSubscription(customer_id: string, planId: number) {
if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized');
const PLAN = getPlanFromId(planId);
if (!PLAN) throw Error('Plan not found');
const subscription = await this.stripe.subscriptions.create({
customer: customer_id,
items: [
{ price: this.testMode ? PLAN.PRICE_TEST : PLAN.PRICE, quantity: 1 }
],
});
return subscription;
}
async createOneTimeSubscriptionDummy(customer_id: string, planId: number) { async createOneTimeSubscriptionDummy(customer_id: string, planId: number) {
if (this.disabledMode) return; if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized'); if (!this.stripe) throw Error('Stripe not initialized');

View File

@@ -62,7 +62,7 @@ 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()); const allKeys = !options.advanced ? [] : Array.from(new Set(result.data.map((e: any) => e[options.advancedGroupKey])).values());
console.log({allKeys}) console.log({ allKeys, allDates })
const fixed: any[] = allDates.map(matchDate => { const fixed: any[] = allDates.map(matchDate => {
@@ -102,9 +102,9 @@ console.log({allKeys})
if (slice == 'hour') { if (slice == 'hour') {
return `${e._id.getHours().toString().padStart(2, '0')}:00` return `${e._id.getHours().toString().padStart(2, '0')}:00`
} else if (slice == 'day') { } else if (slice == 'day') {
return `${e._id.getDate().toString().padStart(2, '0')}/${e._id.getMonth().toString().padStart(2, '0')}` return `${e._id.getDate().toString().padStart(2, '0')}/${(e._id.getMonth() + 1).toString().padStart(2, '0')}`
} else if (slice == 'month') { } else if (slice == 'month') {
return `${e._id.getMonth().toString().padStart(2, '0')}/${e._id.getFullYear().toString()}` return `${(e._id.getMonth() + 1).toString().padStart(2, '0')}/${e._id.getFullYear().toString()}`
} else if (slice == 'year') { } else if (slice == 'year') {
return `${e._id.getFullYear().toString()}` return `${e._id.getFullYear().toString()}`
} else { } else {

View File

@@ -14,7 +14,10 @@ export const PREMIUM_TAGS = [
'SCALING', 'SCALING',
'UNICORN', 'UNICORN',
'LIFETIME_GROWTH_ONETIME', 'LIFETIME_GROWTH_ONETIME',
'GROWTH_DUMMY' 'GROWTH_DUMMY',
'APPSUMO_INCUBATION',
'APPSUMO_ACCELERATION',
'APPSUMO_GROWTH',
] as const; ] as const;
@@ -123,7 +126,31 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
PRICE: 'price_1PvgoRB2lPUiVs9VC51YBT7J', PRICE: 'price_1PvgoRB2lPUiVs9VC51YBT7J',
PRICE_TEST: 'price_1PvgRTB2lPUiVs9V3kFSNC3G', PRICE_TEST: 'price_1PvgRTB2lPUiVs9V3kFSNC3G',
COST: 0 COST: 0
} },
APPSUMO_INCUBATION: {
ID: 6001,
COUNT_LIMIT: 50_000,
AI_MESSAGE_LIMIT: 30,
PRICE: 'price_1QIXwbB2lPUiVs9VKSsoksaU',
PRICE_TEST: '',
COST: 0
},
APPSUMO_ACCELERATION: {
ID: 6002,
COUNT_LIMIT: 150_000,
AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1QIXxRB2lPUiVs9VrjaVRoOl',
PRICE_TEST: '',
COST: 0
},
APPSUMO_GROWTH: {
ID: 6003,
COUNT_LIMIT: 500_000,
AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1QIXy8B2lPUiVs9VQBOUPAoE',
PRICE_TEST: '',
COST: 0
},
} }
try { try {

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAppsumoCode = {
_id: Schema.Types.ObjectId,
code: string,
used_at: Date,
created_at?: Date,
}
const AppsumoCodeSchema = new Schema<TAppsumoCode>({
code: { type: String, index: 1 },
created_at: { type: Date, default: () => Date.now() },
used_at: { type: Date, required: false },
});
export const AppsumoCodeModel = model<TAppsumoCode>('appsumo_codes', AppsumoCodeSchema);

View File

@@ -0,0 +1,15 @@
import { model, Schema, Types } from 'mongoose';
export type TAppsumoCodeTry = {
project_id: Types.ObjectId,
codes: string[],
valid_codes: string[],
}
const AppsumoCodeTrySchema = new Schema<TAppsumoCodeTry>({
project_id: { type: Schema.Types.ObjectId, required: true, unique: true, index: 1 },
codes: [{ type: String }],
valid_codes: [{ type: String }]
});
export const AppsumoCodeTryModel = model<TAppsumoCodeTry>('appsumo_codes_tries', AppsumoCodeTrySchema);

View File

@@ -6,7 +6,8 @@ export type TProjectCount = {
events: number, events: number,
visits: number, visits: number,
sessions: number, sessions: number,
lastRecheck?: Date lastRecheck?: Date,
updated_at: Date
} }
const ProjectCountSchema = new Schema<TProjectCount>({ const ProjectCountSchema = new Schema<TProjectCount>({
@@ -14,7 +15,8 @@ const ProjectCountSchema = new Schema<TProjectCount>({
events: { type: Number, required: true, default: 0 }, events: { type: Number, required: true, default: 0 },
visits: { type: Number, required: true, default: 0 }, visits: { type: Number, required: true, default: 0 },
sessions: { type: Number, required: true, default: 0 }, sessions: { type: Number, required: true, default: 0 },
lastRecheck: { type: Date } lastRecheck: { type: Date },
}); updated_at: { type: Date }
}, { timestamps: { updatedAt: 'updated_at' } });
export const ProjectCountModel = model<TProjectCount>('project_counts', ProjectCountSchema); export const ProjectCountModel = model<TProjectCount>('project_counts', ProjectCountSchema);

View File

@@ -13,7 +13,7 @@ const EventSchema = new Schema<TEvent>({
project_id: { type: Types.ObjectId, index: 1 }, project_id: { type: Types.ObjectId, index: 1 },
name: { type: String, required: true, index: 1 }, name: { type: String, required: true, index: 1 },
metadata: Schema.Types.Mixed, metadata: Schema.Types.Mixed,
session: { type: String }, session: { type: String, index: 1 },
flowHash: { type: String }, flowHash: { type: String },
created_at: { type: Date, default: () => Date.now(), index: true }, created_at: { type: Date, default: () => Date.now(), index: true },
}) })

View File

@@ -12,7 +12,7 @@ export type TSession = {
const SessionSchema = new Schema<TSession>({ const SessionSchema = new Schema<TSession>({
project_id: { type: Types.ObjectId, index: 1 }, project_id: { type: Types.ObjectId, index: 1 },
session: { type: String, required: true }, session: { type: String, required: true, index: 1 },
flowHash: { type: String }, flowHash: { type: String },
duration: { type: Number, required: true, default: 0 }, duration: { type: Number, required: true, default: 0 },
updated_at: { type: Date, default: () => Date.now() }, updated_at: { type: Date, default: () => Date.now() },