mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 07:48:37 +01:00
Merge branch 'refactoring'
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ full_reload.sh
|
|||||||
build-all.sh
|
build-all.sh
|
||||||
tmp
|
tmp
|
||||||
ecosystem.config.js
|
ecosystem.config.js
|
||||||
|
todo
|
||||||
@@ -19,7 +19,7 @@ metricsRouter.get('/queue', async (req, res) => {
|
|||||||
|
|
||||||
metricsRouter.get('/durations', async (req, res) => {
|
metricsRouter.get('/durations', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const durations = RedisStreamService.METRICS_get()
|
const durations = await RedisStreamService.METRICS_get()
|
||||||
res.json({ durations });
|
res.json({ durations });
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
console.error(ex);
|
console.error(ex);
|
||||||
|
|||||||
@@ -1,20 +1,62 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
type CItem = { label: string, slot: string }
|
|
||||||
const props = defineProps<{ items: CItem[] }>();
|
|
||||||
|
export type CItem = { label: string, slot: string, tab?: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: CItem[],
|
||||||
|
manualScroll?: boolean,
|
||||||
|
route?: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const activeTabIndex = ref<number>(0);
|
const activeTabIndex = ref<number>(0);
|
||||||
|
|
||||||
|
|
||||||
|
function updateTab() {
|
||||||
|
const target = props.items.findIndex(e => e.tab == route.query.tab);
|
||||||
|
if (target == -1) {
|
||||||
|
activeTabIndex.value = 0;
|
||||||
|
} else {
|
||||||
|
activeTabIndex.value = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeTab(newIndex: number) {
|
||||||
|
activeTabIndex.value = newIndex;
|
||||||
|
const target = props.items[newIndex];
|
||||||
|
if (!target) return;
|
||||||
|
router.push({ query: { tab: target.tab } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
|
||||||
|
if (props.route !== true) return;
|
||||||
|
|
||||||
|
updateTab();
|
||||||
|
|
||||||
|
watch(route, () => {
|
||||||
|
updateTab();
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</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="onChangeTab(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"
|
||||||
:class="{
|
:class="{
|
||||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
|
||||||
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||||
}">
|
}">
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
@@ -24,7 +66,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>
|
||||||
|
|||||||
15
dashboard/components/LyxUi/Separator.vue
Normal file
15
dashboard/components/LyxUi/Separator.vue
Normal 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>
|
||||||
@@ -98,7 +98,8 @@ async function saveJobTitle() {
|
|||||||
const showOnboarding = computed(() => {
|
const showOnboarding = computed(() => {
|
||||||
if (route.path === '/login') return false;
|
if (route.path === '/login') return false;
|
||||||
if (route.path === '/register') return false;
|
if (route.path === '/register') return false;
|
||||||
if (needsOnboarding.value?.exist === false) return true;
|
if ((needsOnboarding.value as any)?.exist === false) return true;
|
||||||
|
if ((needsOnboarding.value as any)?.exists === false) return true;
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
72
dashboard/components/admin/Backend.vue
Normal file
72
dashboard/components/admin/Backend.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
|
||||||
|
|
||||||
|
const avgDuration = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return -1;
|
||||||
|
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = new Array(650).fill('-');
|
||||||
|
|
||||||
|
const durationsDatasets = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return [];
|
||||||
|
|
||||||
|
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
|
||||||
|
|
||||||
|
const datasets = [];
|
||||||
|
|
||||||
|
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueConsumers.length; i++) {
|
||||||
|
|
||||||
|
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
points: consumerDurations.map((e: any) => {
|
||||||
|
return 1000 / parseInt(e[1])
|
||||||
|
}),
|
||||||
|
color: colors[i],
|
||||||
|
chartType: 'line',
|
||||||
|
name: uniqueConsumers[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return datasets;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex justify-center w-full">
|
||||||
|
|
||||||
|
<div v-if="backendData" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
|
||||||
|
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
|
||||||
|
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
|
||||||
|
</AdminBackendLineChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @click="refreshBackend()"> Refresh </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="backendPending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
31
dashboard/components/admin/Feedbacks.vue
Normal file
31
dashboard/components/admin/Feedbacks.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: feedbacks, pending: pendingFeedbacks } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
|
||||||
|
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
|
||||||
|
v-for="feedback of feedbacks">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
|
||||||
|
<div class="text-lyx-text-dark"> {{ feedback.project[0]?.name || 'DELETED PROJECT' }} </div>
|
||||||
|
</div>
|
||||||
|
{{ feedback.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingFeedbacks"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
271
dashboard/components/admin/MiniChart.vue
Normal file
271
dashboard/components/admin/MiniChart.vue
Normal 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>
|
||||||
45
dashboard/components/admin/Onboardings.vue
Normal file
45
dashboard/components/admin/Onboardings.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex gap-40 px-20">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Anaytics </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Jobs </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex flex-col gap-8">
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingOnboardings"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
184
dashboard/components/admin/Overview.vue
Normal file
184
dashboard/components/admin/Overview.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
|
||||||
|
const page = ref<number>(1);
|
||||||
|
|
||||||
|
const ordersList = [
|
||||||
|
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||||
|
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||||
|
|
||||||
|
{ label: 'active -->', id: '{ "last_log_at": 1 }' },
|
||||||
|
{ label: 'active <--', id: '{ "last_log_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 limitList = [
|
||||||
|
{ label: '10', id: 10 },
|
||||||
|
{ label: '20', id: 20 },
|
||||||
|
{ label: '50', id: 50 },
|
||||||
|
{ label: '100', id: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const limit = ref<number>(20);
|
||||||
|
|
||||||
|
const filterList = [
|
||||||
|
{ label: 'ALL', id: '{}' },
|
||||||
|
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
|
||||||
|
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
|
||||||
|
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
function isRangeSelected(duration: Duration) {
|
||||||
|
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRange(duration: Duration) {
|
||||||
|
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = [
|
||||||
|
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||||
|
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||||
|
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||||
|
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||||
|
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||||
|
{ label: 'Last year', duration: { years: 1 } }
|
||||||
|
]
|
||||||
|
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
for (const key in PREMIUM_PLAN) {
|
||||||
|
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filter = ref<string>('{}');
|
||||||
|
|
||||||
|
const { data: projectsInfo, pending: pendingProjects } = await useFetch<{ count: number, projects: TAdminProject[] }>(
|
||||||
|
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||||
|
signHeaders()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { uiMenu } = useSelectMenuStyle();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-8">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Limit:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="limit">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Filter:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="filter">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 justify-center px-10 w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center shrink-0">
|
||||||
|
<div>Page {{ page }} </div>
|
||||||
|
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
|
||||||
|
}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||||
|
<UButton class="w-full" color="primary" variant="solid">
|
||||||
|
<div class="flex items-center justify-center w-full gap-2">
|
||||||
|
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||||
|
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||||
|
</div>
|
||||||
|
</UButton>
|
||||||
|
<template #panel="{ close }">
|
||||||
|
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||||
|
<div class="hidden sm:flex flex-col py-4">
|
||||||
|
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||||
|
variant="ghost" class="rounded-none px-6"
|
||||||
|
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||||
|
truncate @click="selectRange(range.duration)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatePicker v-model="selected" @close="close" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
</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 projectsInfo?.projects" />
|
||||||
|
|
||||||
|
<div v-if="pendingProjects"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
104
dashboard/components/admin/Users.vue
Normal file
104
dashboard/components/admin/Users.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||||
|
import type { TAdminUser } from '~/server/api/admin/users';
|
||||||
|
|
||||||
|
|
||||||
|
const filterText = ref<string>('');
|
||||||
|
|
||||||
|
watch(filterText,()=>{
|
||||||
|
page.value = 1;
|
||||||
|
})
|
||||||
|
|
||||||
|
const filter = computed(() => {
|
||||||
|
return JSON.stringify({
|
||||||
|
$or: [
|
||||||
|
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
|
||||||
|
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const page = ref<number>(1);
|
||||||
|
|
||||||
|
const ordersList = [
|
||||||
|
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||||
|
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const order = ref<string>('{ "created_at": -1 }');
|
||||||
|
|
||||||
|
|
||||||
|
const limitList = [
|
||||||
|
{ label: '10', id: 10 },
|
||||||
|
{ label: '20', id: 20 },
|
||||||
|
{ label: '50', id: 50 },
|
||||||
|
{ label: '100', id: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const limit = ref<number>(20);
|
||||||
|
|
||||||
|
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
|
||||||
|
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.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>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Limit:</div>
|
||||||
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||||
|
value-attribute="id" option-attribute="label" v-model="limit">
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div>Page {{ page }} </div>
|
||||||
|
<div>
|
||||||
|
{{ Math.min(limit, usersInfo?.count || 0) }}
|
||||||
|
of
|
||||||
|
{{ usersInfo?.count || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
|
||||||
|
</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]">
|
||||||
|
|
||||||
|
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
|
||||||
|
v-for="user of usersInfo?.users" />
|
||||||
|
|
||||||
|
<div v-if="pendingUsers"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
132
dashboard/components/admin/backend/LineChart.vue
Normal file
132
dashboard/components/admin/backend/LineChart.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as datefns from 'date-fns';
|
||||||
|
|
||||||
|
const errored = ref<boolean>(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[],
|
||||||
|
title: string,
|
||||||
|
datasets: {
|
||||||
|
points: number[],
|
||||||
|
color: string,
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleFont: { size: 16, weight: 'bold' },
|
||||||
|
bodyFont: { size: 14 },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 4,
|
||||||
|
boxPadding: 10,
|
||||||
|
caretPadding: 20,
|
||||||
|
yAlign: 'bottom',
|
||||||
|
xAlign: 'center',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line'>>({
|
||||||
|
labels: props.labels.map(e => {
|
||||||
|
try {
|
||||||
|
return datefns.format(new Date(e), 'dd/MM');
|
||||||
|
} catch (ex) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
datasets: props.datasets.map(e => ({
|
||||||
|
data: e.points,
|
||||||
|
label: e.name,
|
||||||
|
backgroundColor: [e.color + '00'],
|
||||||
|
borderColor: e.color,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
type: 'line'
|
||||||
|
} as any))
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// chartData.value.datasets.forEach(dataset => {
|
||||||
|
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
|
||||||
|
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
|
||||||
|
// } else {
|
||||||
|
// dataset.backgroundColor = [createGradient('#3d59a4')]
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
} catch (ex) {
|
||||||
|
errored.value = true;
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="errored"> ERROR CREATING CHART </div>
|
||||||
|
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
48
dashboard/components/admin/dialog/ProjectDetails.vue
Normal file
48
dashboard/components/admin/dialog/ProjectDetails.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
|
||||||
|
const props = defineProps<{ pid: string }>();
|
||||||
|
|
||||||
|
const { data: projectInfo, refresh, pending } = useFetch<{ domains: { _id: string }[], project: TAdminProject }>(
|
||||||
|
() => `/api/admin/project_info?pid=${props.pid}`,
|
||||||
|
signHeaders(),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="mt-6 h-full flex flex-col gap-10 w-full" v-if="!pending">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LyxUiButton type="secondary" @click="refresh"> Refresh </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-10" v-if="projectInfo">
|
||||||
|
|
||||||
|
<AdminOverviewProjectCard :project="projectInfo.project" class="w-[30rem] shrink-0" />
|
||||||
|
|
||||||
|
<AdminMiniChart class="max-w-[40rem]" :pid="pid"></AdminMiniChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectInfo" class="flex flex-col">
|
||||||
|
|
||||||
|
<div>Domains:</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-8 mt-8">
|
||||||
|
|
||||||
|
<div v-for="domain of projectInfo.domains">
|
||||||
|
{{ domain._id }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { type ChartData, type ChartOptions } from 'chart.js';
|
||||||
|
import { PieChart, usePieChart } from 'vue-chart-3';
|
||||||
|
|
||||||
|
const props = defineProps<{ data: { _id: string, count: number }[], title:string }>();
|
||||||
|
|
||||||
|
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title,
|
||||||
|
color: '#EEECF6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
|
||||||
|
labels: props.data.map(e => e._id),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.data.map(e => e.count),
|
||||||
|
backgroundColor: [
|
||||||
|
"#295270",
|
||||||
|
"#304F71",
|
||||||
|
"#374C72",
|
||||||
|
"#3E4A73",
|
||||||
|
"#444773",
|
||||||
|
"#4B4474",
|
||||||
|
"#524175",
|
||||||
|
],
|
||||||
|
borderColor: '#222222'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="graph">
|
||||||
|
<PieChart v-bind="pieChartProps">
|
||||||
|
</PieChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
134
dashboard/components/admin/overview/ProjectCard.vue
Normal file
134
dashboard/components/admin/overview/ProjectCard.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||||
|
|
||||||
|
|
||||||
|
import { AdminDialogProjectDetails } from '#components';
|
||||||
|
|
||||||
|
const { openDialogEx } = useCustomDialog();
|
||||||
|
|
||||||
|
function showProjectDetails(pid: string) {
|
||||||
|
openDialogEx(AdminDialogProjectDetails, {
|
||||||
|
params: { pid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ project: TAdminProject }>();
|
||||||
|
|
||||||
|
|
||||||
|
const logBg = computed(() => {
|
||||||
|
|
||||||
|
const day = 1000 * 60 * 60 * 24;
|
||||||
|
const week = 1000 * 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
const lastLoggedAtDate = new Date(props.project.last_log_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'
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const dateDiffDays = computed(() => {
|
||||||
|
const res = (Date.now() - new Date(props.project.last_log_at || 0).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
if (res > -1 && res < 1) return 0;
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 relative h-fit">
|
||||||
|
|
||||||
|
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||||
|
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||||
|
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? '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 @click="showProjectDetails(project._id.toString())" class="font-medium hover:text-lyx-primary cursor-pointer">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center mt-2">
|
||||||
|
<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>
|
||||||
135
dashboard/components/admin/users/UserCard.vue
Normal file
135
dashboard/components/admin/users/UserCard.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||||
|
import type { TAdminUser } from '~/server/api/admin/users';
|
||||||
|
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||||
|
|
||||||
|
import { AdminDialogProjectDetails } from '#components';
|
||||||
|
|
||||||
|
const { openDialogEx } = useCustomDialog();
|
||||||
|
|
||||||
|
function showProjectDetails(pid: string) {
|
||||||
|
openDialogEx(AdminDialogProjectDetails, {
|
||||||
|
params: { pid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ user: TAdminUser }>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative max-h-[15rem]">
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ user.name ?? user.given_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-5 justify-center">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ user.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyxUiSeparator class="my-2" />
|
||||||
|
|
||||||
|
<div class="flex flex-col text-[.9rem]">
|
||||||
|
<div class="flex gap-2" v-for="project of user.projects">
|
||||||
|
<div class="text-lyx-text-darker">
|
||||||
|
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||||
|
</div>
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||||
|
</div>
|
||||||
|
</UTooltip>
|
||||||
|
|
||||||
|
<div @click="showProjectDetails(project._id.toString())"
|
||||||
|
class="ml-1 hover:text-lyx-primary cursor-pointer">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative">
|
||||||
|
|
||||||
|
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||||
|
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||||
|
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||||
|
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||||
|
<div class="font-medium text-lyx-text-dark">
|
||||||
|
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? '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-2">
|
||||||
|
<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>
|
||||||
@@ -52,7 +52,7 @@ const { showDrawer } = useDrawer();
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
||||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
<div v-if="props.slow"> Can be very slow on large timeframes </div>
|
<!-- <div v-if="props.slow"> Can be very slow on large timeframes </div> -->
|
||||||
</div>
|
</div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
|
|
||||||
|
|||||||
@@ -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) || '...')"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const isDark = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {safeSnapshotDates} = useSnapshot();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,13 +28,23 @@ const isDark = computed({
|
|||||||
<SelectorDomainSelector></SelectorDomainSelector>
|
<SelectorDomainSelector></SelectorDomainSelector>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden lg:flex pl-[12rem] items-center popping text-[.9rem] dark:text-lyx-text-dark">
|
||||||
|
Timeframe:
|
||||||
|
{{new Date(safeSnapshotDates.from).toLocaleDateString()}}
|
||||||
|
to
|
||||||
|
{{new Date(safeSnapshotDates.to).toLocaleDateString()}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="grow"></div>
|
<div class="grow"></div>
|
||||||
<div class="flex items-center gap-6 mr-10">
|
<div class="flex items-center gap-6 mr-10">
|
||||||
|
|
||||||
<div v-if="!selfhosted" @click="modal.open(DialogFeedback, {});"
|
<div v-if="!selfhosted" @click="modal.open(DialogFeedback, {});"
|
||||||
class="flex gap-2 items-center cursor-pointer">
|
class="flex gap-2 items-center cursor-pointer outline-[1px] outline-lyx-widget-lighter p-1 px-3 rounded-md outline">
|
||||||
<i class="far fa-message"></i>
|
<i class="far fa-message"></i>
|
||||||
Feedback
|
Feedback
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div @click="modal.open(DialogHelp, {});" class="cursor-pointer"> Help </div>
|
<div @click="modal.open(DialogHelp, {});" class="cursor-pointer"> Help </div>
|
||||||
<NuxtLink to="https://docs.litlyx.com" target="_blank" class="cursor-pointer">
|
<NuxtLink to="https://docs.litlyx.com" target="_blank" class="cursor-pointer">
|
||||||
Docs
|
Docs
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ function onChange(e: string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
<div @click="refreshDomains" v-if="!refreshingDomains"
|
|
||||||
|
<UTooltip text="Manage domains">
|
||||||
|
<NuxtLink to="/settings?tab=domains"
|
||||||
class="flex items-center hover:rotate-[60deg] transition-all duration-200 ease-in-out cursor-pointer">
|
class="flex items-center hover:rotate-[60deg] transition-all duration-200 ease-in-out cursor-pointer">
|
||||||
<i class="far fa-refresh"></i>
|
<i class="far fa-gear"></i>
|
||||||
</div>
|
</NuxtLink>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,7 +16,7 @@ function isProjectMine(owner?: string) {
|
|||||||
|
|
||||||
function onChange(e: TProject) {
|
function onChange(e: TProject) {
|
||||||
actions.setActiveProject(e._id.toString());
|
actions.setActiveProject(e._id.toString());
|
||||||
setActiveDomain('ALL DOMAINS');
|
setActiveDomain('All domains');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'],
|
|||||||
project_id,
|
project_id,
|
||||||
_id: '___allTime' as any,
|
_id: '___allTime' as any,
|
||||||
name: 'All Time',
|
name: 'All Time',
|
||||||
from: fns.addMinutes(fns.startOfMonth(new Date(project_created_at.toString())), 0),
|
from: fns.addMinutes(fns.startOfMonth(new Date(project_created_at.toString())), -new Date().getTimezoneOffset()),
|
||||||
to: new Date(Date.now()),
|
to: fns.addMilliseconds(fns.endOfDay(Date.now()), 1),
|
||||||
color: '#9362FF',
|
color: '#9362FF',
|
||||||
default: true
|
default: true
|
||||||
}
|
}
|
||||||
|
|||||||
13
dashboard/composables/ui/useSelectMenuStyle.ts
Normal file
13
dashboard/composables/ui/useSelectMenuStyle.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ const refreshingDomains = computed(() => domainsRequest.pending.value);
|
|||||||
const domainList = computed(() => {
|
const domainList = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
_id: 'ALL DOMAINS', visits: domainsRequest.data.value?.reduce((a, e) => a + e.visits, 0)
|
_id: 'All domains', visits: domainsRequest.data.value?.reduce((a, e) => a + e.visits, 0)
|
||||||
},
|
},
|
||||||
...(domainsRequest.data.value?.sort((a, b) => b.visits - a.visits) || [])
|
...(domainsRequest.data.value?.sort((a, b) => b.visits - a.visits) || [])
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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,37 @@ function getLogBg(last_logged_at?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const tabs: CItem[] = [
|
||||||
|
{ label: 'Overview', slot: 'overview' },
|
||||||
|
{ label: 'Users', slot: 'users' },
|
||||||
|
{ 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
|
<template #users>
|
||||||
</div>
|
<AdminUsers></AdminUsers>
|
||||||
<div class="whitespace-pre-wrap poppins">
|
</template>
|
||||||
{{ JSON.stringify(details, null, 3) }}
|
<template #feedbacks>
|
||||||
</div>
|
<AdminFeedbacks></AdminFeedbacks>
|
||||||
</div>
|
</template>
|
||||||
|
<template #onboarding>
|
||||||
<div @click="onHideClicked()" v-if="!isAdminHidden"
|
<AdminOnboardings></AdminOnboardings>
|
||||||
class="bg-menu hover:bg-menu/70 cursor-pointer flex gap-2 rounded-lg w-fit px-6 py-4 text-text-sub">
|
</template>
|
||||||
<div class="text-text-sub/90"> <i class="far fa-eye"></i> </div>
|
<template #backend>
|
||||||
<div> Hide from the bar </div>
|
<AdminBackend></AdminBackend>
|
||||||
</div>
|
</template>
|
||||||
|
</CustomTab>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
287
dashboard/pages/admin/old.vue
Normal file
287
dashboard/pages/admin/old.vue
Normal 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>
|
||||||
@@ -5,12 +5,12 @@ definePageMeta({ layout: 'dashboard' });
|
|||||||
const selfhosted = useSelfhosted();
|
const selfhosted = useSelfhosted();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: 'General', slot: 'general' },
|
{ label: 'General', slot: 'general', tab: 'general' },
|
||||||
{ label: 'Data', slot: 'data' },
|
{ label: 'Domains', slot: 'domains', tab: 'domains' },
|
||||||
{ label: 'Members', slot: 'members' },
|
{ label: 'Members', slot: 'members', tab: 'members' },
|
||||||
{ label: 'Billing', slot: 'billing' },
|
{ label: 'Billing', slot: 'billing', tab: 'billing' },
|
||||||
{ label: 'Codes', slot: 'codes' },
|
{ label: 'Codes', slot: 'codes', tab: 'codes' },
|
||||||
{ label: 'Account', slot: 'account' }
|
{ label: 'Account', slot: 'account', tab: 'account' }
|
||||||
]
|
]
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -20,11 +20,11 @@ const items = [
|
|||||||
|
|
||||||
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Settings </div>
|
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Settings </div>
|
||||||
|
|
||||||
<CustomTab :items="items" class="mt-8">
|
<CustomTab :items="items" :route="true" class="mt-8">
|
||||||
<template #general>
|
<template #general>
|
||||||
<SettingsGeneral :key="refreshKey"></SettingsGeneral>
|
<SettingsGeneral :key="refreshKey"></SettingsGeneral>
|
||||||
</template>
|
</template>
|
||||||
<template #data>
|
<template #domains>
|
||||||
<SettingsData :key="refreshKey"></SettingsData>
|
<SettingsData :key="refreshKey"></SettingsData>
|
||||||
</template>
|
</template>
|
||||||
<template #members>
|
<template #members>
|
||||||
|
|||||||
18
dashboard/server/api/admin/backend.ts
Normal file
18
dashboard/server/api/admin/backend.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
|
||||||
|
const queueRes = await fetch("http://94.130.182.52:3031/metrics/queue");
|
||||||
|
const queue = await queueRes.json();
|
||||||
|
const durationsRes = await fetch("http://94.130.182.52:3031/metrics/durations");
|
||||||
|
const durations = await durationsRes.json();
|
||||||
|
|
||||||
|
return { queue, durations: durations }
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
31
dashboard/server/api/admin/feedbacks.ts
Normal file
31
dashboard/server/api/admin/feedbacks.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import { FeedbackModel } from '@schema/FeedbackSchema';
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
const feedbacks = await FeedbackModel.aggregate([
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: 'user_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'user'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'projects',
|
||||||
|
localField: 'project_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'project'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
return feedbacks;
|
||||||
|
|
||||||
|
});
|
||||||
30
dashboard/server/api/admin/onboardings.ts
Normal file
30
dashboard/server/api/admin/onboardings.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
import { OnboardingModel } from '~/shared/schema/OnboardingSchema';
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
const analytics = await OnboardingModel.aggregate([
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$analytics',
|
||||||
|
count: { $sum: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const jobs = await OnboardingModel.aggregate([
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$job',
|
||||||
|
count: { $sum: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
return { analytics, jobs };
|
||||||
|
|
||||||
|
});
|
||||||
89
dashboard/server/api/admin/project_info.ts
Normal file
89
dashboard/server/api/admin/project_info.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||||
|
import { TProjectLimit } from "~/shared/schema/project/ProjectsLimits";
|
||||||
|
import { TAdminProject } from "./projects";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
const { pid } = getQuery(event);
|
||||||
|
|
||||||
|
const projects = await ProjectModel.aggregate([
|
||||||
|
{
|
||||||
|
$match: { _id: new Types.ObjectId(pid as string) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "project_limits",
|
||||||
|
localField: "_id",
|
||||||
|
foreignField: "project_id",
|
||||||
|
as: "limits"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "project_counts",
|
||||||
|
localField: "_id",
|
||||||
|
foreignField: "project_id",
|
||||||
|
as: "counts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$addFields: addFieldsFromArray([
|
||||||
|
{ arrayName: 'counts', fieldName: 'visits', projectedName: 'visits' },
|
||||||
|
{ arrayName: 'counts', fieldName: 'events', projectedName: 'events' },
|
||||||
|
{ arrayName: 'counts', fieldName: 'session', projectedName: 'session' },
|
||||||
|
{ arrayName: 'counts', fieldName: 'updated_at', projectedName: 'last_log_at' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$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: {
|
||||||
|
limit_total: {
|
||||||
|
$add: [
|
||||||
|
{ $ifNull: ["$limit_visits", 0] },
|
||||||
|
{ $ifNull: ["$limit_events", 0] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $unset: 'counts' },
|
||||||
|
{ $unset: 'limits' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const domains = await VisitModel.aggregate([
|
||||||
|
{
|
||||||
|
$match: { project_id: new Types.ObjectId(pid as string) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$website',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
return { domains, project: (projects[0] as TAdminProject) };
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,24 +1,35 @@
|
|||||||
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,
|
last_log_at: string
|
||||||
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 +38,78 @@ 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, filterQuery, filterFrom, filterTo } = getQuery(event);
|
||||||
{
|
|
||||||
$lookup: {
|
const pageNumber = parseInt(page as string);
|
||||||
from: "projects",
|
const limitNumber = parseInt(limit as string);
|
||||||
localField: "_id",
|
|
||||||
foreignField: "owner",
|
const matchQuery = {
|
||||||
as: "projects"
|
...JSON.parse(filterQuery as string),
|
||||||
|
created_at: {
|
||||||
|
$gte: new Date(filterFrom as string),
|
||||||
|
$lte: new Date(filterTo as string)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await ProjectModel.countDocuments(matchQuery);
|
||||||
|
|
||||||
|
const projects = await ProjectModel.aggregate([
|
||||||
|
{
|
||||||
|
$match: matchQuery
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$unwind: {
|
$lookup: {
|
||||||
path: "$projects",
|
from: "project_limits",
|
||||||
preserveNullAndEmptyArrays: true
|
localField: "_id",
|
||||||
|
foreignField: "project_id",
|
||||||
|
as: "limits"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$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' },
|
||||||
|
{ arrayName: 'counts', fieldName: 'updated_at', projectedName: 'last_log_at' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$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 {
|
||||||
|
count,
|
||||||
|
projects: projects as TAdminProject[]
|
||||||
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
37
dashboard/server/api/admin/users.ts
Normal file
37
dashboard/server/api/admin/users.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { TProject } from "@schema/project/ProjectSchema";
|
||||||
|
import { TUser, UserModel } from "@schema/UserSchema";
|
||||||
|
|
||||||
|
export type TAdminUser = TUser & { _id: string, projects: TProject[] };
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
const { page, limit, sortQuery, filterQuery } = getQuery(event);
|
||||||
|
|
||||||
|
const pageNumber = parseInt(page as string);
|
||||||
|
const limitNumber = parseInt(limit as string);
|
||||||
|
|
||||||
|
const count = await UserModel.countDocuments(JSON.parse(filterQuery as string));
|
||||||
|
|
||||||
|
const users = await UserModel.aggregate([
|
||||||
|
{
|
||||||
|
$match: JSON.parse(filterQuery as string)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "projects",
|
||||||
|
localField: "_id",
|
||||||
|
foreignField: "owner",
|
||||||
|
as: "projects"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $sort: JSON.parse(sortQuery as string) },
|
||||||
|
{ $skip: pageNumber * limitNumber },
|
||||||
|
{ $limit: limitNumber }
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { count, users: users as TAdminUser[] }
|
||||||
|
|
||||||
|
});
|
||||||
97
dashboard/server/api/admin/users_projects.ts
Normal file
97
dashboard/server/api/admin/users_projects.ts
Normal 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[];
|
||||||
|
|
||||||
|
});
|
||||||
@@ -2,59 +2,7 @@
|
|||||||
|
|
||||||
import { UserModel } from "@schema/UserSchema";
|
import { UserModel } from "@schema/UserSchema";
|
||||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
|
import { EventModel } from "~/shared/schema/metrics/EventSchema";
|
||||||
import { google } from 'googleapis';
|
|
||||||
|
|
||||||
const { GOOGLE_AUTH_CLIENT_SECRET, GOOGLE_AUTH_CLIENT_ID } = useRuntimeConfig()
|
|
||||||
|
|
||||||
async function exportToGoogle(data: string, user_id: string) {
|
|
||||||
|
|
||||||
const user = await UserModel.findOne({ _id: user_id }, { google_tokens: true });
|
|
||||||
|
|
||||||
const authClient = new google.auth.OAuth2({
|
|
||||||
clientId: GOOGLE_AUTH_CLIENT_ID,
|
|
||||||
clientSecret: GOOGLE_AUTH_CLIENT_SECRET
|
|
||||||
})
|
|
||||||
|
|
||||||
authClient.setCredentials({ access_token: user?.google_tokens?.access_token, refresh_token: user?.google_tokens?.refresh_token });
|
|
||||||
|
|
||||||
const sheets = google.sheets({ version: 'v4', auth: authClient });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const createSheetResponse = await sheets.spreadsheets.create({
|
|
||||||
requestBody: {
|
|
||||||
properties: {
|
|
||||||
title: 'Text export'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const spreadsheetId = createSheetResponse.data.spreadsheetId;
|
|
||||||
|
|
||||||
await sheets.spreadsheets.values.update({
|
|
||||||
spreadsheetId: spreadsheetId as string,
|
|
||||||
range: 'Sheet1!A1',
|
|
||||||
requestBody: {
|
|
||||||
values: data.split('\n').map(e => {
|
|
||||||
return e.split(',')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true }
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
|
|
||||||
console.error('Error creating Google Sheet from CSV:', error);
|
|
||||||
|
|
||||||
if (error.response && error.response.status === 401) {
|
|
||||||
return { error: 'Auth error, try to logout and login again' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: error.message.toString() }
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { SELFHOSTED } = useRuntimeConfig();
|
const { SELFHOSTED } = useRuntimeConfig();
|
||||||
|
|
||||||
@@ -120,15 +68,42 @@ export default defineEventHandler(async event => {
|
|||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} else if (mode === 'events') {
|
||||||
|
|
||||||
const isGoogle = getHeader(event, 'x-google-export');
|
|
||||||
|
|
||||||
if (isGoogle === 'true') {
|
const eventsReportData = await EventModel.find({
|
||||||
const data = await exportToGoogle(result, user.id);
|
project_id,
|
||||||
return data;
|
created_at: {
|
||||||
|
$gt: Date.now() - timeSub
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const csvHeader = [
|
||||||
|
"name",
|
||||||
|
"session",
|
||||||
|
"metadata",
|
||||||
|
"website",
|
||||||
|
"created_at",
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const lines: any[] = [];
|
||||||
|
eventsReportData.forEach(line => lines.push(line.toJSON()));
|
||||||
|
|
||||||
|
const result = csvHeader.join(',') + '\n' + lines.map(element => {
|
||||||
|
const content: string[] = [];
|
||||||
|
for (const key of csvHeader) {
|
||||||
|
content.push(element[key]);
|
||||||
|
}
|
||||||
|
return content.join(',');
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
|
||||||
import pdfkit from 'pdfkit';
|
import pdfkit from 'pdfkit';
|
||||||
|
|
||||||
import { PassThrough } from 'node:stream';
|
import { PassThrough } from 'node:stream';
|
||||||
|
|
||||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||||
@@ -33,7 +32,7 @@ function formatNumberK(value: string | number, decimals: number = 1) {
|
|||||||
|
|
||||||
const LINE_SPACING = 0.5;
|
const LINE_SPACING = 0.5;
|
||||||
|
|
||||||
const resourcePath = process.env.MODE === 'TEST' ? './public/pdf/' : '../public/pdf/';
|
const resourcePath = process.env.MODE === 'TEST' ? './public/pdf/' : './.output/public/pdf/';
|
||||||
|
|
||||||
function createPdf(data: PDFGenerationData) {
|
function createPdf(data: PDFGenerationData) {
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default defineEventHandler(async event => {
|
|||||||
domain
|
domain
|
||||||
})
|
})
|
||||||
|
|
||||||
return timelineStackedEvents;
|
return timelineStackedEvents.filter(e => e.name != undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -18,7 +18,9 @@ export default defineEventHandler(async event => {
|
|||||||
model: VisitModel,
|
model: VisitModel,
|
||||||
from, to, slice, timeOffset, domain
|
from, to, slice, timeOffset, domain
|
||||||
});
|
});
|
||||||
|
|
||||||
return timelineData;
|
return timelineData;
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +65,7 @@ export async function getRequestData(event: H3Event<EventHandlerRequest>, requir
|
|||||||
if (requireDomain) {
|
if (requireDomain) {
|
||||||
if (domain == null || domain == undefined || domain.length == 0) return setResponseStatus(event, 400, 'x-domain is required');
|
if (domain == null || domain == undefined || domain.length == 0) return setResponseStatus(event, 400, 'x-domain is required');
|
||||||
}
|
}
|
||||||
if (domain === 'ALL DOMAINS') {
|
if (domain === 'All domains') {
|
||||||
domain = { $ne: '_NODOMAIN_' }
|
domain = { $ne: '_NODOMAIN_' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user