12 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
fr0st-iwnl
fa7880552a fix: language correction 2024-10-29 04:22:38 +02:00
49 changed files with 1075 additions and 229 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

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 }}
@@ -132,7 +132,7 @@ function openExternalLink(link: string) {
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,13 +140,13 @@ 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]">
<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>

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>
@@ -256,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>

View File

@@ -104,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">
@@ -116,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">
@@ -124,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

@@ -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

@@ -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> Hide </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

@@ -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>

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 })
@@ -224,7 +228,8 @@ async function signInWithCredentials() {
</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,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');
@@ -113,7 +117,8 @@ async function registerAccount() {
</RouterLink>
</div>
<div v-if="!emailSended" class="text-[.9rem] poppins mt-5 xl: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,8 +5,10 @@ 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' }
]
@@ -21,12 +23,18 @@ const items = [
<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

@@ -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() },