admin panel

This commit is contained in:
Emily
2025-02-12 03:20:54 +01:00
parent a7ebbc22c0
commit f5882bff9f
18 changed files with 1207 additions and 406 deletions

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ dev
docker-compose.admin.yml docker-compose.admin.yml
full_reload.sh full_reload.sh
tmp tmp
ecosystem.config.js ecosystem.config.js
todo

View File

@@ -1,15 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
type CItem = { label: string, slot: string } export type CItem = { label: string, slot: string }
const props = defineProps<{ items: CItem[] }>(); const props = defineProps<{ items: CItem[], manualScroll?:boolean }>();
const activeTabIndex = ref<number>(0); const activeTabIndex = ref<number>(0);
</script> </script>
<template> <template>
<div> <div class="h-full flex flex-col">
<div class="flex overflow-y-auto hide-scrollbars"> <div class="flex overflow-x-auto hide-scrollbars">
<div class="flex"> <div class="flex">
<div v-for="(tab, index) of items" @click="activeTabIndex = index" <div v-for="(tab, index) of items" @click="activeTabIndex = index"
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker" class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
@@ -24,7 +24,7 @@ const activeTabIndex = ref<number>(0);
</div> </div>
</div> </div>
<div> <div :class="{'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
<slot :name="props.items[activeTabIndex].slot"></slot> <slot :name="props.items[activeTabIndex].slot"></slot>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
const props = defineProps<{ size?: string }>();
const widgetStyle = computed(() => {
return `height: ${props.size ?? '1px'}`;
})
</script>
<template>
<div :style="widgetStyle" class="bg-lyx-widget-light"></div>
</template>

View File

@@ -0,0 +1,271 @@
<script lang="ts" setup>
import DateService, { type Slice } from '@services/DateService';
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as fns from 'date-fns';
const props = defineProps<{ pid: string }>();
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
function createGradient(startColor: string) {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${startColor}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${startColor}99`);
gradient.addColorStop(0.35, `${startColor}66`);
gradient.addColorStop(1, `${startColor}22`);
} else {
console.warn('Cannot get context for gradient');
}
return gradient;
}
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: (false as any),
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
beginAtZero: true,
},
x: {
ticks: { display: true },
stacked: false,
offset: false,
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: { enabled: false }
},
});
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
labels: [],
datasets: [
{
label: 'Visits',
data: [],
backgroundColor: ['#5655d7'],
borderColor: '#5655d7',
borderWidth: 4,
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: '#5655d7',
hoverBorderColor: 'white',
hoverBorderWidth: 2,
segment: {
borderColor(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return '#5655d7';
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
return '#5655d7'
},
borderDash(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return undefined;
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
return undefined;
},
backgroundColor(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
return createGradient('#5655d7');
},
},
},
{
label: 'Unique visitors',
data: [],
backgroundColor: ['#4abde8'],
borderColor: '#4abde8',
borderWidth: 2,
hoverBackgroundColor: '#4abde8',
hoverBorderColor: '#4abde8',
hoverBorderWidth: 2,
type: 'bar',
// barThickness: 20,
borderSkipped: ['bottom'],
},
{
label: 'Events',
data: [],
backgroundColor: ['#fbbf24'],
borderWidth: 2,
hoverBackgroundColor: '#fbbf24',
hoverBorderColor: '#fbbf24',
hoverBorderWidth: 2,
type: 'bubble',
stack: 'combined',
borderColor: ["#fbbf24"]
},
],
});
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
const selectedSlice: Slice = 'day'
const allDatesFull = ref<string[]>([]);
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
return { data, labels, todayIndex }
}
function onResponseError(e: any) {
let message = e.response._data.message ?? 'Generic error';
if (message == 'internal server error') message = 'Please change slice';
errorData.value = { errored: true, text: message }
}
function onResponse(e: any) {
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
}
const headers = computed(() => {
return {
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
'x-pid': props.pid
}
});
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}),
lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
watch(readyToDisplay, () => {
if (readyToDisplay.value === true) onDataReady();
})
function onDataReady() {
if (!visitsData.data.value) return;
if (!eventsData.data.value) return;
if (!sessionsData.data.value) return;
chartData.value.labels = visitsData.data.value.labels;
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
const maxEventSize = Math.max(...eventsData.data.value.data)
chartData.value.datasets[0].data = visitsData.data.value.data;
chartData.value.datasets[1].data = sessionsData.data.value.data;
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
const rValue = 20 / maxEventSize * e;
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
});
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return true;
return 'bottom';
});
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return '#fbbf2400';
return '#fbbf24';
});
updateChart();
}
</script>
<template>
<div class="h-[10rem] w-full flex">
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
</div>
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
{{ errorData.text }}
</div>
</div>
</template>
<style lang="scss" scoped>
#external-tooltip {
border-radius: 3px;
color: white;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translate(-50%, 0);
transition: all .1s ease;
}
</style>

View File

@@ -0,0 +1,109 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
const page = ref<number>(0);
const limit = ref<number>(20);
const ordersList = [
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
{ label: 'visits -->', id: '{ "visits": 1 }' },
{ label: 'visits <--', id: '{ "visits": -1 }' },
{ label: 'events -->', id: '{ "events": 1 }' },
{ label: 'events <--', id: '{ "events": -1 }' },
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const { data: projects, pending: pendingProjects } = await useFetch<TAdminProject[]>(
() => `/api/admin/projects?page=${page.value}&limit=${limit.value}&sortQuery=${order.value}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<!-- TODO: Move to metrics tab -->
<!-- TODO: Add project details button -->
<!-- TODO: Add project utilities -->
<div class="flex gap-2 items-center">
<div> Projects: </div>
<div> 123 </div>
<div> Premium: </div>
<div> 123 </div>
<div> Active: </div>
<div> 123 </div>
<div> Dead: </div>
<div> 123 </div>
</div>
<div class="flex gap-2 items-center">
<div> Users: </div>
<div> 123 </div>
</div>
<div class="flex gap-2 items-center">
<div> Total Visits: </div>
<div> 123 </div>
<div> Total Events: </div>
<div> 123 </div>
</div>
</div>
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
class="w-[26rem]" v-for="project of projects" />
<div v-if="pendingProjects"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import type { TAdminUserProjectInfo } from '~/server/api/admin/users_projects';
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
const page = ref<number>(0);
const limit = ref<number>(10);
const sortQuery = computed(() => {
return JSON.stringify({ created_at: 1 })
})
const { data: usersWithProjects } = await useFetch<TAdminUserProjectInfo[]>(
() => `/api/admin/users_projects?page=${page.value}&limit=${limit.value}&sortQuery=${sortQuery.value}`,
signHeaders()
);
</script>
<template>
<div class="px-2 cursor-default">
<div v-for="user of usersWithProjects" class="py-6">
{{ user.email }}
<div class="flex flex-wrap gap-4">
<div class="w-[22rem] outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md"
v-for="project of user.projects">
<div class="flex gap-4 justify-center">
<UTooltip :text="`PRICE_ID: ${project.premium_type.toString()}`">
<div class="font-medium">
{{ getPlanFromId(project.premium_type)?.TAG ?? 'ERROR' }}
</div>
</UTooltip>
<div class="text-lyx-text-darker">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex gap-5 justify-center">
<div class="font-medium">
{{ project.name }}
</div>
</div>
<div class="flex flex-col items-center mt-4">
<div class="flex gap-4">
<div class="flex gap-2">
<div class="text-right"> Visits:</div>
<div>{{ formatNumberK(project.visits) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Events:</div>
<div>{{ formatNumberK(project.events) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Sessions:</div>
<div>{{ formatNumberK(project.sessions) }}</div>
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<AdminMiniChart :pid="project._id.toString()"></AdminMiniChart>
<LyxUiSeparator class="my-2" />
<div class="flex gap-6 justify-around">
<div class="flex gap-1">
<div>
{{ formatNumberK(project.limits[0].visits + project.limits[0].events) }}
/ {{ formatNumberK(project.limits[0].limit) }}
</div>
<div class="text-lyx-text-dark">
~ {{
(100 / project.limits[0].limit * (project.limits[0].visits +
project.limits[0].events)).toFixed(1)
}}%
</div>
</div>
<div class="flex gap-2">
<div>
{{ formatNumberK(project.limits[0].ai_messages) }}
/ {{ formatNumberK(project.limits[0].ai_limit) }}
</div>
<div class="text-lyx-text-dark">
~ {{(100 / project.limits[0].ai_limit * project.limits[0].ai_messages).toFixed(1) }}%
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { getPlanFromId } from '~/shared/data/PREMIUM';
const props = defineProps<{ project: TAdminProject }>();
const usageLabel = computed(() => {
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
});
const usagePercentLabel = computed(() => {
const percent = 100 / props.project.limit_max * props.project.limit_total;
return `~ ${percent.toFixed(1)}%`;
});
const usageAiLabel = computed(() => {
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
}
); const usageAiPercentLabel = computed(() => {
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
return `~ ${percent.toFixed(1)}%`
});
</script>
<template>
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md">
<div class="flex gap-4 justify-center">
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
<div class="font-medium">
{{ getPlanFromId(project.premium_type)?.TAG ?? 'ERROR' }}
</div>
</UTooltip>
<div class="text-lyx-text-darker">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex gap-5 justify-center">
<div class="font-medium">
{{ project.name }}
</div>
</div>
<div class="flex flex-col items-center mt-4">
<div class="flex gap-4">
<div class="flex gap-2">
<div class="text-right"> Visits:</div>
<div>{{ formatNumberK(project.visits || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Events:</div>
<div>{{ formatNumberK(project.events || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Sessions:</div>
<div>{{ formatNumberK(project.sessions || 0) }}</div>
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="mb-2">
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
</div>
<div class="flex gap-6 justify-around">
<div class="flex gap-1">
<div>
{{ usageLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usagePercentLabel }}
</div>
</div>
<div class="flex gap-2">
<div>
{{ usageAiLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usageAiPercentLabel }}
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -96,7 +96,7 @@ const todayIndex = computed(() => {
<template> <template>
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4"> <div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 m-cards-wrap:grid-cols-4">
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth" <DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')" text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"

View File

@@ -0,0 +1,13 @@
export function useSelectMenuStyle() {
return {
uiMenu: {
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
}
}
}
}

View File

@@ -83,7 +83,7 @@ const { isOpen, close, open } = useMenu();
</div> --> </div> -->
</div> </div>
<div class="flex h-full"> <div class="flex h-full overflow-y-hidden">
<div v-if="isOpen" @click="close()" <div v-if="isOpen" @click="close()"
@@ -95,7 +95,7 @@ const { isOpen, close, open } = useMenu();
</LayoutVerticalNavigation> </LayoutVerticalNavigation>
<div class="overflow-hidden w-full bg-lyx-lightmode-background dark:bg-lyx-background relative h-full"> <div class="flex flex-col overflow-hidden w-full bg-lyx-lightmode-background dark:bg-lyx-background relative h-full">
<div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]"> <div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]">
<i <i
@@ -107,9 +107,9 @@ const { isOpen, close, open } = useMenu();
<DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard> <DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard>
</div> </div>
<LayoutTopNavigation class="flex"></LayoutTopNavigation> <LayoutTopNavigation class="flex shrink-0"></LayoutTopNavigation>
<div class="h-full pb-[3rem]"> <div class="flex-1 overflow-auto">
<slot></slot> <slot></slot>
</div> </div>

View File

@@ -1,135 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const entries = [
{
label: 'Home',
icon: 'i-heroicons-home',
to: '/',
},
{
label: 'Pricing',
icon: 'i-heroicons-currency-dollar',
to: '/pricing'
},
{
label: 'FAQ',
icon: 'i-heroicons-question-mark-circle',
to: '/faq'
}
]
const loggedUser = useLoggedUser();
const { setToken } = useAccessToken();
</script>
<template>
<div class="layout h-full flex flex-col pt-1 px-1">
<div class="text-white flex items-center py-4 pl-10 gap-2 mx-20">
<div class="flex gap-4 items-center">
<div class="bg-[#2969f1] h-[2.8rem] aspect-[1/1] flex items-center justify-center rounded-lg">
<img class="h-[1.8rem]" :src="'/logo.png'">
</div>
<div class="font-bold text-[1.6rem] text-gray-300 poppins"> Litlyx </div>
</div>
<!-- <div class="flex items-center gap-4">
<div class="w-8 h-8 bg-blue-400"></div>
<div class="font-bold text-[1.2rem] poppins"> Litlyx </div>
</div> -->
<div class="grow"></div>
<div class="flex gap-8 text-[1rem] text-white font-[500] poppins">
<div> Open metrics </div>
<div> Docs </div>
<div> Pricing </div>
<div> GitHub </div>
<div> FAQ </div>
</div>
<div class="px-10">
<div class="poppins font-[500] px-4 py-[.3rem] bg-accent rounded-xl"> Open App </div>
</div>
</div>
<div class="overflow-y-auto shrink h-full">
<div>
<slot></slot>
</div>
<div class="flex justify-center text-[1.3rem] items-center poppins py-16">
Made with in Italy
</div>
<div class="border-t-[1px] border-accent/40 flex h-fit py-12 w-full justify-between px-[8rem] footer">
<div class="flex flex-col gap-7">
<div class="flex items-center gap-2">
<!-- <div class="flex items-center justify-center">
<img :src="'logo.png'" class="h-[1.5rem]">
</div> -->
<div class="poppins font-bold text-[1.6rem] text-text/90">
Litlyx
</div>
</div>
<div class="flex gap-6 text-[1.5rem] text-text-sub/80">
<div> <i class="fab fa-x-twitter"></i> </div>
<div> <i class="fab fa-linkedin"></i> </div>
</div>
<div>
<div class="text-[.9rem] text-text-sub/80"> © 2024 Epictech Development S.r.l. All right
reserved.
</div>
</div>
</div>
<div class="flex gap-20">
<div class="flex flex-col gap-4">
<div class="text-text-sub/60 font-semibold"> Product </div>
<div class="hover:text-accent cursor-pointer"> Pricing </div>
<div class="hover:text-accent cursor-pointer"> Docs </div>
<div class="hover:text-accent cursor-pointer"> Github </div>
</div>
<div class="flex flex-col gap-4">
<div class="text-text-sub/60 font-semibold"> Company </div>
<div class="hover:text-accent cursor-pointer"> About </div>
<div class="hover:text-accent cursor-pointer"> Contact us </div>
</div>
<div class="flex flex-col gap-4">
<div class="text-text-sub/60 font-semibold"> Legal </div>
<div class="hover:text-accent cursor-pointer"> Privacy policy </div>
<div class="hover:text-accent cursor-pointer"> Terms and conditions </div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.banner * {
font-family: "Nunito";
}
.layout * {
font-family: "Inter";
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AdminProjectsList } from '~/server/api/admin/projects'; import type { CItem } from '~/components/CustomTab.vue';
definePageMeta({ layout: 'dashboard' }); definePageMeta({ layout: 'dashboard' });
@@ -24,110 +24,110 @@ const timeRangeTimestamp = computed(() => {
}) })
const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders()); // const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders()); // const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
function onHideClicked() { // function onHideClicked() {
isAdminHidden.value = true; // isAdminHidden.value = true;
} // }
function isAppsumoType(type: number) { // function isAppsumoType(type: number) {
return type > 6000 && type < 6004 // return type > 6000 && type < 6004
} // }
const projectsAggregated = computed(() => { // const projectsAggregated = computed(() => {
let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : []; // let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : [];
let shownPool: AdminProjectsList[] = []; // let shownPool: AdminProjectsList[] = [];
for (const element of pool) { // for (const element of pool) {
shownPool.push({ ...element, projects: [...element.projects] }); // shownPool.push({ ...element, projects: [...element.projects] });
if (filterAppsumo.value === true) { // if (filterAppsumo.value === true) {
shownPool.forEach(e => { // shownPool.forEach(e => {
e.projects = e.projects.filter(project => { // e.projects = e.projects.filter(project => {
return isAppsumoType(project.premium_type) // return isAppsumoType(project.premium_type)
}) // })
}) // })
shownPool = shownPool.filter(e => { // shownPool = shownPool.filter(e => {
return e.projects.length > 0; // return e.projects.length > 0;
}) // })
} else if (filterPremium.value === true) { // } else if (filterPremium.value === true) {
shownPool.forEach(e => { // shownPool.forEach(e => {
e.projects = e.projects.filter(project => { // e.projects = e.projects.filter(project => {
return project.premium === true; // return project.premium === true;
}) // })
}) // })
shownPool = shownPool.filter(e => { // shownPool = shownPool.filter(e => {
return e.projects.length > 0; // return e.projects.length > 0;
}) // })
} else { // } else {
console.log('NO DATA') // console.log('NO DATA')
} // }
} // }
return shownPool.sort((a, b) => { // return shownPool.sort((a, b) => {
const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0); // 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); // const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
return sumVisitsB - sumVisitsA; // return sumVisitsB - sumVisitsA;
}).filter(e => { // }).filter(e => {
return new Date(e.created_at).getTime() >= timeRangeTimestamp.value // return new Date(e.created_at).getTime() >= timeRangeTimestamp.value
}); // });
}) // })
const premiumCount = computed(() => { // const premiumCount = computed(() => {
let premiums = 0; // let premiums = 0;
projectsAggregated.value?.forEach(e => { // projectsAggregated.value?.forEach(e => {
e.projects.forEach(p => { // e.projects.forEach(p => {
if (p.premium) premiums++; // if (p.premium) premiums++;
}); // });
}) // })
return premiums; // return premiums;
}) // })
const activeProjects = computed(() => { // const activeProjects = computed(() => {
let actives = 0; // let actives = 0;
projectsAggregated.value?.forEach(e => { // projectsAggregated.value?.forEach(e => {
e.projects.forEach(p => { // e.projects.forEach(p => {
if (!p.counts) return; // if (!p.counts) return;
if (!p.counts.updated_at) return; // if (!p.counts.updated_at) return;
const updated_at = new Date(p.counts.updated_at).getTime(); // const updated_at = new Date(p.counts.updated_at).getTime();
if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return; // if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return;
actives++; // actives++;
}); // });
}) // })
return actives; // return actives;
}); // });
const totalVisits = computed(() => { // const totalVisits = computed(() => {
return projectsAggregated.value?.reduce((a, e) => { // return projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0); // return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
}, 0) || 0; // }, 0) || 0;
}); // });
const totalEvents = computed(() => { // const totalEvents = computed(() => {
return projectsAggregated.value?.reduce((a, e) => { // return projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0); // return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0);
}, 0) || 0; // }, 0) || 0;
}); // });
@@ -165,121 +165,25 @@ function getLogBg(last_logged_at?: string) {
} }
const tabs: CItem[] = [
{ label: 'Overview', slot: 'overview' },
{ label: 'Premiums', slot: 'premiums' },
{ label: 'Feedbacks', slot: 'feedbacks' },
{ label: 'OnBoarding', slot: 'onboarding' },
{ label: 'Backend', slot: 'backend' }
]
</script> </script>
<template> <template>
<div class="bg-bg overflow-y-auto w-full h-dvh p-6 gap-6 flex flex-col"> <div class="bg-bg overflow-y-hidden w-full p-6 gap-6 flex flex-col h-full">
<div v-if="showDetails" <CustomTab :items="tabs" :manualScroll="true">
class="w-full md:px-40 h-full fixed top-0 left-0 bg-black/90 backdrop-blur-[2px] z-[20] overflow-y-auto"> <template #overview>
<div class="cursor-pointer bg-red-400 w-fit px-10 py-2 rounded-lg font-semibold my-3" <AdminOverview></AdminOverview>
@click="showDetails = false"> </template>
Close </CustomTab>
</div>
<div class="whitespace-pre-wrap poppins">
{{ JSON.stringify(details, null, 3) }}
</div>
</div>
<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 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-2 flex gap-10 items-center justify-center">
<UCheckbox v-model="filterPremium" label="Filter Premium"></UCheckbox>
<UCheckbox v-model="filterAppsumo" label="Filter Appsumo"></UCheckbox>
</Card>
<Card class="p-4">
<div class="grid grid-cols-2 gap-1">
<div>
Users: {{ counts?.users }}
</div>
<div>
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
</div>
<div>
Total visits: {{ formatNumberK(totalVisits) }}
</div>
<div>
Active: {{ activeProjects }} |
Dead: {{ (counts?.projects || 0) - activeProjects }}
</div>
<div>
Total events: {{ formatNumberK(totalEvents) }}
</div>
</div>
</Card>
<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.email }} </div>
<div> {{ item.name }} </div>
</div>
<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="{
'outline outline-[2px] outline-yellow-400': isAppsumoType(project.premium_type)
}" 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">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="text-ellipsis line-clamp-1"> {{ project.name }} </div>
<div class="flex gap-2">
<div> Visits: </div>
<div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
<div> Events: </div>
<div> {{ formatNumberK(project.counts?.events || 0) }} </div>
<div> Sessions: </div>
<div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
</div>
<div class="flex gap-4 items-center mt-4">
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
Payment details
</LyxUiButton>
<LyxUiButton type="danger" @click="resetCount(project._id)">
Refresh counts
</LyxUiButton>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
import type { AdminProjectsList } from '~/server/api/admin/projects';
definePageMeta({ layout: 'dashboard' });
const filterPremium = ref<boolean>(false);
const filterAppsumo = ref<boolean>(false);
const timeRange = ref<number>(9);
function setTimeRange(n: number) {
timeRange.value = n;
}
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 { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
function onHideClicked() {
isAdminHidden.value = true;
}
function isAppsumoType(type: number) {
return type > 6000 && type < 6004
}
const projectsAggregated = computed(() => {
let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : [];
let shownPool: AdminProjectsList[] = [];
for (const element of pool) {
shownPool.push({ ...element, projects: [...element.projects] });
if (filterAppsumo.value === true) {
shownPool.forEach(e => {
e.projects = e.projects.filter(project => {
return isAppsumoType(project.premium_type)
})
})
shownPool = shownPool.filter(e => {
return e.projects.length > 0;
})
} else if (filterPremium.value === true) {
shownPool.forEach(e => {
e.projects = e.projects.filter(project => {
return project.premium === true;
})
})
shownPool = shownPool.filter(e => {
return e.projects.length > 0;
})
} else {
console.log('NO DATA')
}
}
return shownPool.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;
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 projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
}, 0) || 0;
});
const totalEvents = computed(() => {
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) {
details.value = await $fetch(`/api/admin/details?project_id=${project_id}`, signHeaders());
showDetails.value = true;
}
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>
<template>
<div class="bg-bg overflow-y-auto w-full h-dvh p-6 gap-6 flex flex-col">
<div v-if="showDetails"
class="w-full md:px-40 h-full fixed top-0 left-0 bg-black/90 backdrop-blur-[2px] z-[20] overflow-y-auto">
<div class="cursor-pointer bg-red-400 w-fit px-10 py-2 rounded-lg font-semibold my-3"
@click="showDetails = false">
Close
</div>
<div class="whitespace-pre-wrap poppins">
{{ JSON.stringify(details, null, 3) }}
</div>
</div>
<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 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-2 flex gap-10 items-center justify-center">
<UCheckbox v-model="filterPremium" label="Filter Premium"></UCheckbox>
<UCheckbox v-model="filterAppsumo" label="Filter Appsumo"></UCheckbox>
</Card>
<Card class="p-4">
<div class="grid grid-cols-2 gap-1">
<div>
Users: {{ counts?.users }}
</div>
<div>
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
</div>
<div>
Total visits: {{ formatNumberK(totalVisits) }}
</div>
<div>
Active: {{ activeProjects }} |
Dead: {{ (counts?.projects || 0) - activeProjects }}
</div>
<div>
Total events: {{ formatNumberK(totalEvents) }}
</div>
</div>
</Card>
<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.email }} </div>
<div> {{ item.name }} </div>
</div>
<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="{
'outline outline-[2px] outline-yellow-400': isAppsumoType(project.premium_type)
}" 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">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="text-ellipsis line-clamp-1"> {{ project.name }} </div>
<div class="flex gap-2">
<div> Visits: </div>
<div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
<div> Events: </div>
<div> {{ formatNumberK(project.counts?.events || 0) }} </div>
<div> Sessions: </div>
<div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
</div>
<div class="flex gap-4 items-center mt-4">
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
Payment details
</LyxUiButton>
<LyxUiButton type="danger" @click="resetCount(project._id)">
Refresh counts
</LyxUiButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -1,24 +1,34 @@
import { UserModel } from "@schema/UserSchema"; import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
import { TProjectLimit } from "~/shared/schema/project/ProjectsLimits";
export type AdminProjectsList = { type ExtendedProject = {
_id: string, limits: TProjectLimit[],
name: string, counts: [{
given_name: string, events: number,
created_at: string, visits: number,
email: string, sessions: number
projects: { }],
_id: string, visits: number,
owner: string, events: number,
name: string, sessions: number,
premium: boolean, limit_visits: number,
premium_type: number, limit_events: number,
customer_id: string, limit_max: number,
subscription_id: string, limit_ai_messages: number,
premium_expire_at: string, limit_ai_max: number,
created_at: string, limit_total: number
__v: number, }
counts: { _id: string, project_id: string, events: number, visits: number, sessions: number, updated_at?: string }
}[], export type TAdminProject = TProject & ExtendedProject;
function addFieldsFromArray(data: { fieldName: string, projectedName: string, arrayName: string }[]) {
const content: Record<string, any> = {};
data.forEach(e => {
content[e.projectedName] = {
"$ifNull": [{ "$getField": { "field": e.fieldName, "input": { "$arrayElemAt": [`$${e.arrayName}`, 0] } } }, 0]
}
});
return content;
} }
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
@@ -27,58 +37,61 @@ export default defineEventHandler(async event => {
if (!userData?.logged) return; if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return; if (!userData.user.roles.includes('ADMIN')) return;
const data: AdminProjectsList[] = await UserModel.aggregate([ const { page, limit, sortQuery } = getQuery(event);
const pageNumber = parseInt(page as string);
const limitNumber = parseInt(limit as string);
const projects = await ProjectModel.aggregate([
{ {
$lookup: { $lookup: {
from: "projects", from: "project_limits",
localField: "_id", localField: "_id",
foreignField: "owner", foreignField: "project_id",
as: "projects" as: "limits"
}
},
{
$unwind: {
path: "$projects",
preserveNullAndEmptyArrays: true
} }
}, },
{ {
$lookup: { $lookup: {
from: "project_counts", from: "project_counts",
localField: "projects._id", localField: "_id",
foreignField: "project_id", foreignField: "project_id",
as: "projects.counts" as: "counts"
} }
}, },
{
$addFields: addFieldsFromArray([
{ arrayName: 'counts', fieldName: 'visits', projectedName: 'visits' },
{ arrayName: 'counts', fieldName: 'events', projectedName: 'events' },
{ arrayName: 'counts', fieldName: 'session', projectedName: 'session' },
]),
},
{
$addFields: addFieldsFromArray([
{ arrayName: 'limits', fieldName: 'visits', projectedName: 'limit_visits' },
{ arrayName: 'limits', fieldName: 'events', projectedName: 'limit_events' },
{ arrayName: 'limits', fieldName: 'limit', projectedName: 'limit_max' },
{ arrayName: 'limits', fieldName: 'ai_messages', projectedName: 'limit_ai_messages' },
{ arrayName: 'limits', fieldName: 'ai_limit', projectedName: 'limit_ai_max' },
]),
},
{ {
$addFields: { $addFields: {
"projects.counts": { limit_total: {
$arrayElemAt: ["$projects.counts", 0] $add: [
} { $ifNull: ["$limit_visits", 0] },
{ $ifNull: ["$limit_events", 0] }
]
},
} }
}, },
{ { $unset: 'counts' },
$group: { { $unset: 'limits' },
_id: "$_id", { $sort: JSON.parse(sortQuery as string) },
name: { { $skip: pageNumber * limitNumber },
$first: "$name" { $limit: limitNumber }
},
given_name: {
$first: "$given_name"
},
created_at: {
$first: "$created_at"
},
email: {
$first: "$email"
},
projects: {
$push: "$projects"
}
}
}
]); ]);
return data; return projects as TAdminProject[];
}); });

View File

@@ -0,0 +1,97 @@
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
import { TUser, UserModel } from "@schema/UserSchema";
import { TProjectLimit } from "~/shared/schema/project/ProjectsLimits";
export type TAdminUserProjectInfo = TUser & {
projects: (TProject & {
limits: TProjectLimit[],
visits: number,
events: number,
sessions: number
})[],
}
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return;
const { page, limit, sortQuery } = getQuery(event);
const pageNumber = parseInt(page as string);
const limitNumber = parseInt(limit as string);
const users = await UserModel.aggregate([
{
$lookup: {
from: "projects",
localField: "_id",
foreignField: "owner",
pipeline: [
{
$lookup: {
from: "project_limits",
localField: "_id",
foreignField: "project_id",
as: "limits"
}
},
{
$lookup: {
from: "visits",
localField: "_id",
foreignField: "project_id",
pipeline: [
{
$count: "total_visits"
}
],
as: "visit_data"
}
},
{
$lookup: {
from: "events",
localField: "_id",
foreignField: "project_id",
pipeline: [
{
$count: "total_events"
}
],
as: "event_data"
}
},
{
$lookup: {
from: "sessions",
localField: "_id",
foreignField: "project_id",
pipeline: [
{
$count: "total_sessions"
}
],
as: "session_data"
}
},
{ $addFields: { visits: { $ifNull: [{ $arrayElemAt: ["$visit_data.total_visits", 0] }, 0] } } },
{ $addFields: { events: { $ifNull: [{ $arrayElemAt: ["$event_data.total_events", 0] }, 0] } } },
{ $addFields: { sessions: { $ifNull: [{ $arrayElemAt: ["$session_data.total_sessions", 0] }, 0] } }, },
{ $unset: "visit_data" },
{ $unset: "event_data" },
{ $unset: "session_data" }
],
as: "projects"
},
},
{ $sort: JSON.parse(sortQuery as string) },
{ $skip: pageNumber * limitNumber },
{ $limit: limitNumber }
]);
return users as TAdminUserProjectInfo[];
});

View File

@@ -5,6 +5,7 @@ import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
import { Model, Types } from "mongoose"; import { Model, Types } from "mongoose";
import { TeamMemberModel } from "@schema/TeamMemberSchema"; import { TeamMemberModel } from "@schema/TeamMemberSchema";
import { Slice } from "@services/DateService"; import { Slice } from "@services/DateService";
import { ADMIN_EMAILS } from "~/shared/data/ADMINS";
export function getRequestUser(event: H3Event<EventHandlerRequest>) { export function getRequestUser(event: H3Event<EventHandlerRequest>) {
if (!event.context.auth) return; if (!event.context.auth) return;
@@ -40,6 +41,10 @@ async function hasAccessToProject(user_id: string, project: TProject) {
if (owner === user_id) return [true, 'OWNER']; if (owner === user_id) return [true, 'OWNER'];
const isGuest = await TeamMemberModel.exists({ project_id, user_id }); const isGuest = await TeamMemberModel.exists({ project_id, user_id });
if (isGuest) return [true, 'GUEST']; if (isGuest) return [true, 'GUEST'];
//TODO: Create table with admins
if (user_id === '66520c90f381ec1e9284938b') return [true, 'ADMIN'];
return [false, 'NONE']; return [false, 'NONE'];
} }

View File

@@ -1,4 +1,10 @@
{ {
// https://nuxt.com/docs/guide/concepts/typescript // https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json" "extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"skipLibCheck": true,
},
"exclude": [
"node_modules"
]
} }

View File

@@ -26,7 +26,8 @@ export type PREMIUM_DATA = {
PRICE: string, PRICE: string,
PRICE_TEST: string, PRICE_TEST: string,
ID: number, ID: number,
COST: number COST: number,
TAG: PREMIUM_TAG
} }
export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = { export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
@@ -36,7 +37,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 10, AI_MESSAGE_LIMIT: 10,
PRICE: 'price_1POKCMB2lPUiVs9VLe3QjIHl', PRICE: 'price_1POKCMB2lPUiVs9VLe3QjIHl',
PRICE_TEST: 'price_1PNbHYB2lPUiVs9VZP32xglF', PRICE_TEST: 'price_1PNbHYB2lPUiVs9VZP32xglF',
COST: 0 COST: 0,
TAG: 'FREE'
}, },
PLAN_1: { PLAN_1: {
ID: 1, ID: 1,
@@ -44,7 +46,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 100, AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1POKCOB2lPUiVs9VC13s2rQw', PRICE: 'price_1POKCOB2lPUiVs9VC13s2rQw',
PRICE_TEST: 'price_1PNZjVB2lPUiVs9VrsTbJL04', PRICE_TEST: 'price_1PNZjVB2lPUiVs9VrsTbJL04',
COST: 0 COST: 0,
TAG: 'PLAN_1'
}, },
PLAN_2: { PLAN_2: {
ID: 2, ID: 2,
@@ -52,7 +55,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 5_000, AI_MESSAGE_LIMIT: 5_000,
PRICE: 'price_1POKCKB2lPUiVs9Vol8XOmhW', PRICE: 'price_1POKCKB2lPUiVs9Vol8XOmhW',
PRICE_TEST: 'price_1POK34B2lPUiVs9VIROb0IIV', PRICE_TEST: 'price_1POK34B2lPUiVs9VIROb0IIV',
COST: 0 COST: 0,
TAG: 'PLAN_2'
}, },
CUSTOM_1: { CUSTOM_1: {
ID: 1001, ID: 1001,
@@ -60,7 +64,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 100_000, AI_MESSAGE_LIMIT: 100_000,
PRICE: 'price_1POKZyB2lPUiVs9VMAY6jXTV', PRICE: 'price_1POKZyB2lPUiVs9VMAY6jXTV',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'CUSTOM_1'
}, },
INCUBATION: { INCUBATION: {
ID: 101, ID: 101,
@@ -68,7 +73,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 30, AI_MESSAGE_LIMIT: 30,
PRICE: 'price_1PdsyzB2lPUiVs9V4J246Jw0', PRICE: 'price_1PdsyzB2lPUiVs9V4J246Jw0',
PRICE_TEST: '', PRICE_TEST: '',
COST: 499 COST: 499,
TAG: 'INCUBATION'
}, },
ACCELERATION: { ACCELERATION: {
ID: 102, ID: 102,
@@ -76,7 +82,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 100, AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1Pdt5bB2lPUiVs9VhkuCouEt', PRICE: 'price_1Pdt5bB2lPUiVs9VhkuCouEt',
PRICE_TEST: '', PRICE_TEST: '',
COST: 999 COST: 999,
TAG: 'ACCELERATION'
}, },
GROWTH: { GROWTH: {
ID: 103, ID: 103,
@@ -84,7 +91,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PdszrB2lPUiVs9VIdkT3thv', PRICE: 'price_1PdszrB2lPUiVs9VIdkT3thv',
PRICE_TEST: '', PRICE_TEST: '',
COST: 2999 COST: 2999,
TAG: 'GROWTH'
}, },
EXPANSION: { EXPANSION: {
ID: 104, ID: 104,
@@ -92,7 +100,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 5_000, AI_MESSAGE_LIMIT: 5_000,
PRICE: 'price_1Pdt0xB2lPUiVs9V0Rdt80Fe', PRICE: 'price_1Pdt0xB2lPUiVs9V0Rdt80Fe',
PRICE_TEST: '', PRICE_TEST: '',
COST: 5999 COST: 5999,
TAG: 'EXPANSION'
}, },
SCALING: { SCALING: {
ID: 105, ID: 105,
@@ -100,7 +109,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 10_000, AI_MESSAGE_LIMIT: 10_000,
PRICE: 'price_1Pdt1UB2lPUiVs9VUmxntSwZ', PRICE: 'price_1Pdt1UB2lPUiVs9VUmxntSwZ',
PRICE_TEST: '', PRICE_TEST: '',
COST: 9999 COST: 9999,
TAG: 'SCALING'
}, },
UNICORN: { UNICORN: {
ID: 106, ID: 106,
@@ -108,7 +118,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 20_000, AI_MESSAGE_LIMIT: 20_000,
PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G', PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G',
PRICE_TEST: '', PRICE_TEST: '',
COST: 14999 COST: 14999,
TAG: 'UNICORN'
}, },
LIFETIME_GROWTH_ONETIME: { LIFETIME_GROWTH_ONETIME: {
ID: 2001, ID: 2001,
@@ -116,7 +127,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PvewGB2lPUiVs9VLheJC8s1', PRICE: 'price_1PvewGB2lPUiVs9VLheJC8s1',
PRICE_TEST: 'price_1Pvf7LB2lPUiVs9VMFNyzpim', PRICE_TEST: 'price_1Pvf7LB2lPUiVs9VMFNyzpim',
COST: 239900 COST: 239900,
TAG: 'LIFETIME_GROWTH_ONETIME'
}, },
GROWTH_DUMMY: { GROWTH_DUMMY: {
ID: 5001, ID: 5001,
@@ -124,7 +136,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PvgoRB2lPUiVs9VC51YBT7J', PRICE: 'price_1PvgoRB2lPUiVs9VC51YBT7J',
PRICE_TEST: 'price_1PvgRTB2lPUiVs9V3kFSNC3G', PRICE_TEST: 'price_1PvgRTB2lPUiVs9V3kFSNC3G',
COST: 0 COST: 0,
TAG: 'GROWTH_DUMMY'
}, },
APPSUMO_INCUBATION: { APPSUMO_INCUBATION: {
ID: 6001, ID: 6001,
@@ -132,7 +145,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 30, AI_MESSAGE_LIMIT: 30,
PRICE: 'price_1QIXwbB2lPUiVs9VKSsoksaU', PRICE: 'price_1QIXwbB2lPUiVs9VKSsoksaU',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'APPSUMO_INCUBATION'
}, },
APPSUMO_ACCELERATION: { APPSUMO_ACCELERATION: {
ID: 6002, ID: 6002,
@@ -140,7 +154,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 100, AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1QIXxRB2lPUiVs9VrjaVRoOl', PRICE: 'price_1QIXxRB2lPUiVs9VrjaVRoOl',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'APPSUMO_ACCELERATION'
}, },
APPSUMO_GROWTH: { APPSUMO_GROWTH: {
ID: 6003, ID: 6003,
@@ -148,7 +163,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1QIXy8B2lPUiVs9VQBOUPAoE', PRICE: 'price_1QIXy8B2lPUiVs9VQBOUPAoE',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'APPSUMO_GROWTH'
}, },
APPSUMO_UNICORN: { APPSUMO_UNICORN: {
ID: 6006, ID: 6006,
@@ -156,7 +172,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 20_000, AI_MESSAGE_LIMIT: 20_000,
PRICE: 'price_1Qls1lB2lPUiVs9VI6ej8hwE', PRICE: 'price_1Qls1lB2lPUiVs9VI6ej8hwE',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'APPSUMO_UNICORN'
} }
} }