mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9de299d841 | ||
|
|
2929b229c4 | ||
|
|
f06d7d78fc | ||
|
|
4d7cfbb7b9 | ||
|
|
b4c0620f17 | ||
|
|
b8c2e40f7a | ||
|
|
e866a1c22b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
steps
|
||||
PROCESS_EVENT
|
||||
**/node_modules/
|
||||
docker
|
||||
dev
|
||||
docker-compose.admin.yml
|
||||
|
||||
@@ -67,6 +67,9 @@ const { visible } = usePricingDrawer();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<UModals />
|
||||
|
||||
<NuxtLayout>
|
||||
<NuxtPage></NuxtPage>
|
||||
</NuxtLayout>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -140,8 +140,8 @@ 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
82
dashboard/components/dialog/DeleteDomainData.vue
Normal file
82
dashboard/components/dialog/DeleteDomainData.vue
Normal 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>
|
||||
58
dashboard/components/settings/Codes.vue
Normal file
58
dashboard/components/settings/Codes.vue
Normal 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>
|
||||
154
dashboard/components/settings/Data.vue
Normal file
154
dashboard/components/settings/Data.vue
Normal 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>
|
||||
42
dashboard/composables/useCountryName.ts
Normal file
42
dashboard/composables/useCountryName.ts
Normal 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;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export type CustomDialogOptions = {
|
||||
params?: any,
|
||||
width?: string,
|
||||
height?: string,
|
||||
closable?: boolean
|
||||
closable?: boolean,
|
||||
}
|
||||
|
||||
function openDialogEx(component: Component, options?: CustomDialogOptions) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -142,9 +142,17 @@ async function resetCount(project_id: string) {
|
||||
</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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
dashboard/server/api/pay/redeem_appsumo_code.post.ts
Normal file
51
dashboard/server/api/pay/redeem_appsumo_code.post.ts
Normal 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);
|
||||
|
||||
});
|
||||
14
dashboard/server/api/pay/valid_codes.ts
Normal file
14
dashboard/server/api/pay/valid_codes.ts
Normal 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 }
|
||||
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
36
dashboard/server/api/settings/delete_all.delete.ts
Normal file
36
dashboard/server/api/settings/delete_all.delete.ts
Normal 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`);
|
||||
|
||||
}
|
||||
104
dashboard/server/api/settings/delete_domain.delete.ts
Normal file
104
dashboard/server/api/settings/delete_domain.delete.ts
Normal 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`);
|
||||
|
||||
}
|
||||
79
dashboard/server/api/settings/domain_counts.ts
Normal file
79
dashboard/server/api/settings/domain_counts.ts
Normal 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 }
|
||||
}
|
||||
});
|
||||
18
dashboard/server/api/settings/domains.ts
Normal file
18
dashboard/server/api/settings/domains.ts
Normal 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 }[];
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
26
dashboard/server/services/AppsumoService.ts
Normal file
26
dashboard/server/services/AppsumoService.ts
Normal 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() });
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
16
shared/schema/AppsumoCodeSchema.ts
Normal file
16
shared/schema/AppsumoCodeSchema.ts
Normal 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);
|
||||
15
shared/schema/AppsumoCodeTrySchema.ts
Normal file
15
shared/schema/AppsumoCodeTrySchema.ts
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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() },
|
||||
|
||||
Reference in New Issue
Block a user