18 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
Ikko Eltociear Ashimine
e43f138945 docs: update README.md
enviroments -> environments
2024-10-11 08:18:09 +09:00
64 changed files with 1183 additions and 300 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
steps
PROCESS_EVENT
**/node_modules/
docker
dev
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
```
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">
<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>
<UModals />
<NuxtLayout>
<NuxtPage></NuxtPage>
</NuxtLayout>

View File

@@ -1,7 +1,7 @@
<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 = {
@@ -80,7 +80,7 @@ function openExternalLink(link: string) {
</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 items-center gap-2">
<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>
<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]">
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
<img v-if="iconProvider(element)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element)?.[1]">
<i v-else :class="iconProvider(element._id)?.[1]"></i>
<i v-else :class="iconProvider(element)?.[1]"></i>
</div>
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
{{ elementTextTransformer?.(element._id) || element._id }}
@@ -125,15 +125,14 @@ function openExternalLink(link: string) {
</div>
</div>
</div>
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
formatNumberK(element.count) }} </div>
</div>
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]">
No data yet
</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')"
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
Show more

View File

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

View File

@@ -1,6 +1,18 @@
<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 }[]) {
console.log(data);
return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : e._id }))
@@ -34,9 +46,9 @@ async function showMore() {
<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 || []"
:dataIcons="false" desc="The devices most used to access your website." :loading="devicesData.pending.value"
label="Top Devices" sub-label="Devices"></BarCardBase>
:iconProvider="iconProvider" :dataIcons="true" desc="The devices most used to access your website."
:loading="devicesData.pending.value" label="Devices" sub-label="Devices"></BarCardBase>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ import 'highlight.js/styles/stackoverflow-dark.css';
import hljs from 'highlight.js';
import CardTitled from './CardTitled.vue';
import { Lit } from 'litlyx-js';
const props = defineProps<{
firstInteraction: boolean,
refreshInteraction: () => any
@@ -19,6 +21,7 @@ onMounted(() => {
function copyProjectId() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText(project.value?._id?.toString() || '');
Lit.event('no_visit_copy_id');
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
}
@@ -36,6 +39,7 @@ function copyScript() {
].join('')
}
Lit.event('no_visit_copy_script');
navigator.clipboard.writeText(createScriptText());
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
}
@@ -53,6 +57,7 @@ const scriptText = computed(() => {
function reloadPage() {
location.reload();
}
</script>
<template>
@@ -76,26 +81,32 @@ function reloadPage() {
<div class="flex items-center justify-center mt-10">
<div class="flex flex-col gap-6">
<div class="flex gap-6">
<div>
<CardTitled class="h-full" title="Tutorial" sub="Coming soon. For now enjoy our launch video.">
<div class="flex items-center justify-center h-full">
<iframe width="560" height="315"
<div class="flex gap-6 xl:flex-row flex-col">
<div class="h-full w-full">
<CardTitled class="h-full w-full xl:min-w-[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"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>
</CardTitled>
</div>
<div class="flex flex-col gap-6">
<div>
<div class="w-full">
<CardTitled title="Quick Integration"
sub="Start tracking web analytics in one line. (works everywhere js is supported)">
<div class="flex flex-col items-end gap-4">
<div class="w-full">
<div class="w-full xl:text-[1rem] text-[.8rem]">
<pre><code class="language-html">{{ scriptText }}</code></pre>
</div>
<LyxUiButton type="secondary" @click="copyScript()">
@@ -122,7 +133,7 @@ function reloadPage() {
<CardTitled class="w-full h-full" title="Documentation"
sub="Learn how to use Litlyx in every tech stack">
<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"
xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_473_1361" fill="white">
@@ -250,7 +261,9 @@ function reloadPage() {
</defs>
</svg>
</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>
</div>
</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="poppins font-semibold"> Copy your project_id: </div>
@@ -273,7 +286,7 @@ function reloadPage() {
</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">
Start logging visits in 1 click | Plug anywhere !
</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 class="flex flex-col grow">
<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 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
Plan for our first 100 users who believe in our project.
<br>
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
claim your discount.
</div>
</div> -->
</div>
<div>
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>

View File

@@ -251,7 +251,7 @@ const legendClasses = ref<string[]>([
</SelectButton>
</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">
<div class="flex items-center gap-2 px-10">
<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 }[]) {
const data = input.map(e => e.count || 0);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
const pool = [...input.map(e => e.count || 0)];
pool.pop();
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
const diffPercent: number = (100 / avg * (input.at(-1)?.count || 0)) - 100;
const 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);
return { data, labels, trend }
}
const visitsData = useFetch('/api/timeline/visits', {
@@ -94,7 +104,7 @@ const avgSessionDuration = computed(() => {
<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">
<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) || '...')"
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
@@ -106,7 +116,7 @@ const avgSessionDuration = computed(() => {
</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) || '...')"
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
: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"
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"
color="#f56523">
</DashboardCountCard>

View File

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

@@ -140,7 +140,7 @@ async function onEventCheck(eventName: string) {
<template>
<CardTitled title="Funnel"
sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience">
<div class="flex gap-2 justify-between">
<div class="flex gap-2 justify-between lg:flex-row flex-col">
<div class="flex flex-col gap-1">
<div class="min-w-[20rem] text-lyx-text-darker">
Select two or more events

View File

@@ -104,7 +104,7 @@ const canSearch = computed(() => {
</USelectMenu>
</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]">
Search results: {{ metadataFieldGroupedFiltered.length }}
</div>
@@ -119,13 +119,13 @@ const canSearch = computed(() => {
</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"
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.count }} </div>
<div class="px-1"> {{ item.count }} </div>
</div>
</div>

View File

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

View File

@@ -62,7 +62,13 @@ async function analyzeEvent() {
</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 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 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
</div>
<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}"
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
</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>
<div class="flex justify-end w-full">
<LyxUiButton type="outline" class="flex lg:hidden mt-4">
Copy script
</LyxUiButton>
</div>
</template>
<template #pdelete>
<div class="flex justify-end" v-if="!isGuest">
<div class="flex lg:justify-end" v-if="!isGuest">
<LyxUiButton type="danger" @click="deleteProject()">
Delete project
</LyxUiButton>

View File

@@ -16,10 +16,10 @@ const props = defineProps<SettingsTemplateProp>();
<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 class="flex">
<div class="flex-[2]">
<div class="flex xl:flex-row flex-col gap-4 xl:gap-0">
<div class="xl:flex-[2]">
<div class="poppins font-medium text-lyx-text">
{{ entry.title }}
</div>
@@ -27,7 +27,7 @@ const props = defineProps<SettingsTemplateProp>();
{{ entry.text }}
</div>
</div>
<div class="flex-[3]">
<div class="xl:flex-[3]">
<slot :name="entry.id"></slot>
</div>
</div>

View File

@@ -164,7 +164,7 @@ const { visible } = usePricingDrawer();
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
</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' }}
</div>
</div>
@@ -190,7 +190,7 @@ const { visible } = usePricingDrawer();
</div>
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
</div>
<div class="flex justify-between px-8 flex-col sm:flex-row">
<div class="flex 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="poppins"> Expire date:</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,
width?: string,
height?: string,
closable?: boolean
closable?: boolean,
}
function openDialogEx(component: Component, options?: CustomDialogOptions) {

View File

@@ -15,7 +15,7 @@ const sections: Section[] = [
entries: [
{ label: 'Web Analytics', to: '/', icon: 'fal fa-table-layout' },
{ 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: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', 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' });
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch('/api/admin/counts', signHeaders());
type TProjectsGrouped = {
user: {
name: string,
email: string,
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 timeRange = ref<number>(9);
function setTimeRange(n: number) {
timeRange.value = n;
}
const projectsGrouped = computed(() => {
if (!projects.value) return [];
const result: TProjectsGrouped[] = [];
for (const project of projects.value) {
if (!project.user) continue;
const timeRangeTimestamp = computed(()=>{
if (timeRange.value == 1) return Date.now() - 1000 * 60 * 60 * 24;
if (timeRange.value == 2) return Date.now() - 1000 * 60 * 60 * 24 * 7;
if (timeRange.value == 3) return Date.now() - 1000 * 60 * 60 * 24 * 30;
return 0;
})
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() {
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(() => {
let premiums = 0;
projects.value?.forEach(e => {
if (e.premium) premiums++;
projectsAggregated.value?.forEach(e => {
e.projects.forEach(p => {
if (p.premium) 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(() => {
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(() => {
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 showDetails = ref<boolean>(false);
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());
}
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>
@@ -138,13 +138,21 @@ async function resetCount(project_id: string) {
<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">
<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>
<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">
<div class="grid grid-cols-2">
<div class="grid grid-cols-2 gap-1">
<div>
Users: {{ counts?.users }}
</div>
@@ -154,6 +162,10 @@ async function resetCount(project_id: string) {
<div>
Total visits: {{ formatNumberK(totalVisits) }}
</div>
<div>
Active: {{ activeProjects }} |
Dead: {{ (counts?.projects || 0) - activeProjects }}
</div>
<div>
Total events: {{ formatNumberK(totalEvents) }}
</div>
@@ -162,17 +174,25 @@ async function resetCount(project_id: string) {
</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-1">
<div> {{ item.user.email }} </div>
<div> {{ item.user.name }} </div>
<div> {{ item.email }} </div>
<div> {{ item.name }} </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"
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="font-bold"> {{ project.premium ? 'PREMIUM' : 'FREE' }} </div>
<div class="text-text-sub/90">
@@ -181,14 +201,14 @@ async function resetCount(project_id: string) {
</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> Visits: </div>
<div> {{ project.total_visits }} </div>
<div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
<div> Events: </div>
<div> {{ project.total_events }} </div>
<div> {{ formatNumberK(project.counts?.events || 0) }} </div>
<div> Sessions: </div>
<div> {{ project.total_sessions }} </div>
<div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
</div>
<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 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">
<div class="w-[7rem] lg:w-[10rem]">
<div class="w-[7rem] xl:w-[10rem]">
<img :src="'analyst.png'" class="w-full h-full">
</div>
<div class="poppins text-[1.2rem] text-center">
Ask me anything about your data
</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"
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 }}
@@ -216,7 +216,7 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
<i class="far fa-arrow-up"></i>
</div>
<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>
</div>
</div>
@@ -225,12 +225,12 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
<div :class="{
'absolute': menuOpen,
'hidden lg:flex': !menuOpen
'absolute top-0 left-0 w-full': menuOpen,
'hidden xl:flex': !menuOpen
}" 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="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>
</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">
<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>
Total events: {{ eventsData.data.value?.[0]?.count || '0' }}
@@ -29,7 +29,7 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
</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
</LyxUiButton>
</div>
@@ -40,9 +40,9 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
<BarCardEvents :key="refreshKey"></BarCardEvents>
</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.">
<template #header>
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
@@ -55,7 +55,7 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
</div>
</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.">
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
</CardTitled>

View File

@@ -35,7 +35,7 @@ const showDashboard = computed(() => project.value && firstInteraction.data.valu
<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">
@@ -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 gap-6 flex-col xl:flex-row">
<div class="flex-1">
<BarCardWebsites :key="refreshKey"></BarCardWebsites>
</div>
<div class="flex-1">
<BarCardReferrers :key="refreshKey"></BarCardReferrers>
</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>
<BarCardWebsites :key="refreshKey"></BarCardWebsites>
</div>
</div>
</div>
@@ -86,6 +75,17 @@ const showDashboard = computed(() => project.value && firstInteraction.data.valu
</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>
@@ -97,8 +97,11 @@ const showDashboard = computed(() => project.value && firstInteraction.data.valu
Create your first project...
</div>
<div v-if="justLogged" class="text-[2rem]">
The page will refresh soon
<div v-if="justLogged" class="text-[2rem] w-full h-full flex items-center justify-center">
<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>

View File

@@ -2,6 +2,8 @@
definePageMeta({ layout: 'none' });
import { Lit } from 'litlyx-js';
const config = useRuntimeConfig()
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 })
})
Lit.event('google_login_signup');
if (result.error) return alert('Error during login, please try again');
setToken(result.access_token);
@@ -120,7 +124,7 @@ function goBackToEmailLogin() {
async function signInWithCredentials() {
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value, password: password.value })
@@ -159,7 +163,7 @@ async function signInWithCredentials() {
<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>
@@ -171,9 +175,8 @@ async function signInWithCredentials() {
Sign in
</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
<br>
with extreme simplicity in under 30 sec.
<br>
<!-- <div class="font-bold poppins mt-4">
@@ -221,11 +224,12 @@ async function signInWithCredentials() {
<div class="flex items-center">
<i class="far fa-envelope"></i>
</div>
Continue with Email
Sign in with Email
</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
</RouterLink>

View File

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

View File

@@ -2,6 +2,7 @@
definePageMeta({ layout: 'none' });
import { Lit } from 'litlyx-js';
const emailSended = ref<boolean>(false);
@@ -29,6 +30,9 @@ async function registerAccount() {
body: JSON.stringify({ email: email.value, password: password.value })
});
if (res.error === true) return alert(res.message);
Lit.event('email_signup');
emailSended.value = true;
} catch (ex) {
alert('Something went wrong');
@@ -45,7 +49,7 @@ async function registerAccount() {
<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>
@@ -57,9 +61,8 @@ async function registerAccount() {
Sign up
</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
<br>
with extreme simplicity in under 30 sec.
<br>
<!-- <div class="font-bold poppins mt-4">
@@ -114,7 +117,8 @@ async function registerAccount() {
</RouterLink>
</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
<br>
our

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import { ProjectModel } from "@schema/ProjectSchema";
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' }
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)

View File

@@ -16,9 +16,7 @@ export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { max_projects: true });
const maxProjects = userSettings?.max_projects || 3;
const maxProjects = 20;
const existingUserProjects = await ProjectModel.countDocuments({ owner: userData.id });
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 { ProjectModel } from "@schema/ProjectSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import { VisitModel } from '@schema/metrics/VisitSchema';
import { EventModel } from '@schema/metrics/EventSchema';
@@ -82,15 +81,13 @@ function createPdf(data: PDFGenerationData) {
export default defineEventHandler(async event => {
const data = await getRequestData(event, { requireSchema: false, allowGuests: true, requireRange: false });
if (!data) return;
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
const project_id = currentActiveProject.active_project_id;
const project = await ProjectModel.findById(project_id);
const project = await ProjectModel.findById(data.project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found');
const snapshotHeader = getHeader(event, 'x-snapshot-name');

View File

@@ -1,8 +1,7 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { TeamMemberModel } from "@schema/TeamMemberSchema";
import { UserModel } from "@schema/UserSchema";
import { UserSettingsModel } from "@schema/UserSettings";
import StripeService from '~/server/services/StripeService';
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 { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
import { UserSettingsModel } from "@schema/UserSettings";
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 { name: newSnapshotName, from, to, color: snapshotColor } = body;
@@ -19,13 +21,8 @@ export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { active_project_id: 1 });
if (!userSettings) return setResponseStatus(event, 500, 'Unkwnown error');
const currentProjectId = userSettings.active_project_id;
const project = await ProjectModel.findById(currentProjectId);
const project = await ProjectModel.findById(data.project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found');
@@ -34,7 +31,7 @@ export default defineEventHandler(async event => {
from: new Date(from),
to: new Date(to),
color: snapshotColor,
project_id: currentProjectId
project_id: data.project_id
});
return newSnapshot.id;

View File

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

View File

@@ -4,6 +4,7 @@ import { AuthContext } from "~/server/middleware/01-authorization";
export default defineEventHandler(async event => {
const userData: AuthContext = getRequestUser(event) as any;
if (!userData.logged) return;
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { max_projects: 1 });
return userSettings?.max_projects || 3;
// const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { max_projects: 1 });
// 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;
}
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) {
if (this.disabledMode) return;
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());
console.log({allKeys})
console.log({ allKeys, allDates })
const fixed: any[] = allDates.map(matchDate => {
@@ -102,9 +102,9 @@ console.log({allKeys})
if (slice == 'hour') {
return `${e._id.getHours().toString().padStart(2, '0')}:00`
} 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') {
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') {
return `${e._id.getFullYear().toString()}`
} else {

View File

@@ -14,7 +14,10 @@ export const PREMIUM_TAGS = [
'SCALING',
'UNICORN',
'LIFETIME_GROWTH_ONETIME',
'GROWTH_DUMMY'
'GROWTH_DUMMY',
'APPSUMO_INCUBATION',
'APPSUMO_ACCELERATION',
'APPSUMO_GROWTH',
] as const;
@@ -123,7 +126,31 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
PRICE: 'price_1PvgoRB2lPUiVs9VC51YBT7J',
PRICE_TEST: 'price_1PvgRTB2lPUiVs9V3kFSNC3G',
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 {

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,
visits: number,
sessions: number,
lastRecheck?: Date
lastRecheck?: Date,
updated_at: Date
}
const ProjectCountSchema = new Schema<TProjectCount>({
@@ -14,7 +15,8 @@ const ProjectCountSchema = new Schema<TProjectCount>({
events: { type: Number, required: true, default: 0 },
visits: { 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);

View File

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

View File

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