16 Commits

Author SHA1 Message Date
Emily
fa5a37ece2 . 2024-09-17 13:41:52 +02:00
Emily
db32afe741 better processed logs 2024-09-17 13:39:56 +02:00
Emily
e813b3246d remove console.log 2024-09-17 13:38:47 +02:00
Emily
86011c38ce add logger 2024-09-17 13:38:02 +02:00
Emily
fd5eca29cc remove test error 2024-09-16 20:39:07 +02:00
Emily
a591b43600 fixes 2024-09-16 20:09:32 +02:00
Emily
cebb45484c add logger 2024-09-16 20:09:15 +02:00
Emily
e4e2c2a42a fix anomaly 2024-09-16 20:09:07 +02:00
Emily
dfa1407102 fix anomaly service 2024-09-16 20:08:58 +02:00
Emily
e6adbf9c7b enchance ai 2024-09-16 15:37:18 +02:00
Emily
c3904ebd55 Add advanced ai 2024-09-16 01:03:49 +02:00
Emily
4c46a36c75 add anomaly + fix billing + add emails templates 2024-09-14 17:07:46 +02:00
Emily
c253846b86 fix email 2024-09-13 19:02:15 +02:00
Emily
e7c2dbf237 fix dates 2024-09-13 15:25:26 +02:00
Emily
525a371a6e . 2024-09-12 16:16:19 +02:00
Emily
6a9a698b7a add colors + fix billing page 2024-09-11 15:13:03 +02:00
75 changed files with 2450 additions and 687 deletions

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ steps
PROCESS_EVENT PROCESS_EVENT
docker docker
dev dev
docker-compose.admin.yml docker-compose.admin.yml
full_reload.sh

View File

@@ -11,8 +11,6 @@ if (process.env.EMAIL_SERVICE) {
export async function checkLimitsForEmail(projectCounts: TProjectLimit) { export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
console.log('CHECK LIMIT EMAIL');
const project_id = projectCounts.project_id; const project_id = projectCounts.project_id;
const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id }); const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
if (!hasNotifyEntry) { if (!hasNotifyEntry) {
@@ -20,7 +18,6 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
} }
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) { if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
console.log('LIMIT 3');
const notify = await LimitNotifyModel.findOne({ project_id }); const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.limit3 === true) return; if (notify && notify.limit3 === true) return;
@@ -35,7 +32,6 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true }); await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) { } else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
console.log('LIMIT 2');
const notify = await LimitNotifyModel.findOne({ project_id }); const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.limit2 === true) return; if (notify && notify.limit2 === true) return;
@@ -51,8 +47,6 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) { } else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
console.log('LIMIT 1');
const notify = await LimitNotifyModel.findOne({ project_id }); const notify = await LimitNotifyModel.findOne({ project_id });
if (notify && notify.limit1 === true) return; if (notify && notify.limit1 === true) return;

View File

@@ -20,7 +20,8 @@ export async function startStreamLoop() {
await RedisStreamService.startReadingLoop({ await RedisStreamService.startReadingLoop({
streamName: requireEnv('STREAM_NAME'), streamName: requireEnv('STREAM_NAME'),
delay: { base: 10, empty: 5000 }, delay: { base: 10, empty: 5000 },
readBlock: 2000 readBlock: 2000,
consumer: 'consumer_' + process.env.NODE_APP_INSTANCE
}, processStreamEvent); }, processStreamEvent);
} }

View File

@@ -12,6 +12,7 @@ node_modules
# Logs # Logs
logs logs
*.log *.log
winston-*.ndjson
# Misc # Misc
.DS_Store .DS_Store

View File

@@ -12,12 +12,6 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
const { visible } = usePricingDrawer(); const { visible } = usePricingDrawer();
const { data: planData } = useFetch('/api/project/plan', {
...signHeaders(),
lazy: true
});
</script> </script>
<template> <template>
@@ -25,10 +19,10 @@ const { data: planData } = useFetch('/api/project/plan', {
<div class="w-dvw h-dvh bg-lyx-background-light relative"> <div class="w-dvw h-dvh bg-lyx-background-light relative">
<Transition name="pdrawer"> <Transition name="pdrawer">
<LazyPricingDrawer @onCloseClick="visible = false" :currentSub="planData?.premium_type || 0" <LazyPricingDrawer @onCloseClick="visible = false"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if=visible> class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="visible">
</LazyPricingDrawer> </LazyPricingDrawer>
</Transition> </Transition>
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0"> <div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">

View File

@@ -67,7 +67,7 @@ const chartData = ref<ChartData<'bar'>>({
label: e.label || '?', label: e.label || '?',
backgroundColor: [e.color], backgroundColor: [e.color],
borderWidth: 0, borderWidth: 0,
borderRadius: 8 borderRadius: 0
} }
}) })
}); });

View File

@@ -10,7 +10,7 @@ export type Entry = {
icon?: string, icon?: string,
action?: () => any, action?: () => any,
adminOnly?: boolean, adminOnly?: boolean,
premiumOnly?:boolean, premiumOnly?: boolean,
external?: boolean, external?: boolean,
grow?: boolean grow?: boolean
} }
@@ -28,6 +28,7 @@ const route = useRoute();
const props = defineProps<Props>(); const props = defineProps<Props>();
const { isAdmin } = useUserRoles(); const { isAdmin } = useUserRoles();
const loggedUser = useLoggedUser()
const debugMode = process.dev; const debugMode = process.dev;
@@ -101,8 +102,15 @@ function onLogout() {
} }
const { projects } = useProjectsList(); const { projects } = useProjectsList();
const { data: guestProjects } = useGuestProjectsList()
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const selectorProjects = computed(() => {
const result: TProject[] = [];
if (projects.value) result.push(...projects.value);
if (guestProjects.value) result.push(...guestProjects.value);
return result;
});
const { data: maxProjects } = useFetch("/api/user/max_projects", { const { data: maxProjects } = useFetch("/api/user/max_projects", {
headers: computed(() => { headers: computed(() => {
@@ -117,10 +125,18 @@ watch(selected, () => {
setActiveProject(selected.value._id.toString()) setActiveProject(selected.value._id.toString())
}) })
const isPremium = computed(()=>{ const isPremium = computed(() => {
return activeProject.value?.premium; return activeProject.value?.premium;
}) })
function isProjectMine(owner?: string) {
if (!owner) return false;
if (!loggedUser.value?.logged) return;
return loggedUser.value.id == owner;
}
const pricingDrawer = usePricingDrawer();
</script> </script>
<template> <template>
@@ -131,6 +147,13 @@ const isPremium = computed(()=>{
}"> }">
<div class="py-4 px-2 gap-6 flex flex-col w-full"> <div class="py-4 px-2 gap-6 flex flex-col w-full">
<!-- <div class="flex px-2" v-if="!isPremium">
<LyxUiButton type="primary" class="w-full text-center text-[.8rem] font-medium" @click="pricingDrawer.visible.value = true;">
Upgrade plan
</LyxUiButton>
</div> -->
<div class="flex px-2 flex-col"> <div class="flex px-2 flex-col">
<div class="flex items-center gap-2 w-full"> <div class="flex items-center gap-2 w-full">
@@ -142,14 +165,14 @@ const isPremium = computed(()=>{
base: 'hover:!bg-lyx-widget-lighter cursor-pointer', base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter' active: '!bg-lyx-widget-lighter'
} }
}" class="w-full" v-if="projects" v-model="selected" :options="projects"> }" class="w-full" v-if="selectorProjects" v-model="selected" :options="selectorProjects">
<template #option="{ option, active, selected }"> <template #option="{ option, active, selected }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div> <div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo"> <img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div> </div>
<div> {{ option.name }} </div> <div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
</div> </div>
</template> </template>
@@ -158,7 +181,10 @@ const isPremium = computed(()=>{
<div> <div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo"> <img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div> </div>
<div> {{ activeProject?.name || '???' }} </div> <div>
{{ activeProject?.name || '-' }}
{{ !isProjectMine(activeProject?.owner?.toString()) ? '(Guest)' : '' }}
</div>
</div> </div>
</template> </template>
</USelectMenu> </USelectMenu>
@@ -169,6 +195,7 @@ const isPremium = computed(()=>{
</div> </div>
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))" <NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer"> class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
<div><i class="fas fa-plus"></i></div> <div><i class="fas fa-plus"></i></div>

View File

@@ -9,10 +9,10 @@ const props = defineProps<{ title: string, sub?: string }>();
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="flex flex-col grow"> <div class="flex flex-col grow">
<div class="poppins font-semibold text-[1.1rem] md:text-[1.4rem] text-text"> <div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-text">
{{ props.title }} {{ props.title }}
</div> </div>
<div v-if="props.sub" class="poppins text-[.8rem] md:text-[1.1rem] text-text-sub"> <div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-text-sub">
{{ props.sub }} {{ props.sub }}
</div> </div>
</div> </div>

View File

@@ -16,10 +16,10 @@ const emits = defineEmits<{
<template> <template>
<div class="flex gap-2 border-[1px] border-gray-400 p-1 md:p-2 rounded-xl"> <div class="flex gap-2 border-[1px] border-lyx-widget-lighter p-1 md:p-2 rounded-xl bg-lyx-widget">
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options" <div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
class="hover:bg-white/10 select-btn-animated cursor-pointer rounded-lg poppins font-semibold px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]" class="hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
:class="{ 'bg-accent hover:!bg-accent': currentIndex == index }"> :class="{ 'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
{{ opt.label }} {{ opt.label }}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as datefns from 'date-fns';
registerChartComponents();
const errored = ref<boolean>(false);
const props = defineProps<{
labels: string[],
title: string,
datasets: {
points: number[],
color: string,
chartType: 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 + '77'],
borderColor: e.color,
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: e.color,
hoverBorderColor: 'white',
hoverBorderWidth: 2,
type: e.chartType
} 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>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
registerChartComponents();
const props = defineProps<{
data: any[],
labels: string[]
color: 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: false },
title: { display: false },
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,
datasets: [
{
data: props.data,
backgroundColor: [props.color + '77'],
borderColor: props.color,
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: props.color,
hoverBorderColor: 'white',
hoverBorderWidth: 2,
},
],
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
onMounted(async () => {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${props.color}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${props.color}99`);
gradient.addColorStop(0.35, `${props.color}66`);
gradient.addColorStop(1, `${props.color}22`);
} else {
console.warn('Cannot get context for gradient');
}
chartData.value.datasets[0].backgroundColor = [gradient];
watch(props, () => {
chartData.value.labels = props.labels;
chartData.value.datasets[0].data = props.data;
});
});
</script>
<template>
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</template>

View File

@@ -0,0 +1,329 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService';
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
registerChartComponents();
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: false },
title: { display: false },
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',
enabled: false,
position: 'nearest',
external: externalTooltipHandler
}
},
});
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
labels: [],
datasets: [
{
label: 'Visits',
data: [],
backgroundColor: ['#5655d7'],
borderColor: '#5655d7',
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: '#5655d7',
hoverBorderColor: 'white',
hoverBorderWidth: 2,
},
{
label: 'Unique sessions',
data: [],
backgroundColor: ['#4abde8'],
borderColor: '#4abde8',
borderWidth: 2,
hoverBackgroundColor: '#4abde8',
hoverBorderColor: '#4abde8',
hoverBorderWidth: 2,
type: 'bar'
},
{
label: 'Events',
data: [],
backgroundColor: ['#fbbf24'],
borderColor: '#fbbf24',
borderWidth: 2,
hoverBackgroundColor: '#fbbf24',
hoverBorderColor: '#fbbf24',
hoverBorderWidth: 2,
type: 'bubble',
stack: 'combined'
},
],
});
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
const externalTooltipElement = ref<null | HTMLDivElement>(null);
function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) {
const { chart, tooltip } = context;
const tooltipEl = externalTooltipElement.value;
currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
currentTooltipData.value.date = new Date(allDatesFull.value[tooltip.dataPoints[0].dataIndex]).toLocaleDateString();
if (!tooltipEl) return;
if (tooltip.opacity === 0) {
tooltipEl.style.opacity = '0';
return;
}
const { left: positionX, top: positionY } = chart.canvas.getBoundingClientRect();
tooltipEl.style.opacity = '1';
tooltipEl.style.left = positionX + tooltip.caretX + 'px';
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
}
const selectLabels: { label: string, value: Slice }[] = [
{ label: 'Hour', value: 'hour' },
{ label: 'Day', value: 'day' },
];
const selectedLabelIndex = ref<number>(1);
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
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, navigator.language, selectLabels[selectedLabelIndex.value].value));
allDatesFull.value = input.map(e => e._id.toString());
return { data, labels }
}
const body = computed(() => {
return {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: selectLabels[selectedLabelIndex.value].value
}
});
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
});
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
});
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
});
const readyToDisplay = computed(() => {
return !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value;
});
watch(readyToDisplay, () => {
if (readyToDisplay.value === true) onDataReady();
})
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;
}
function onDataReady() {
console.log('DATA READY');
if (!visitsData.data.value) return;
if (!eventsData.data.value) return;
if (!sessionsData.data.value) return;
console.log('DATA READY 2');
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 = 25 / maxEventSize * e;
return { x: 0, y: maxChartY + 70, 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')];
console.log('UPDATE CHART');
updateChart();
}
const currentTooltipData = ref<{ visits: number, events: number, sessions: number, date: string }>({
visits: 0,
events: 0,
sessions: 0,
date: ''
});
const tooltipNameIndex = ['visits', 'sessions', 'events'];
function onLegendChange(dataset: any, index: number, checked: any) {
dataset.hidden = !checked;
}
const legendColors = [
'#5655d7',
'#4abde8',
'#fbbf24'
]
onMounted(async () => {
visitsData.execute();
eventsData.execute();
sessionsData.execute();
});
</script>
<template>
<CardTitled title="Trend chart" sub="Easily match Visits, Unique sessions and Events trends." class="w-full">
<template #header>
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event"
:currentIndex="selectedLabelIndex" :options="selectLabels">
</SelectButton>
</template>
<div class="flex gap-6 w-full justify-between">
<LyxUiButton type="secondary" to="/analyst">
<div class="flex items-center gap-2 px-10">
<i class="far fa-sparkles text-yellow-400"></i>
<div class="poppins text-lyx-text"> Ask AI </div>
</div>
</LyxUiButton>
<div class="flex gap-6">
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center text-[.9rem]">
<UCheckbox :ui="{
color: `text-[${legendColors[index]}]`
}" :model-value="true" @change="onLegendChange(dataset, index, $event)"></UCheckbox>
<label class="mt-[2px]"> {{ dataset.label }} </label>
</div>
</div>
</div>
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
<LyxUiCard>
<div class="flex gap-2 items-center">
<div> Date: </div>
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
</div>
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center">
<div :style="`background-color: ${legendColors[index]}`" class="h-4 w-4 rounded-full">
</div>
<div> {{ dataset.label }}</div>
<div v-if="currentTooltipData" class="grow text-right px-4">
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
</div>
</div>
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
</LyxUiCard>
</div>
<div v-if="!readyToDisplay" class="flex justify-center py-40">
<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" v-if="readyToDisplay">
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
</div>
</CardTitled>
</template>
<style lang="scss" scoped>
#external-tooltip {
border-radius: 3px;
color: white;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translate(-50%, 0);
transition: all .1s ease;
}
</style>

View File

@@ -46,7 +46,28 @@ const chartData = ref<ChartData<'doughnut'>>({
{ {
rotation: 1, rotation: 1,
data: [], data: [],
backgroundColor: ['#6bbbe3', '#5655d0', '#a6d5cb', '#fae0b9'], backgroundColor: [
"#5655d0",
"#6bbbe3",
"#a6d5cb",
"#fae0b9",
"#f28e8e",
"#e3a7e4",
"#c4a8e1",
"#8cc1d8",
"#f9c2cd",
"#b4e3b2",
"#ffdfba",
"#e9c3b5",
"#d5b8d6",
"#add7f6",
"#ffd1dc",
"#ffe7a1",
"#a8e6cf",
"#d4a5a5",
"#f3d6e4",
"#c3aed6"
],
borderColor: ['#1d1d1f'], borderColor: ['#1d1d1f'],
borderWidth: 2 borderWidth: 2
}, },
@@ -87,7 +108,7 @@ const headers = computed(() => {
}); });
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, { const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
method: 'POST', headers, lazy: true, immediate: false,transform:transformResponse method: 'POST', headers, lazy: true, immediate: false, transform: transformResponse
}); });
onMounted(() => { onMounted(() => {

View File

@@ -13,6 +13,19 @@ function copyProjectId() {
navigator.clipboard.writeText((activeProject.value?._id || 0).toString()); navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000); createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
} }
function showAnomalyInfoAlert() {
createAlert('AI Anomaly Detector info',
`Anomaly detector is running. It helps you detect a spike in visits or events, it could mean an
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
Litlyx will notify you via email with actionable advices`,
'far fa-bug',
10000
)
}
</script> </script>
@@ -29,8 +42,10 @@ function copyProjectId() {
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row"> <div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div> <div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }} </div> <div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }}
</div>
</div> </div>
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start"> <div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div> <div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -38,9 +53,20 @@ function copyProjectId() {
{{ activeProject?._id || 'Loading...' }} {{ activeProject?._id || 'Loading...' }}
</div> </div>
<div class="flex items-center ml-3"> <div class="flex items-center ml-3">
<i @click="copyProjectId()" class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i> <i @click="copyProjectId()"
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
</div> </div>
</div> </div>
</div> </div>
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
<div class="poppins font-regular text-[1rem]"> AI Anomaly Detector </div>
<div class="flex items-center">
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
@click="showAnomalyInfoAlert"></i>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -30,7 +30,29 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
}); });
const parsedDatasets: any[] = []; const parsedDatasets: any[] = [];
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
const colors = [
"#5655d0",
"#6bbbe3",
"#a6d5cb",
"#fae0b9",
"#f28e8e",
"#e3a7e4",
"#c4a8e1",
"#8cc1d8",
"#f9c2cd",
"#b4e3b2",
"#ffdfba",
"#e9c3b5",
"#d5b8d6",
"#add7f6",
"#ffd1dc",
"#ffe7a1",
"#a8e6cf",
"#d4a5a5",
"#f3d6e4",
"#c3aed6"
];
for (let i = 0; i < fixed.allKeys.length; i++) { for (let i = 0; i < fixed.allKeys.length; i++) {
const line: any = { const line: any = {

View File

@@ -38,7 +38,7 @@ async function analyzeEvent() {
<template> <template>
<CardTitled title="Event User Flow" <CardTitled title="Event User Flow"
sub="Track your user's journey from external links to custom events within your platform." class="w-full p-4"> sub="Track your user's journey from external links to in-app events, maintaining a complete view of their path from entry to engagement." class="w-full p-4">
<div class="p-2 flex flex-col gap-3"> <div class="p-2 flex flex-col gap-3">
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full" <USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"

View File

@@ -1,185 +1,201 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { PricingCardProp } from './PricingCardGeneric.vue'; import type { PricingCardProp } from './PricingCardGeneric.vue';
const props = defineProps<{ currentSub: number }>();
const freePricing: PricingCardProp[] = [
{
title: 'Free',
price: '€0 / mo',
subs: [
'Up to 5000 visits/events per month',
'CPM 0€ per visit/event'
],
features: [
'Email support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 10',
'Server type: SHARED',
'Data retention: 2 Months'
],
cta: 'Start For Free now!',
active: props.currentSub == 0,
isDowngrade: props.currentSub > 0,
planId: 0
},
]
const customPricing: PricingCardProp[] = [ const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
{ ...signHeaders(),
title: 'Enterprise', lazy: true
price: 'Custom', });
subs: [
'Unlimited visits/events per month', const activeProject = useActiveProject();
'Service Tailor-made on needs'
], watch(activeProject, () => {
features: [ refreshPlanData();
'Priority support', });
'Server type: DEDICATED',
'DB instance: DEDICATED',
'Dedicated operator', function getPricingsData() {
'White label',
'Custom Data Aggregation' const freePricing: PricingCardProp[] = [
], {
cta: 'Let\'s Talk!', title: 'Free',
link: 'mailto:help@litlyx.com', price: '€0 / mo',
active: false, subs: [
isDowngrade: false, 'Up to 5000 visits/events per month',
planId: -1 'CPM 0€ per visit/event'
} ],
] features: [
'Email support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 10',
'Server type: SHARED',
'Data retention: 2 Months'
],
cta: 'Start For Free now!',
active: (planData.value?.premium_type || 0) == 0,
isDowngrade: (planData.value?.premium_type || 0) > 0,
planId: 0
},
]
const customPricing: PricingCardProp[] = [
{
title: 'Enterprise',
price: 'Custom',
subs: [
'Unlimited visits/events per month',
'Service Tailor-made on needs'
],
features: [
'Priority support',
'Server type: DEDICATED',
'DB instance: DEDICATED',
'Dedicated operator',
'White label',
'Custom Data Aggregation'
],
cta: 'Let\'s Talk!',
link: 'mailto:help@litlyx.com',
active: false,
isDowngrade: false,
planId: -1
}
]
const slidePricings: PricingCardProp[] = [
{
title: 'Incubation',
price: '€4,99 / mo',
subs: [
'Up to 50.000 visits/events per month',
'CPM 0,10€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 30',
'Server type: SHARED',
'Data retention: 6 Months'
],
cta: 'Go to Cloud Dashboard',
active: (planData.value?.premium_type || 0) == 101,
isDowngrade: (planData.value?.premium_type || 0) > 101,
planId: 101
},
{
title: 'Acceleration',
price: '€9,99 / mo',
subs: [
'Up to 150.000 visits/events per month',
'CPM 0,06€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 100',
'Server type: SHARED',
'Data retention: 9 Months'
],
cta: 'Go to Cloud Dashboard',
active: (planData.value?.premium_type || 0) == 102,
isDowngrade: (planData.value?.premium_type || 0) > 102,
planId: 102
},
{
title: 'Growth',
price: '€29,99 / mo',
subs: [
'Up to 500.000 visits/events per month',
'CPM 0,059€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 3.000',
'Server type: SHARED',
'Data retention: 1 Year'
],
cta: 'Go to Cloud Dashboard',
active: (planData.value?.premium_type || 0) == 103,
isDowngrade: (planData.value?.premium_type || 0) > 103,
planId: 103
},
{
title: 'Expansion',
price: '€59,99 / mo',
subs: [
'Up to 1.000.000 visits/events per month',
'CPM 0,059€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 5.000',
'Server type: SHARED',
'Data retention: 1 Year'
],
cta: 'Go to Cloud Dashboard',
active: (planData.value?.premium_type || 0) == 104,
isDowngrade: (planData.value?.premium_type || 0) > 104,
planId: 104
},
{
title: 'Scaling',
price: '€99,99 / mo',
subs: [
'Up to 2.500.000 visits/events per month',
'CPM 0,039€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 10.000',
'Server type: DEDICATED',
'Data retention: 2 Years'
],
cta: 'Go to Cloud Dashboard',
active: (planData.value?.premium_type || 0) == 105,
isDowngrade: (planData.value?.premium_type || 0) > 105,
planId: 105
},
{
title: 'Unicorn',
price: '€149,99 / mo',
subs: [
'Up to 5.000.000 visits/events per month',
'CPM 0,029€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 20.000',
'Server type: DEDICATED',
'Data retention: 3 Years'
],
cta: 'Go to Cloud Dashboard',
active: (planData.value?.premium_type || 0) == 106,
isDowngrade: (planData.value?.premium_type || 0) > 106,
planId: 106
}
]
return { freePricing, customPricing, slidePricings }
}
const slidePricings: PricingCardProp[] = [
{
title: 'Incubation',
price: '€4,99 / mo',
subs: [
'Up to 50.000 visits/events per month',
'CPM 0,10€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 30',
'Server type: SHARED',
'Data retention: 6 Months'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 101,
isDowngrade: props.currentSub > 101,
planId: 101
},
{
title: 'Acceleration',
price: '€9,99 / mo',
subs: [
'Up to 150.000 visits/events per month',
'CPM 0,06€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 100',
'Server type: SHARED',
'Data retention: 9 Months'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 102,
isDowngrade: props.currentSub > 102,
planId: 102
},
{
title: 'Growth',
price: '€29,99 / mo',
subs: [
'Up to 500.000 visits/events per month',
'CPM 0,059€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 3.000',
'Server type: SHARED',
'Data retention: 1 Year'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 103,
isDowngrade: props.currentSub > 103,
planId: 103
},
{
title: 'Expansion',
price: '€59,99 / mo',
subs: [
'Up to 1.000.000 visits/events per month',
'CPM 0,059€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 5.000',
'Server type: SHARED',
'Data retention: 1 Year'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 104,
isDowngrade: props.currentSub > 104,
planId: 104
},
{
title: 'Scaling',
price: '€99,99 / mo',
subs: [
'Up to 2.500.000 visits/events per month',
'CPM 0,039€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 10.000',
'Server type: DEDICATED',
'Data retention: 2 Years'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 105,
isDowngrade: props.currentSub > 105,
planId: 105
},
{
title: 'Unicorn',
price: '€149,99 / mo',
subs: [
'Up to 5.000.000 visits/events per month',
'CPM 0,029€ per visit/event'
],
features: [
'Slack support',
'Unlimited domains',
'Unlimited reports',
'AI Tokens: 20.000',
'Server type: DEDICATED',
'Data retention: 3 Years'
],
cta: 'Go to Cloud Dashboard',
active: props.currentSub == 106,
isDowngrade: props.currentSub > 106,
planId: 106
}
]
const emits = defineEmits<{ const emits = defineEmits<{
(evt: 'onCloseClick'): void (evt: 'onCloseClick'): void
}>(); }>();
const activeProject = useActiveProject()
async function onLifetimeUpgradeClick() { async function onLifetimeUpgradeClick() {
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create-onetime`, { const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create-onetime`, {
...signHeaders({ 'content-type': 'application/json' }), ...signHeaders({ 'content-type': 'application/json' }),
@@ -201,9 +217,9 @@ async function onLifetimeUpgradeClick() {
</div> </div>
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col"> <div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
<PricingCardGeneric class="flex-1" :datas="freePricing"></PricingCardGeneric> <PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="slidePricings" :default-index="2"></PricingCardGeneric> <PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="customPricing"></PricingCardGeneric> <PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
</div> </div>
<LyxUiCard class="w-full mt-6"> <LyxUiCard class="w-full mt-6">

View File

@@ -14,7 +14,6 @@ const entries: SettingsTemplateEntry[] = [
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const projectNameInputVal = ref<string>(activeProject.value?.name || ''); const projectNameInputVal = ref<string>(activeProject.value?.name || '');
const apiKeys = ref<TApiSettings[]>([]); const apiKeys = ref<TApiSettings[]>([]);
const newApiKeyName = ref<string>(''); const newApiKeyName = ref<string>('');
@@ -145,13 +144,15 @@ function copyProjectId() {
<template #pname> <template #pname>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput> <LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton> <LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary"> Change
</LyxUiButton>
</div> </div>
</template> </template>
<template #api> <template #api>
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5"> <div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
<LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput> <LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput>
<LyxUiButton @click="createApiKey()" :disabled="newApiKeyName.length < 3" type="primary"> <LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3"
type="primary">
<i class="far fa-plus"></i> <i class="far fa-plus"></i>
</LyxUiButton> </LyxUiButton>
</div> </div>
@@ -185,8 +186,8 @@ function copyProjectId() {
<div><i class="far fa-copy" @click="copyScript()"></i></div> <div><i class="far fa-copy" @click="copyScript()"></i></div>
</LyxUiCard> </LyxUiCard>
</template> </template>
<template #pdelete> <template #pdelete >
<div class="flex justify-end"> <div class="flex justify-end" v-if="!isGuest">
<LyxUiButton type="danger" @click="deleteProject()"> <LyxUiButton type="danger" @click="deleteProject()">
Delete project Delete project
</LyxUiButton> </LyxUiButton>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { SettingsTemplateEntry } from './Template.vue'; import type { SettingsTemplateEntry } from './Template.vue';
import { getPlanFromId, PREMIUM_PLAN, type PREMIUM_TAG } from '@data/PREMIUM';
const activeProject = useActiveProject(); const activeProject = useActiveProject();
@@ -51,11 +52,17 @@ function openInvoice(link: string) {
} }
function getPremiumName(type: number) { function getPremiumName(type: number) {
if (type === 0) return 'FREE';
if (type === 1) return 'ACCELERATION';
if (type === 2) return 'EXPANSION';
return 'CUSTOM';
return Object.keys(PREMIUM_PLAN).map(e => ({
...PREMIUM_PLAN[e as PREMIUM_TAG], name: e
})).find(e => e.ID == type)?.name;
}
function getPremiumPrice(type: number) {
const PLAN = getPlanFromId(type);
if (!PLAN) return '0,00';
return (PLAN.COST / 100).toFixed(2).replace('.', ',')
} }
@@ -66,12 +73,17 @@ watch(activeProject, () => {
const entries: SettingsTemplateEntry[] = [ const entries: SettingsTemplateEntry[] = [
// { id: 'info', title: 'Billing informations', text: 'Manage billing informations for this project' },
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' }, { id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' }, { id: 'usage', title: 'Usage', text: 'Show usage of current project' },
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' }, { id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
] ]
const currentBillingInfo = ref<any>({
address: ''
});
const { visible } = usePricingDrawer(); const { visible } = usePricingDrawer();
</script> </script>
@@ -104,8 +116,12 @@ const { visible } = usePricingDrawer();
</div> </div>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="poppins font-semibold text-[2rem]"> $0 </div> <div class="poppins font-semibold text-[2rem]">
{{ getPremiumPrice(planData.premium_type) }} </div>
<div class="poppins text-text-sub mt-2"> per month </div> <div class="poppins text-text-sub mt-2"> per month </div>
<div class="flex items-center ml-2">
<i class="far fa-info-circle text-[.8rem]"></i>
</div>
</div> </div>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">

View File

@@ -1,11 +1,12 @@
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import annotaionPlugin from 'chartjs-plugin-annotation';
let registered = false; let registered = false;
export async function registerChartComponents() { export async function registerChartComponents() {
if (registered) return; if (registered) return;
if (process.client) { if (process.client) {
Chart.register(...registerables); Chart.register(...registerables, annotaionPlugin);
registered = true; registered = true;
} }
} }

View File

@@ -17,7 +17,7 @@ const sections: Section[] = [
entries: [ entries: [
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' }, { label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' }, { label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
{ label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' }, { label: 'AI Analyst', to: '/analyst', icon: 'fal fa-sparkles' },
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true }, { label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
{ label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true }, { label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
{ label: 'Integrations (soon)', to: '#', icon: 'fal fa-cube', disabled: true }, { label: 'Integrations (soon)', to: '#', icon: 'fal fa-cube', disabled: true },
@@ -29,8 +29,10 @@ const sections: Section[] = [
}, },
{ {
label: 'Slack support', icon: 'fab fa-slack', label: 'Slack support', icon: 'fab fa-slack',
to:'#',
premiumOnly: true, premiumOnly: true,
action() { action() {
if (isGuest.value === true) return;
if (isPremium.value === true) { if (isPremium.value === true) {
window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank'); window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank');
} else { } else {

View File

@@ -43,6 +43,8 @@ export default defineNuxtConfig({
AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET,
GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID, GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID,
GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET, GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET,
GITHUB_AUTH_CLIENT_ID: process.env.GITHUB_AUTH_CLIENT_ID,
GITHUB_AUTH_CLIENT_SECRET: process.env.GITHUB_AUTH_CLIENT_SECRET,
STRIPE_SECRET: process.env.STRIPE_SECRET, STRIPE_SECRET: process.env.STRIPE_SECRET,
STRIPE_WH_SECRET: process.env.STRIPE_WH_SECRET, STRIPE_WH_SECRET: process.env.STRIPE_WH_SECRET,
STRIPE_SECRET_TEST: process.env.STRIPE_SECRET_TEST, STRIPE_SECRET_TEST: process.env.STRIPE_SECRET_TEST,
@@ -50,7 +52,8 @@ export default defineNuxtConfig({
NOAUTH_USER_EMAIL: process.env.NOAUTH_USER_EMAIL, NOAUTH_USER_EMAIL: process.env.NOAUTH_USER_EMAIL,
NOAUTH_USER_NAME: process.env.NOAUTH_USER_NAME, NOAUTH_USER_NAME: process.env.NOAUTH_USER_NAME,
public: { public: {
AUTH_MODE: process.env.AUTH_MODE AUTH_MODE: process.env.AUTH_MODE,
GITHUB_CLIENT_ID: process.env.GITHUB_AUTH_CLIENT_ID || 'NONE'
} }
}, },

View File

@@ -16,6 +16,7 @@
"@getbrevo/brevo": "^2.2.0", "@getbrevo/brevo": "^2.2.0",
"@nuxtjs/tailwindcss": "^6.12.0", "@nuxtjs/tailwindcss": "^6.12.0",
"chart.js": "^3.9.1", "chart.js": "^3.9.1",
"chartjs-plugin-annotation": "^2.2.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"google-auth-library": "^9.9.0", "google-auth-library": "^9.9.0",
@@ -25,7 +26,7 @@
"nodemailer": "^6.9.13", "nodemailer": "^6.9.13",
"nuxt": "^3.11.2", "nuxt": "^3.11.2",
"nuxt-vue3-google-signin": "^0.0.11", "nuxt-vue3-google-signin": "^0.0.11",
"openai": "^4.47.1", "openai": "^4.61.0",
"pdfkit": "^0.15.0", "pdfkit": "^0.15.0",
"primevue": "^3.52.0", "primevue": "^3.52.0",
"redis": "^4.6.13", "redis": "^4.6.13",
@@ -34,11 +35,14 @@
"v-calendar": "^3.1.2", "v-calendar": "^3.1.2",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-chart-3": "^3.1.8", "vue-chart-3": "^3.1.8",
"vue-router": "^4.3.0" "vue-markdown-render": "^2.2.1",
"vue-router": "^4.3.0",
"winston": "^3.14.2"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/ui": "^2.15.2", "@nuxt/ui": "^2.15.2",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^6.4.15", "@types/nodemailer": "^6.4.15",
"@types/pdfkit": "^0.13.4", "@types/pdfkit": "^0.13.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",

View File

@@ -1,10 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
definePageMeta({ layout: 'dashboard' });
import VueMarkdown from 'vue-markdown-render';
definePageMeta({ layout: 'dashboard' });
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const { data: chatsList, refresh: reloadChatsList } = useFetch(`/api/ai/${activeProject.value?._id}/chats_list`, signHeaders()); const { data: chatsList, refresh: reloadChatsList } = useFetch(`/api/ai/${activeProject.value?._id}/chats_list`, {
...signHeaders()
});
const viewChatsList = computed(() => (chatsList.value || []).toReversed());
const { data: chatsRemaining, refresh: reloadChatsRemaining } = useFetch(`/api/ai/${activeProject.value?._id}/chats_remaining`, signHeaders()); const { data: chatsRemaining, refresh: reloadChatsRemaining } = useFetch(`/api/ai/${activeProject.value?._id}/chats_remaining`, signHeaders());
@@ -12,7 +20,7 @@ const currentText = ref<string>("");
const loading = ref<boolean>(false); const loading = ref<boolean>(false);
const currentChatId = ref<string>(""); const currentChatId = ref<string>("");
const currentChatMessages = ref<any[]>([]); const currentChatMessages = ref<{ role: string, content: string, charts?: any[] }[]>([]);
const scroller = ref<HTMLDivElement | null>(null); const scroller = ref<HTMLDivElement | null>(null);
@@ -39,7 +47,7 @@ async function sendMessage() {
body: JSON.stringify(body), body: JSON.stringify(body),
...signHeaders({ 'Content-Type': 'application/json' }) ...signHeaders({ 'Content-Type': 'application/json' })
}); });
currentChatMessages.value.push({ role: 'assistant', content: res }); currentChatMessages.value.push({ role: 'assistant', content: res.content || 'nocontent', charts: res.charts.map(e => JSON.parse(e)) });
await reloadChatsRemaining(); await reloadChatsRemaining();
await reloadChatsList(); await reloadChatsList();
@@ -67,15 +75,18 @@ async function sendMessage() {
async function openChat(chat_id?: string) { async function openChat(chat_id?: string) {
menuOpen.value = false; menuOpen.value = false;
if (!activeProject.value) return; if (!activeProject.value) return;
currentChatMessages.value = [];
if (!chat_id) { if (!chat_id) {
currentChatMessages.value = [];
currentChatId.value = ''; currentChatId.value = '';
return; return;
} }
currentChatId.value = chat_id; currentChatId.value = chat_id;
const messages = await $fetch(`/api/ai/${activeProject.value._id}/${chat_id}/get_messages`, signHeaders()); const messages = await $fetch(`/api/ai/${activeProject.value._id}/${chat_id}/get_messages`, signHeaders());
if (!messages) return; if (!messages) return;
currentChatMessages.value = messages;
currentChatMessages.value = messages.map(e => ({ ...e, charts: e.charts.map(k => JSON.parse(k)) })) as any;
setTimeout(() => scrollToBottom(), 1); setTimeout(() => scrollToBottom(), 1);
} }
@@ -99,10 +110,10 @@ function onKeyDown(e: KeyboardEvent) {
const menuOpen = ref<boolean>(false); const menuOpen = ref<boolean>(false);
const defaultPrompts = [ const defaultPrompts = [
'How many visits i got last week ?', "Create a line chart with this data: \n[100, 200, 30, 300, 500, 40]",
'How many visits i got last month ?', "Create a chart with Events (bar) and Visits (line) data from last week.",
'How many visits i got today ?', "How many visits did I get last week?",
'How many events i got last week ?', "Create a line chart of last week's visits."
] ]
async function deleteChat(chat_id: string) { async function deleteChat(chat_id: string) {
@@ -117,6 +128,8 @@ async function deleteChat(chat_id: string) {
await reloadChatsList(); await reloadChatsList();
} }
const { visible: pricingDrawerVisible } = usePricingDrawer()
</script> </script>
<template> <template>
@@ -124,7 +137,7 @@ async function deleteChat(chat_id: string) {
<div class="flex flex-row h-full"> <div class="flex flex-row h-full">
<div class="flex-[5] py-8 flex flex-col items-center relative"> <div class="flex-[5] py-8 flex flex-col items-center relative bg-lyx-background-light">
<div class="flex flex-col items-center mt-[20vh] px-28" v-if="currentChatMessages.length == 0"> <div class="flex flex-col items-center mt-[20vh] px-28" v-if="currentChatMessages.length == 0">
<div class="w-[10rem]"> <div class="w-[10rem]">
@@ -138,7 +151,7 @@ async function deleteChat(chat_id: string) {
</div> </div>
<div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest"> <div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest">
<div v-for="prompt of defaultPrompts" @click="currentText = prompt" <div v-for="prompt of defaultPrompts" @click="currentText = prompt"
class="bg-[#2f2f2f] hover:bg-[#424242] cursor-pointer p-4 rounded-lg poppins text-center"> class="bg-lyx-widget-light hover:bg-lyx-widget-lighter cursor-pointer p-4 rounded-lg poppins text-center whitespace-pre-wrap flex items-center justify-center text-[.9rem]">
{{ prompt }} {{ prompt }}
</div> </div>
</div> </div>
@@ -146,21 +159,36 @@ async function deleteChat(chat_id: string) {
<div ref="scroller" class="flex flex-col w-full gap-6 px-6 xl:px-28 overflow-y-auto pb-20"> <div ref="scroller" class="flex flex-col w-full gap-6 px-6 xl:px-28 overflow-y-auto pb-20">
<div class="flex w-full" v-for="message of currentChatMessages"> <div class="flex w-full flex-col" v-for="message of currentChatMessages">
<div class="flex justify-end w-full poppins text-[1.1rem]" v-if="message.role === 'user'"> <div class="flex justify-end w-full poppins text-[1.1rem]" v-if="message.role === 'user'">
<div class="bg-[#303030] px-5 py-3 rounded-lg"> <div class="bg-lyx-widget-light px-5 py-3 rounded-lg">
{{ message.content }} {{ message.content }}
</div> </div>
</div> </div>
<div class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]" <div class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]"
v-if="message.role === 'assistant'"> v-if="message.role === 'assistant' && message.content">
<div class="flex items-center justify-center shrink-0"> <div class="flex items-center justify-center shrink-0">
<img class="h-[3.5rem] w-auto" :src="'analyst.png'"> <img class="h-[3.5rem] w-auto" :src="'analyst.png'">
</div> </div>
<div v-html="parseMessageContent(message.content)" <div class="max-w-[70%] text-text/90 ai-message">
class="max-w-[70%] text-text/90 whitespace-pre-wrap"> <vue-markdown :source="message.content" :options="{
html: true,
breaks: true,
}" />
</div>
</div>
<div v-if="message.charts && message.charts.length > 0"
class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem] flex-col mt-4">
<div v-for="chart of message.charts" class="w-full">
<AnalystComposableChart :datasets="chart.datasets" :labels="chart.labels"
:title="chart.title">
</AnalystComposableChart>
</div> </div>
</div> </div>
</div> </div>
<div v-if="loading" <div v-if="loading"
@@ -177,13 +205,13 @@ async function deleteChat(chat_id: string) {
<div v-if="!isGuest" class="flex gap-2 items-center absolute bottom-8 left-0 w-full px-10 xl:px-28"> <div v-if="!isGuest" class="flex gap-2 items-center absolute bottom-8 left-0 w-full px-10 xl:px-28">
<input @keydown="onKeyDown" v-model="currentText" <input @keydown="onKeyDown" v-model="currentText"
class="bg-[#303030] w-full focus:outline-none px-4 py-2 rounded-lg" type="text"> class="bg-lyx-widget-light w-full focus:outline-none px-4 py-2 rounded-lg" type="text">
<div @click="sendMessage()" <div @click="sendMessage()"
class="bg-[#303030] hover:bg-[#464646] cursor-pointer px-4 py-2 rounded-full"> class="bg-lyx-widget-light hhover:bg-lyx-widget-lighter cursor-pointer px-4 py-2 rounded-full">
<i class="far fa-arrow-up"></i> <i class="far fa-arrow-up"></i>
</div> </div>
<div @click="menuOpen = !menuOpen" <div @click="menuOpen = !menuOpen"
class="bg-[#303030] lg:hidden hover:bg-[#464646] cursor-pointer px-4 py-2 rounded-full"> class="bg-lyx-widget-light lg:hidden hhover:bg-lyx-widget-lighter cursor-pointer px-4 py-2 rounded-full">
<i class="far fa-message"></i> <i class="far fa-message"></i>
</div> </div>
</div> </div>
@@ -194,32 +222,37 @@ async function deleteChat(chat_id: string) {
<div :class="{ <div :class="{
'absolute': menuOpen, 'absolute': menuOpen,
'hidden lg:flex': !menuOpen 'hidden lg:flex': !menuOpen
}" class="flex-[2] bg-[#303030] p-6 flex flex-col gap-4 h-full overflow-hidden"> }" class="flex-[2] bg-lyx-widget-light p-6 flex flex-col gap-4 h-full overflow-hidden">
<div class="gap-2 flex flex-col"> <div class="gap-2 flex flex-col">
<div class="lg:hidden absolute right-4 top-4 text-[1.5rem]"> <div class="lg:hidden absolute right-4 top-4 text-[1.5rem]">
<i @click="menuOpen = false" class="fas fa-close cursor-pointer"></i> <i @click="menuOpen = false" class="fas fa-close cursor-pointer"></i>
</div> </div>
<div class="poppins font-semibold text-[1.5rem]"> <div class="poppins font-semibold text-[1.5rem]">
Lit, your AI Analyst is here! What Lit can do for you?
</div> </div>
<div class="poppins text-text/75"> <div class="poppins text-text/75">
Ask anything you want on your analytics, Ask anything from your data history, visualize and overlap charts, explore events or metadata,
and understand more Trends and Key Points to take Strategic moves! and enjoy a highly personalized data analysis experience.
</div> </div>
</div> </div>
<div class="flex gap-2 items-center py-3"> <div class="flex gap-2 items-center pt-3">
<div class="bg-accent w-5 h-5 rounded-full animate-pulse"> <div class="bg-accent w-5 h-5 rounded-full animate-pulse">
</div> </div>
<div class="manrope font-semibold"> {{ chatsRemaining }} remaining messages </div> <div class="manrope font-semibold"> {{ chatsRemaining }} remaining AI requests </div>
</div> </div>
<div class="poppins font-semibold text-[1.1rem]"> History: </div> <LyxUiButton type="primary" class="text-[.9rem] text-center w-full"
@click="pricingDrawerVisible = true">
Upgrade plan for more requests
</LyxUiButton>
<div class="poppins font-semibold text-[1.1rem]"> History </div>
<div class="px-2"> <div class="px-2">
<div @click="openChat()" <div @click="openChat()"
class="bg-menu cursor-pointer hover:bg-menu/80 rounded-lg px-4 py-3 poppins flex gap-2 items-center"> class="bg-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget rounded-lg px-4 py-3 poppins flex gap-4 items-center">
<div> <i class="fas fa-plus"></i> </div> <div> <i class="fas fa-plus"></i> </div>
<div> New chat </div> <div> New chat </div>
</div> </div>
@@ -228,12 +261,13 @@ async function deleteChat(chat_id: string) {
<div class="overflow-y-auto"> <div class="overflow-y-auto">
<div class="flex flex-col gap-2 px-2"> <div class="flex flex-col gap-2 px-2">
<div class="flex items-center gap-4 w-full" v-for="chat of chatsList?.toReversed()"> <div :class="{ '!bg-accent/60': chat._id.toString() === currentChatId }"
class="flex rounded-lg items-center gap-4 w-full px-4 bg-lyx-widget-lighter hover:bg-lyx-widget"
v-for="chat of viewChatsList">
<i @click="deleteChat(chat._id.toString())" <i @click="deleteChat(chat._id.toString())"
class="fas fa-trash hover:text-gray-300 cursor-pointer"></i> class="far fa-trash hover:text-gray-300 cursor-pointer"></i>
<div @click="openChat(chat._id.toString())" <div @click="openChat(chat._id.toString())"
class="bg-menu px-4 py-3 w-full cursor-pointer hover:bg-menu/80 poppins rounded-lg" class="py-3 w-full cursor-pointer poppins rounded-lg">
:class="{ '!bg-accent/60': chat._id.toString() === currentChatId }">
{{ chat.title }} {{ chat.title }}
</div> </div>
</div> </div>
@@ -248,4 +282,76 @@ async function deleteChat(chat_id: string) {
</div> </div>
</div> </div>
</template> </template>
<style lang="scss">
.ai-message {
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: bold;
margin-top: 1.5em;
margin-bottom: 0.5em;
color: white;
}
p {
line-height: 1.8;
margin-bottom: 1em;
max-width: 750px;
}
blockquote {
margin: 1.5em 10px;
padding: 10px 20px;
color: #555;
border-left: 5px solid #ccc;
background-color: #f5f5f5;
}
pre {
background-color: #f4f4f4;
padding: 15px;
border-radius: 5px;
font-size: 14px;
overflow-x: auto;
}
code {
background-color: #f1f1f1;
padding: 2px 5px;
border-radius: 3px;
font-size: 90%;
}
ul,
ol {
margin-left: 30px;
margin-bottom: 1.5em;
}
li {
margin-bottom: 0.5em;
}
a {
color: #007acc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
hr {
border: 1px solid #ddd;
margin: 2em 0;
}
}
</style>

View File

@@ -78,6 +78,10 @@ const { snapshot } = useSnapshot();
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`); const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
const isPremium = computed(() => {
return activeProject.value?.premium;
})
const pricingDrawer = usePricingDrawer(); const pricingDrawer = usePricingDrawer();
function goToUpgrade() { function goToUpgrade() {
@@ -93,9 +97,7 @@ function goToUpgrade() {
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value"> <div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value">
<div class="w-full px-4 py-2"> <div class="w-full px-4 py-2 gap-2 flex flex-col">
<div v-if="limitsInfo && limitsInfo.limited" <div v-if="limitsInfo && limitsInfo.limited"
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center"> class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
<div class="flex flex-col grow"> <div class="flex flex-col grow">
@@ -110,15 +112,35 @@ function goToUpgrade() {
<div> <div>
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton> <LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
</div> </div>
</div> </div>
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
<div class="flex flex-col grow">
<div class="poppins font-semibold text-lyx-primary">
Launch offer: 25% off
</div>
<div class="poppins text-lyx-primary">
We're offering an exclusive 25% discount forever on all plans starting from the Acceleration
Plan for our first 100 users who believe in our project.
<br>
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
claim your discount.
</div>
</div>
<div>
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
</div>
</div>
</div> </div>
<DashboardTopSection></DashboardTopSection> <DashboardTopSection></DashboardTopSection>
<DashboardTopCards :key="refreshKey"></DashboardTopCards> <DashboardTopCards :key="refreshKey"></DashboardTopCards>
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full">
<DashboardActionableChart :key="refreshKey"></DashboardActionableChart>
</div>
<!--
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row"> <div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Visits trends" <CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Visits trends"
@@ -128,26 +150,25 @@ function goToUpgrade() {
:options="selectLabels"> :options="selectLabels">
</SelectButton> </SelectButton>
</template> </template>
<div> <div>
<DashboardVisitsLineChart :slice="(selectLabels[mainChartSelectIndex].value as any)"> <DashboardVisitsLineChart :slice="(selectLabels[mainChartSelectIndex].value as any)">
</DashboardVisitsLineChart> </DashboardVisitsLineChart>
</div> </div>
</CardTitled> </CardTitled>
<!-- <CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions" <CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions" sub="Shows trends in sessions.">
sub="Shows trends in sessions."> <template #header>
<template #header>
<SelectButton @changeIndex="sessionsChartSelectIndex = $event" <SelectButton @changeIndex="sessionsChartSelectIndex = $event"
:currentIndex="sessionsChartSelectIndex" :options="selectLabels"> :currentIndex="sessionsChartSelectIndex" :options="selectLabels">
</SelectButton> </SelectButton>
</template> </template>
<div> <div>
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)"> <DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
</DashboardSessionsLineChart> </DashboardSessionsLineChart>
</div> </div>
</CardTitled> --> </CardTitled>
</div> </div> -->
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">
<div class="flex w-full gap-6 flex-col xl:flex-row"> <div class="flex w-full gap-6 flex-col xl:flex-row">
@@ -160,7 +181,6 @@ function goToUpgrade() {
</div> </div>
</div> </div>
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">
<div class="flex w-full gap-6 flex-col xl:flex-row"> <div class="flex w-full gap-6 flex-col xl:flex-row">
<div class="flex-1"> <div class="flex-1">

View File

@@ -81,6 +81,33 @@ function handleOnError(errorResponse: any) {
alert('Error' + errorResponse); alert('Error' + errorResponse);
}; };
function getRandomHex(size: number) {
const bytes = new Uint8Array(size);
window.crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
function githubLogin() {
const client_id = config.public.GITHUB_CLIENT_ID;
const redirect_uri = window.location.origin + '/api';
console.log({ redirect_uri })
const state = getRandomHex(16);
localStorage.setItem("latestCSRFToken", state);
const link = `https://github.com/login/oauth/authorize?client_id=${client_id}&response_type=code&scope=repo&redirect_uri=${redirect_uri}/integrations/github/oauth2/callback&state=${state}`;
window.location.assign(link);
}
const route = useRoute();
onMounted(() => {
if (route.query.github_access_token) {
//TODO: Something
}
})
</script> </script>
@@ -103,23 +130,34 @@ function handleOnError(errorResponse: any) {
</div> </div>
<div class="text-text/80 text-[1.2rem] text-center w-[70%] poppins mt-2"> <div class="text-text/80 text-[1.2rem] text-center w-[70%] poppins mt-2">
Real-time analytics for 15+ JS/TS frameworks Track web analytics and custom events
<br> <br>
with one-line code setup. with extreme simplicity in under 30 sec.
<br> <br>
<div class="font-bold poppins mt-4"> <!-- <div class="font-bold poppins mt-4">
Start for Free now! Up to 3k visits/events monthly. Start for Free now! Up to 3k visits/events monthly.
</div> </div> -->
</div> </div>
<div class="mt-12"> <div class="mt-12">
<div v-if="!isNoAuth" @click="login"
class="hover:bg-accent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]"> <div v-if="!isNoAuth" class="flex flex-col gap-2">
<div class="flex items-center"> <div @click="login"
<i class="fab fa-google"></i> class="hover:bg-accent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
<div class="flex items-center">
<i class="fab fa-google"></i>
</div>
Continue with Google
</div>
<div
class=" opacity-35 cursor-not-allowed flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
<div class="flex items-center">
<i class="fab fa-github"></i>
</div>
Continue with GitHub
</div> </div>
Continue with Google
</div> </div>
<div v-if="isNoAuth" @click="loginWithoutAuth" <div v-if="isNoAuth" @click="loginWithoutAuth"
@@ -133,7 +171,7 @@ function handleOnError(errorResponse: any) {
</div> </div>
<div class="text-[.9rem] poppins mt-12 text-text-sub text-center relative z-[2]"> <div class="text-[.9rem] poppins mt-12 text-text-sub text-center relative z-[2]">
By continuing you are indicating that you accept By continuing you are accepting
<br> <br>
our our
<a class="underline" href="https://litlyx.com/terms" target="_blank">Terms of Service</a> and <a class="underline" href="https://litlyx.com/terms" target="_blank">Terms of Service</a> and

308
dashboard/pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
chart.js: chart.js:
specifier: ^3.9.1 specifier: ^3.9.1
version: 3.9.1 version: 3.9.1
chartjs-plugin-annotation:
specifier: ^2.2.1
version: 2.2.1(chart.js@3.9.1)
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
@@ -45,8 +48,8 @@ importers:
specifier: ^0.0.11 specifier: ^0.0.11
version: 0.0.11(@types/node@20.12.12)(rollup@4.18.0)(typescript@5.4.2)(vite@5.2.12(@types/node@20.12.12)(sass@1.77.2)(terser@5.31.0))(vue@3.4.27(typescript@5.4.2)) version: 0.0.11(@types/node@20.12.12)(rollup@4.18.0)(typescript@5.4.2)(vite@5.2.12(@types/node@20.12.12)(sass@1.77.2)(terser@5.31.0))(vue@3.4.27(typescript@5.4.2))
openai: openai:
specifier: ^4.47.1 specifier: ^4.61.0
version: 4.47.2(encoding@0.1.13) version: 4.61.0(encoding@0.1.13)
pdfkit: pdfkit:
specifier: ^0.15.0 specifier: ^0.15.0
version: 0.15.0 version: 0.15.0
@@ -71,9 +74,15 @@ importers:
vue-chart-3: vue-chart-3:
specifier: ^3.1.8 specifier: ^3.1.8
version: 3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2)) version: 3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2))
vue-markdown-render:
specifier: ^2.2.1
version: 2.2.1(vue@3.4.27(typescript@5.4.2))
vue-router: vue-router:
specifier: ^4.3.0 specifier: ^4.3.0
version: 4.3.2(vue@3.4.27(typescript@5.4.2)) version: 4.3.2(vue@3.4.27(typescript@5.4.2))
winston:
specifier: ^3.14.2
version: 3.14.2
devDependencies: devDependencies:
'@nuxt/ui': '@nuxt/ui':
specifier: ^2.15.2 specifier: ^2.15.2
@@ -81,6 +90,9 @@ importers:
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: ^9.0.6 specifier: ^9.0.6
version: 9.0.6 version: 9.0.6
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@types/nodemailer': '@types/nodemailer':
specifier: ^6.4.15 specifier: ^6.4.15
version: 6.4.15 version: 6.4.15
@@ -304,6 +316,10 @@ packages:
resolution: {integrity: sha512-EeEjMobfuJrwoctj7FA1y1KEbM0+Q1xSjobIEyie9k4haVEBB7vkDvsasw1pM3rO39mL2akxIAzLMUAtrMHZhA==} resolution: {integrity: sha512-EeEjMobfuJrwoctj7FA1y1KEbM0+Q1xSjobIEyie9k4haVEBB7vkDvsasw1pM3rO39mL2akxIAzLMUAtrMHZhA==}
engines: {node: '>=16.13'} engines: {node: '>=16.13'}
'@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
engines: {node: '>=0.1.90'}
'@csstools/selector-resolve-nested@1.1.0': '@csstools/selector-resolve-nested@1.1.0':
resolution: {integrity: sha512-uWvSaeRcHyeNenKg8tp17EVDRkpflmdyvbE0DHo6D/GdBb6PDnCYYU6gRpXhtICMGMcahQmj2zGxwFM/WC8hCg==} resolution: {integrity: sha512-uWvSaeRcHyeNenKg8tp17EVDRkpflmdyvbE0DHo6D/GdBb6PDnCYYU6gRpXhtICMGMcahQmj2zGxwFM/WC8hCg==}
engines: {node: ^14 || ^16 || >=18} engines: {node: ^14 || ^16 || >=18}
@@ -316,6 +332,9 @@ packages:
peerDependencies: peerDependencies:
postcss-selector-parser: ^6.0.13 postcss-selector-parser: ^6.0.13
'@dabh/diagnostics@2.0.3':
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
'@egoist/tailwindcss-icons@1.8.0': '@egoist/tailwindcss-icons@1.8.0':
resolution: {integrity: sha512-75LfllKL2lq0sGH+wcpsn/sLtJ0kMkDWmcZTLAB76QLDTmfsFu4QHwZVbtCD2woGyKl9c8KvtOUW9JSjRqOVtA==} resolution: {integrity: sha512-75LfllKL2lq0sGH+wcpsn/sLtJ0kMkDWmcZTLAB76QLDTmfsFu4QHwZVbtCD2woGyKl9c8KvtOUW9JSjRqOVtA==}
peerDependencies: peerDependencies:
@@ -1168,9 +1187,18 @@ packages:
'@types/jsonwebtoken@9.0.6': '@types/jsonwebtoken@9.0.6':
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/lodash@4.17.7': '@types/lodash@4.17.7':
resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==} resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node-fetch@2.6.11': '@types/node-fetch@2.6.11':
resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==} resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==}
@@ -1186,12 +1214,18 @@ packages:
'@types/pdfkit@0.13.4': '@types/pdfkit@0.13.4':
resolution: {integrity: sha512-ixGNDHYJCCKuamY305wbfYSphZ2WPe8FPkjn8oF4fHV+PgPV4V+hecPh2VOS2h4RNtpSB3zQcR4sCpNvvrEb1A==} resolution: {integrity: sha512-ixGNDHYJCCKuamY305wbfYSphZ2WPe8FPkjn8oF4fHV+PgPV4V+hecPh2VOS2h4RNtpSB3zQcR4sCpNvvrEb1A==}
'@types/qs@6.9.16':
resolution: {integrity: sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==}
'@types/resize-observer-browser@0.1.11': '@types/resize-observer-browser@0.1.11':
resolution: {integrity: sha512-cNw5iH8JkMkb3QkCoe7DaZiawbDQEUX8t7iuQaRTyLOyQCR2h+ibBD4GJt7p5yhUHrlOeL7ZtbxNHeipqNsBzQ==} resolution: {integrity: sha512-cNw5iH8JkMkb3QkCoe7DaZiawbDQEUX8t7iuQaRTyLOyQCR2h+ibBD4GJt7p5yhUHrlOeL7ZtbxNHeipqNsBzQ==}
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@types/web-bluetooth@0.0.20': '@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
@@ -1425,20 +1459,20 @@ packages:
'@vue/reactivity@3.4.27': '@vue/reactivity@3.4.27':
resolution: {integrity: sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==} resolution: {integrity: sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==}
'@vue/reactivity@3.4.38': '@vue/reactivity@3.5.6':
resolution: {integrity: sha512-4vl4wMMVniLsSYYeldAKzbk72+D3hUnkw9z8lDeJacTxAkXeDAP1uE9xr2+aKIN0ipOL8EG2GPouVTH6yF7Gnw==} resolution: {integrity: sha512-shZ+KtBoHna5GyUxWfoFVBCVd7k56m6lGhk5e+J9AKjheHF6yob5eukssHRI+rzvHBiU1sWs/1ZhNbLExc5oYQ==}
'@vue/runtime-core@3.4.27': '@vue/runtime-core@3.4.27':
resolution: {integrity: sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==} resolution: {integrity: sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==}
'@vue/runtime-core@3.4.38': '@vue/runtime-core@3.5.6':
resolution: {integrity: sha512-21z3wA99EABtuf+O3IhdxP0iHgkBs1vuoCAsCKLVJPEjpVqvblwBnTj42vzHRlWDCyxu9ptDm7sI2ZMcWrQqlA==} resolution: {integrity: sha512-FpFULR6+c2lI+m1fIGONLDqPQO34jxV8g6A4wBOgne8eSRHP6PQL27+kWFIx5wNhhjkO7B4rgtsHAmWv7qKvbg==}
'@vue/runtime-dom@3.4.27': '@vue/runtime-dom@3.4.27':
resolution: {integrity: sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==} resolution: {integrity: sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==}
'@vue/runtime-dom@3.4.38': '@vue/runtime-dom@3.5.6':
resolution: {integrity: sha512-afZzmUreU7vKwKsV17H1NDThEEmdYI+GCAK/KY1U957Ig2NATPVjCROv61R19fjZNzMmiU03n79OMnXyJVN0UA==} resolution: {integrity: sha512-SDPseWre45G38ENH2zXRAHL1dw/rr5qp91lS4lt/nHvMr0MhsbCbihGAWLXNB/6VfFOJe2O+RBRkXU+CJF7/sw==}
'@vue/server-renderer@3.4.27': '@vue/server-renderer@3.4.27':
resolution: {integrity: sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==} resolution: {integrity: sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==}
@@ -1448,11 +1482,8 @@ packages:
'@vue/shared@3.4.27': '@vue/shared@3.4.27':
resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==} resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==}
'@vue/shared@3.4.34': '@vue/shared@3.5.6':
resolution: {integrity: sha512-x5LmiRLpRsd9KTjAB8MPKf0CDPMcuItjP0gbNqFCIgL1I8iYp4zglhj9w9FPCdIbHG2M91RVeIbArFfFTz9I3A==} resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==}
'@vue/shared@3.4.38':
resolution: {integrity: sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw==}
'@vueuse/components@10.10.0': '@vueuse/components@10.10.0':
resolution: {integrity: sha512-HiA10NQ9HJAGnju+8ZK4TyA8LIc0a6BnJmVWDa/k+TRhaYCVacSDU04k0BQ2otV+gghUDdwu98upf6TDRXpoeg==} resolution: {integrity: sha512-HiA10NQ9HJAGnju+8ZK4TyA8LIc0a6BnJmVWDa/k+TRhaYCVacSDU04k0BQ2otV+gghUDdwu98upf6TDRXpoeg==}
@@ -1826,6 +1857,11 @@ packages:
chart.js@3.9.1: chart.js@3.9.1:
resolution: {integrity: sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==} resolution: {integrity: sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==}
chartjs-plugin-annotation@2.2.1:
resolution: {integrity: sha512-RL9UtrFr2SXd7C47zD0MZqn6ZLgrcRt3ySC6cYal2amBdANcYB1QcwFXcpKWAYnO4SGJYRok7P5rKDDNgJMA/w==}
peerDependencies:
chart.js: '>=3.7.0'
check-error@1.0.3: check-error@1.0.3:
resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
@@ -1884,16 +1920,25 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color-support@1.1.3: color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true hasBin: true
color@3.2.1:
resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
colord@2.9.3: colord@2.9.3:
resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
colorette@2.0.20: colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
colorspace@1.1.4:
resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2264,6 +2309,9 @@ packages:
emoji-regex@9.2.2: emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
enabled@2.0.0:
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
encodeurl@1.0.2: encodeurl@1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2275,6 +2323,10 @@ packages:
resolution: {integrity: sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==} resolution: {integrity: sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
entities@3.0.1:
resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==}
engines: {node: '>=0.12'}
entities@4.5.0: entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
@@ -2419,6 +2471,9 @@ packages:
fastq@1.17.1: fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
fecha@4.2.3:
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
@@ -2450,6 +2505,9 @@ packages:
'@nuxt/kit': '@nuxt/kit':
optional: true optional: true
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
focus-trap@7.5.4: focus-trap@7.5.4:
resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==} resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==}
@@ -2846,6 +2904,9 @@ packages:
resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-arrayish@0.3.2:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
is-bigint@1.0.4: is-bigint@1.0.4:
resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==}
@@ -3146,6 +3207,9 @@ packages:
kolorist@1.8.0: kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
kuler@2.0.0:
resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
launch-editor@2.6.1: launch-editor@2.6.1:
resolution: {integrity: sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==} resolution: {integrity: sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==}
@@ -3171,6 +3235,9 @@ packages:
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@4.0.1:
resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==}
listhen@1.7.2: listhen@1.7.2:
resolution: {integrity: sha512-7/HamOm5YD9Wb7CFgAZkKgVPA96WwhcTQoqtm2VTZGVbVVn3IWKRBTgrU7cchA3Q8k9iCsG8Osoi9GX4JsGM9g==} resolution: {integrity: sha512-7/HamOm5YD9Wb7CFgAZkKgVPA96WwhcTQoqtm2VTZGVbVVn3IWKRBTgrU7cchA3Q8k9iCsG8Osoi9GX4JsGM9g==}
hasBin: true hasBin: true
@@ -3241,6 +3308,10 @@ packages:
lodash@4.17.21: lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
logform@2.6.1:
resolution: {integrity: sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA==}
engines: {node: '>= 12.0.0'}
loupe@2.3.7: loupe@2.3.7:
resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==}
@@ -3273,12 +3344,19 @@ packages:
resolution: {integrity: sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==} resolution: {integrity: sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==}
engines: {node: ^16.14.0 || >=18.0.0} engines: {node: ^16.14.0 || >=18.0.0}
markdown-it@13.0.2:
resolution: {integrity: sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==}
hasBin: true
mdn-data@2.0.28: mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
mdn-data@2.0.30: mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
mdurl@1.0.1:
resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==}
media-typer@0.3.0: media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3676,6 +3754,9 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
onetime@5.1.2: onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -3699,9 +3780,14 @@ packages:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
openai@4.47.2: openai@4.61.0:
resolution: {integrity: sha512-E3Wq9mYdDSLajmcJm9RO/lCegTKrQ7ilAkMbhob4UgGhTjHwIHI+mXNDNPl5+sGIUp2iVUkpoi772FjYa7JlqA==} resolution: {integrity: sha512-xkygRBRLIUumxzKGb1ug05pWmJROQsHkGuj/N6Jiw2dj0dI19JvbFpErSZKmJ/DA+0IvpcugZqCAyk8iLpyM6Q==}
hasBin: true hasBin: true
peerDependencies:
zod: ^3.23.8
peerDependenciesMeta:
zod:
optional: true
openapi-typescript@6.7.6: openapi-typescript@6.7.6:
resolution: {integrity: sha512-c/hfooPx+RBIOPM09GSxABOZhYPblDoyaGhqBkD/59vtpN21jEuWKDlM0KYTvqJVlSYjKs0tBcIdeXKChlSPtw==} resolution: {integrity: sha512-c/hfooPx+RBIOPM09GSxABOZhYPblDoyaGhqBkD/59vtpN21jEuWKDlM0KYTvqJVlSYjKs0tBcIdeXKChlSPtw==}
@@ -4253,6 +4339,10 @@ packages:
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -4347,6 +4437,9 @@ packages:
simple-git@3.24.0: simple-git@3.24.0:
resolution: {integrity: sha512-QqAKee9Twv+3k8IFOFfPB2hnk6as6Y6ACUpwCtQvRYBAes23Wv3SZlHVobAzqcE8gfsisCvPw3HGW3HYM+VYYw==} resolution: {integrity: sha512-QqAKee9Twv+3k8IFOFfPB2hnk6as6Y6ACUpwCtQvRYBAes23Wv3SZlHVobAzqcE8gfsisCvPw3HGW3HYM+VYYw==}
simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
sirv@2.0.4: sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -4429,6 +4522,9 @@ packages:
resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
stack-trace@0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -4575,6 +4671,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
text-hex@1.0.0:
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
text-table@0.2.0: text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -4629,6 +4728,10 @@ packages:
resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==}
engines: {node: '>=14'} engines: {node: '>=14'}
triple-beam@1.4.1:
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
engines: {node: '>= 14.0.0'}
ts-interface-checker@0.1.13: ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -4678,6 +4781,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@1.0.6:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
ufo@1.5.3: ufo@1.5.3:
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
@@ -5032,6 +5138,11 @@ packages:
vue-devtools-stub@0.1.0: vue-devtools-stub@0.1.0:
resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==}
vue-markdown-render@2.2.1:
resolution: {integrity: sha512-XkYnC0PMdbs6Vy6j/gZXSvCuOS0787Se5COwXlepRqiqPiunyCIeTPQAO2XnB4Yl04EOHXwLx5y6IuszMWSgyQ==}
peerDependencies:
vue: ^3.3.4
vue-observe-visibility@2.0.0-alpha.1: vue-observe-visibility@2.0.0-alpha.1:
resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==} resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==}
peerDependencies: peerDependencies:
@@ -5079,10 +5190,6 @@ packages:
typescript: typescript:
optional: true optional: true
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
web-streams-polyfill@4.0.0-beta.3: web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -5142,6 +5249,14 @@ packages:
wide-align@1.1.5: wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
winston-transport@4.7.1:
resolution: {integrity: sha512-wQCXXVgfv/wUPOfb2x0ruxzwkcZfxcktz6JIMUaPLmcNhO4bZTwA/WtDWK74xV3F2dKu8YadrFv0qhwYjVEwhA==}
engines: {node: '>= 12.0.0'}
winston@3.14.2:
resolution: {integrity: sha512-CO8cdpBB2yqzEf8v895L+GNKYJiEq8eKlHU38af3snQBQ+sdAIUepjMSguOIJC7ICbzm0ZI+Af2If4vIJrtmOg==}
engines: {node: '>= 12.0.0'}
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -5463,6 +5578,8 @@ snapshots:
dependencies: dependencies:
mime: 3.0.0 mime: 3.0.0
'@colors/colors@1.6.0': {}
'@csstools/selector-resolve-nested@1.1.0(postcss-selector-parser@6.1.0)': '@csstools/selector-resolve-nested@1.1.0(postcss-selector-parser@6.1.0)':
dependencies: dependencies:
postcss-selector-parser: 6.1.0 postcss-selector-parser: 6.1.0
@@ -5471,6 +5588,12 @@ snapshots:
dependencies: dependencies:
postcss-selector-parser: 6.1.0 postcss-selector-parser: 6.1.0
'@dabh/diagnostics@2.0.3':
dependencies:
colorspace: 1.1.4
enabled: 2.0.0
kuler: 2.0.0
'@egoist/tailwindcss-icons@1.8.0(tailwindcss@3.4.3)': '@egoist/tailwindcss-icons@1.8.0(tailwindcss@3.4.3)':
dependencies: dependencies:
'@iconify/utils': 2.1.23 '@iconify/utils': 2.1.23
@@ -6538,11 +6661,20 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.12.12 '@types/node': 20.12.12
'@types/linkify-it@5.0.0': {}
'@types/lodash@4.17.7': {} '@types/lodash@4.17.7': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/node-fetch@2.6.11': '@types/node-fetch@2.6.11':
dependencies: dependencies:
'@types/node': 18.19.33 '@types/node': 20.12.12
form-data: 4.0.0 form-data: 4.0.0
'@types/node@18.19.33': '@types/node@18.19.33':
@@ -6561,10 +6693,14 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.12.12 '@types/node': 20.12.12
'@types/qs@6.9.16': {}
'@types/resize-observer-browser@0.1.11': {} '@types/resize-observer-browser@0.1.11': {}
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/triple-beam@1.3.5': {}
'@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.20': {}
'@types/webidl-conversions@7.0.3': {} '@types/webidl-conversions@7.0.3': {}
@@ -6990,7 +7126,7 @@ snapshots:
'@volar/language-core': 1.11.1 '@volar/language-core': 1.11.1
'@volar/source-map': 1.11.1 '@volar/source-map': 1.11.1
'@vue/compiler-dom': 3.4.27 '@vue/compiler-dom': 3.4.27
'@vue/shared': 3.4.34 '@vue/shared': 3.5.6
computeds: 0.0.1 computeds: 0.0.1
minimatch: 9.0.4 minimatch: 9.0.4
muggle-string: 0.3.1 muggle-string: 0.3.1
@@ -7003,19 +7139,19 @@ snapshots:
dependencies: dependencies:
'@vue/shared': 3.4.27 '@vue/shared': 3.4.27
'@vue/reactivity@3.4.38': '@vue/reactivity@3.5.6':
dependencies: dependencies:
'@vue/shared': 3.4.38 '@vue/shared': 3.5.6
'@vue/runtime-core@3.4.27': '@vue/runtime-core@3.4.27':
dependencies: dependencies:
'@vue/reactivity': 3.4.27 '@vue/reactivity': 3.4.27
'@vue/shared': 3.4.27 '@vue/shared': 3.4.27
'@vue/runtime-core@3.4.38': '@vue/runtime-core@3.5.6':
dependencies: dependencies:
'@vue/reactivity': 3.4.38 '@vue/reactivity': 3.5.6
'@vue/shared': 3.4.38 '@vue/shared': 3.5.6
'@vue/runtime-dom@3.4.27': '@vue/runtime-dom@3.4.27':
dependencies: dependencies:
@@ -7023,11 +7159,11 @@ snapshots:
'@vue/shared': 3.4.27 '@vue/shared': 3.4.27
csstype: 3.1.3 csstype: 3.1.3
'@vue/runtime-dom@3.4.38': '@vue/runtime-dom@3.5.6':
dependencies: dependencies:
'@vue/reactivity': 3.4.38 '@vue/reactivity': 3.5.6
'@vue/runtime-core': 3.4.38 '@vue/runtime-core': 3.5.6
'@vue/shared': 3.4.38 '@vue/shared': 3.5.6
csstype: 3.1.3 csstype: 3.1.3
'@vue/server-renderer@3.4.27(vue@3.4.27(typescript@5.4.2))': '@vue/server-renderer@3.4.27(vue@3.4.27(typescript@5.4.2))':
@@ -7038,9 +7174,7 @@ snapshots:
'@vue/shared@3.4.27': {} '@vue/shared@3.4.27': {}
'@vue/shared@3.4.34': {} '@vue/shared@3.5.6': {}
'@vue/shared@3.4.38': {}
'@vueuse/components@10.10.0(vue@3.4.27(typescript@5.4.2))': '@vueuse/components@10.10.0(vue@3.4.27(typescript@5.4.2))':
dependencies: dependencies:
@@ -7429,6 +7563,10 @@ snapshots:
chart.js@3.9.1: {} chart.js@3.9.1: {}
chartjs-plugin-annotation@2.2.1(chart.js@3.9.1):
dependencies:
chart.js: 3.9.1
check-error@1.0.3: check-error@1.0.3:
dependencies: dependencies:
get-func-name: 2.0.2 get-func-name: 2.0.2
@@ -7487,12 +7625,27 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.2
color-support@1.1.3: {} color-support@1.1.3: {}
color@3.2.1:
dependencies:
color-convert: 1.9.3
color-string: 1.9.1
colord@2.9.3: {} colord@2.9.3: {}
colorette@2.0.20: {} colorette@2.0.20: {}
colorspace@1.1.4:
dependencies:
color: 3.2.1
text-hex: 1.0.0
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -7815,6 +7968,8 @@ snapshots:
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
enabled@2.0.0: {}
encodeurl@1.0.2: {} encodeurl@1.0.2: {}
encoding@0.1.13: encoding@0.1.13:
@@ -7827,6 +7982,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.2.1 tapable: 2.2.1
entities@3.0.1: {}
entities@4.5.0: {} entities@4.5.0: {}
env-paths@2.2.1: {} env-paths@2.2.1: {}
@@ -8038,6 +8195,8 @@ snapshots:
dependencies: dependencies:
reusify: 1.0.4 reusify: 1.0.4
fecha@4.2.3: {}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
dependencies: dependencies:
flat-cache: 3.2.0 flat-cache: 3.2.0
@@ -8069,6 +8228,8 @@ snapshots:
optionalDependencies: optionalDependencies:
'@nuxt/kit': 3.11.2(rollup@4.18.0) '@nuxt/kit': 3.11.2(rollup@4.18.0)
fn.name@1.1.0: {}
focus-trap@7.5.4: focus-trap@7.5.4:
dependencies: dependencies:
tabbable: 6.2.0 tabbable: 6.2.0
@@ -8531,6 +8692,8 @@ snapshots:
call-bind: 1.0.7 call-bind: 1.0.7
get-intrinsic: 1.2.4 get-intrinsic: 1.2.4
is-arrayish@0.3.2: {}
is-bigint@1.0.4: is-bigint@1.0.4:
dependencies: dependencies:
has-bigints: 1.0.2 has-bigints: 1.0.2
@@ -8829,6 +8992,8 @@ snapshots:
kolorist@1.8.0: {} kolorist@1.8.0: {}
kuler@2.0.0: {}
launch-editor@2.6.1: launch-editor@2.6.1:
dependencies: dependencies:
picocolors: 1.0.1 picocolors: 1.0.1
@@ -8854,6 +9019,10 @@ snapshots:
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
linkify-it@4.0.1:
dependencies:
uc.micro: 1.0.6
listhen@1.7.2: listhen@1.7.2:
dependencies: dependencies:
'@parcel/watcher': 2.4.1 '@parcel/watcher': 2.4.1
@@ -8924,6 +9093,15 @@ snapshots:
lodash@4.17.21: {} lodash@4.17.21: {}
logform@2.6.1:
dependencies:
'@colors/colors': 1.6.0
'@types/triple-beam': 1.3.5
fecha: 4.2.3
ms: 2.1.3
safe-stable-stringify: 2.5.0
triple-beam: 1.4.1
loupe@2.3.7: loupe@2.3.7:
dependencies: dependencies:
get-func-name: 2.0.2 get-func-name: 2.0.2
@@ -8973,10 +9151,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
markdown-it@13.0.2:
dependencies:
argparse: 2.0.1
entities: 3.0.1
linkify-it: 4.0.1
mdurl: 1.0.1
uc.micro: 1.0.6
mdn-data@2.0.28: {} mdn-data@2.0.28: {}
mdn-data@2.0.30: {} mdn-data@2.0.30: {}
mdurl@1.0.1: {}
media-typer@0.3.0: {} media-typer@0.3.0: {}
memory-pager@1.5.0: {} memory-pager@1.5.0: {}
@@ -9550,6 +9738,10 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
one-time@1.0.0:
dependencies:
fn.name: 1.1.0
onetime@5.1.2: onetime@5.1.2:
dependencies: dependencies:
mimic-fn: 2.1.0 mimic-fn: 2.1.0
@@ -9578,16 +9770,17 @@ snapshots:
is-docker: 2.2.1 is-docker: 2.2.1
is-wsl: 2.2.0 is-wsl: 2.2.0
openai@4.47.2(encoding@0.1.13): openai@4.61.0(encoding@0.1.13):
dependencies: dependencies:
'@types/node': 18.19.33 '@types/node': 18.19.33
'@types/node-fetch': 2.6.11 '@types/node-fetch': 2.6.11
'@types/qs': 6.9.16
abort-controller: 3.0.0 abort-controller: 3.0.0
agentkeepalive: 4.5.0 agentkeepalive: 4.5.0
form-data-encoder: 1.7.2 form-data-encoder: 1.7.2
formdata-node: 4.4.1 formdata-node: 4.4.1
node-fetch: 2.7.0(encoding@0.1.13) node-fetch: 2.7.0(encoding@0.1.13)
web-streams-polyfill: 3.3.3 qs: 6.12.1
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -10163,6 +10356,8 @@ snapshots:
safe-buffer@5.2.1: {} safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
sass@1.77.2: sass@1.77.2:
@@ -10284,6 +10479,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
simple-swizzle@0.2.2:
dependencies:
is-arrayish: 0.3.2
sirv@2.0.4: sirv@2.0.4:
dependencies: dependencies:
'@polka/url': 1.0.0-next.25 '@polka/url': 1.0.0-next.25
@@ -10366,6 +10565,8 @@ snapshots:
dependencies: dependencies:
minipass: 7.1.2 minipass: 7.1.2
stack-trace@0.0.10: {}
stackback@0.0.2: {} stackback@0.0.2: {}
standard-as-callback@2.1.0: {} standard-as-callback@2.1.0: {}
@@ -10549,6 +10750,8 @@ snapshots:
commander: 2.20.3 commander: 2.20.3
source-map-support: 0.5.21 source-map-support: 0.5.21
text-hex@1.0.0: {}
text-table@0.2.0: {} text-table@0.2.0: {}
thenify-all@1.6.0: thenify-all@1.6.0:
@@ -10590,6 +10793,8 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
triple-beam@1.4.1: {}
ts-interface-checker@0.1.13: {} ts-interface-checker@0.1.13: {}
tslib@2.6.2: {} tslib@2.6.2: {}
@@ -10629,6 +10834,8 @@ snapshots:
typescript@5.4.2: {} typescript@5.4.2: {}
uc.micro@1.0.6: {}
ufo@1.5.3: {} ufo@1.5.3: {}
ultrahtml@1.5.3: {} ultrahtml@1.5.3: {}
@@ -11028,8 +11235,8 @@ snapshots:
vue-chart-3@3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2)): vue-chart-3@3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2)):
dependencies: dependencies:
'@vue/runtime-core': 3.4.38 '@vue/runtime-core': 3.5.6
'@vue/runtime-dom': 3.4.38 '@vue/runtime-dom': 3.5.6
chart.js: 3.9.1 chart.js: 3.9.1
csstype: 3.1.3 csstype: 3.1.3
lodash-es: 4.17.21 lodash-es: 4.17.21
@@ -11041,6 +11248,11 @@ snapshots:
vue-devtools-stub@0.1.0: {} vue-devtools-stub@0.1.0: {}
vue-markdown-render@2.2.1(vue@3.4.27(typescript@5.4.2)):
dependencies:
markdown-it: 13.0.2
vue: 3.4.27(typescript@5.4.2)
vue-observe-visibility@2.0.0-alpha.1(vue@3.4.27(typescript@5.4.2)): vue-observe-visibility@2.0.0-alpha.1(vue@3.4.27(typescript@5.4.2)):
dependencies: dependencies:
vue: 3.4.27(typescript@5.4.2) vue: 3.4.27(typescript@5.4.2)
@@ -11098,8 +11310,6 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.4.2 typescript: 5.4.2
web-streams-polyfill@3.3.3: {}
web-streams-polyfill@4.0.0-beta.3: {} web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}
@@ -11164,6 +11374,26 @@ snapshots:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
winston-transport@4.7.1:
dependencies:
logform: 2.6.1
readable-stream: 3.6.2
triple-beam: 1.4.1
winston@3.14.2:
dependencies:
'@colors/colors': 1.6.0
'@dabh/diagnostics': 2.0.3
async: 3.2.5
is-stream: 2.0.1
logform: 2.6.1
one-time: 1.0.0
readable-stream: 3.6.2
safe-stable-stringify: 2.5.0
stack-trace: 0.0.10
triple-beam: 1.4.1
winston-transport: 4.7.1
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:

View File

@@ -9,6 +9,7 @@ export async function getUserProjectFromId(project_id: string, user: AuthContext
return project; return project;
} else { } else {
if (!user?.logged) return; if (!user?.logged) return;
if (!project_id) return;
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return; if (!project) return;
const [hasAccess, role] = await hasAccessToProject(user.id, project_id, project); const [hasAccess, role] = await hasAccessToProject(user.id, project_id, project);

View File

@@ -0,0 +1,46 @@
import winston from 'winston';
const { combine, timestamp, json, errors } = winston.format;
export const logger = winston.createLogger({
format: combine(
errors({ stack: true }),
timestamp({
format: 'DD-MM-YYYY hh:mm:ss'
}),
json()
),
exceptionHandlers: [
new winston.transports.File({ filename: 'winston-logs.ndjson' }),
new winston.transports.File({ filename: 'winston-exceptions.ndjson' }),
],
rejectionHandlers: [
new winston.transports.File({ filename: 'winston-logs.ndjson' }),
new winston.transports.File({ filename: 'winston-rejections.ndjson' }),
],
transports: [
new winston.transports.Console({
level: 'debug',
format: combine(
winston.format.colorize({ all: true }),
errors({ stack: true }),
timestamp({ format: 'DD-MM-YYYY hh:mm:ss' }),
winston.format.printf((info) => {
if (info instanceof Error) {
return `${info.timestamp} [${info.level}]: ${info.message}\n${info.stack}`;
} else {
return `${info.timestamp} [${info.level}]: ${info.message}`;
}
})
),
}),
new winston.transports.File({ filename: 'winston-logs.ndjson' }),
new winston.transports.File({
level: 'debug',
filename: 'winston-debug.ndjson'
})
]
});

View File

@@ -0,0 +1,30 @@
import type OpenAI from 'openai'
export type AIPlugin_TTool<T extends string> = (OpenAI.Chat.Completions.ChatCompletionTool & { function: { name: T } });
export type AIPlugin_TFunction<T extends string> = (...args: any[]) => any;
type AIPlugin_Constructor<Items extends string[]> = {
[Key in Items[number]]: {
tool: AIPlugin_TTool<Key>,
handler: AIPlugin_TFunction<Key>
}
}
export abstract class AIPlugin<Items extends string[] = []> {
constructor(public functions: AIPlugin_Constructor<Items>) { }
getTools() {
const keys = Object.keys(this.functions) as Items;
return keys.map((key: Items[number]) => { return this.functions[key].tool });
}
getHandlers() {
const keys = Object.keys(this.functions) as Items;
const result: Record<string, any> = {};
keys.forEach((key: Items[number]) => {
result[key] = this.functions[key].handler;
});
return result;
}
}

View File

@@ -0,0 +1,67 @@
import { AIPlugin } from "../Plugin";
export class AiComposableChart extends AIPlugin<['createComposableChart']> {
constructor() {
super({
'createComposableChart': {
handler: (data: { labels: string, points: number[] }) => {
return { ok: true };
},
tool: {
type: 'function',
function: {
name: 'createComposableChart',
description: 'Creates a chart based on the provided datasets',
parameters: {
type: 'object',
properties: {
labels: {
type: 'array',
items: { type: 'string' },
description: 'Labels for each data point in the chart'
},
title: {
type: 'string',
description: 'Title of the chart to let user understand what is displaying, not include dates'
},
datasets: {
type: 'array',
description: 'List of datasets',
items: {
type: 'object',
properties: {
chartType: {
type: 'string',
enum: ['line', 'bar'],
description: 'The type of chart to display the dataset, either "line" or "bar"'
},
points: {
type: 'array',
items: { type: 'number' },
description: 'Numerical values for each data point in the chart'
},
color: {
type: 'string',
description: 'Color used to represent the dataset in format "#RRGGBB"'
},
name: {
type: 'string',
description: 'Name of the dataset'
}
},
required: ['points', 'color', 'chartType', 'name'],
description: 'Data points and style information for the dataset'
}
}
},
required: ['labels', 'datasets', 'title']
}
}
}
}
})
}
}
export const AiComposableChartInstance = new AiComposableChart();

View File

@@ -0,0 +1,87 @@
import { EventModel } from "@schema/metrics/EventSchema";
import { AdvancedTimelineAggregationOptions, executeAdvancedTimelineAggregation, executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
import { Types } from "mongoose";
import { AIPlugin, AIPlugin_TTool } from "../Plugin";
const getEventsCountTool: AIPlugin_TTool<'getEventsCount'> = {
type: 'function',
function: {
name: 'getEventsCount',
description: 'Gets the number of events received on a date range, can also specify the event name and the metadata associated',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date including hours' },
to: { type: 'string', description: 'ISO string of end date including hours' },
name: { type: 'string', description: 'Name of the events to get' },
metadata: { type: 'object', description: 'Metadata of events to get' },
},
required: ['from', 'to']
}
}
}
const getEventsTimelineTool: AIPlugin_TTool<'getEventsTimeline'> = {
type: 'function',
function: {
name: 'getEventsTimeline',
description: 'Gets an array of date and count for events received on a date range. Should be used to create charts.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date including hours' },
to: { type: 'string', description: 'ISO string of end date including hours' },
name: { type: 'string', description: 'Name of the events to get' },
metadata: { type: 'object', description: 'Metadata of events to get' },
},
required: ['from', 'to']
}
}
}
export class AiEvents extends AIPlugin<['getEventsCount', 'getEventsTimeline']> {
constructor() {
super({
'getEventsCount': {
handler: async (data: { project_id: string, from?: string, to?: string, name?: string, metadata?: string }) => {
const query: any = {
project_id: data.project_id,
created_at: {
$gt: data.from ? new Date(data.from).getTime() : new Date(2023).getTime(),
$lt: data.to ? new Date(data.to).getTime() : new Date().getTime(),
}
}
if (data.metadata) query.metadata = data.metadata;
if (data.name) query.name = data.name;
const result = await EventModel.countDocuments(query);
return { count: result };
},
tool: getEventsCountTool
},
'getEventsTimeline': {
handler: async (data: { project_id: string, from: string, to: string, name?: string, metadata?: string }) => {
const query: AdvancedTimelineAggregationOptions & { customMatch: Record<string, any> } = {
projectId: new Types.ObjectId(data.project_id) as any,
model: EventModel,
from: data.from, to: data.to, slice: 'day',
customMatch: {}
}
if (data.metadata) query.customMatch.metadata = data.metadata;
if (data.name) query.customMatch.name = data.name;
const timelineData = await executeAdvancedTimelineAggregation(query);
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, 'day', data.from, data.to);
return { data: timelineFilledMerged };
},
tool: getEventsTimelineTool
}
})
}
}
export const AiEventsInstance = new AiEvents();

View File

@@ -0,0 +1,87 @@
import { VisitModel } from "@schema/metrics/VisitSchema";
import { AdvancedTimelineAggregationOptions, executeAdvancedTimelineAggregation, executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
import { Types } from "mongoose";
import { AIPlugin, AIPlugin_TTool } from "../Plugin";
const getVisitsCountsTool: AIPlugin_TTool<'getVisitsCount'> = {
type: 'function',
function: {
name: 'getVisitsCount',
description: 'Gets the number of visits received on a date range',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date including hours' },
to: { type: 'string', description: 'ISO string of end date including hours' },
website: { type: 'string', description: 'The website of the visits' },
page: { type: 'string', description: 'The page of the visit' }
},
required: ['from', 'to']
}
}
}
const getVisitsTimelineTool: AIPlugin_TTool<'getVisitsTimeline'> = {
type: 'function',
function: {
name: 'getVisitsTimeline',
description: 'Gets an array of date and count for events received on a date range. Should be used to create charts.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date including hours' },
to: { type: 'string', description: 'ISO string of end date including hours' },
website: { type: 'string', description: 'The website of the visits' },
page: { type: 'string', description: 'The page of the visit' }
},
required: ['from', 'to']
}
}
}
export class AiVisits extends AIPlugin<['getVisitsCount', 'getVisitsTimeline']> {
constructor() {
super({
'getVisitsCount': {
handler: async (data: { project_id: string, from?: string, to?: string, website?: string, page?: string }) => {
const query: any = {
project_id: data.project_id,
created_at: {
$gt: data.from ? new Date(data.from).getTime() : new Date(2023).getTime(),
$lt: data.to ? new Date(data.to).getTime() : new Date().getTime(),
}
}
if (data.website) query.website = data.website;
if (data.page) query.page = data.page;
const result = await VisitModel.countDocuments(query);
return { count: result };
},
tool: getVisitsCountsTool
},
'getVisitsTimeline': {
handler: async (data: { project_id: string, from: string, to: string, website?: string, page?: string }) => {
const query: AdvancedTimelineAggregationOptions & { customMatch: Record<string, any> } = {
projectId: new Types.ObjectId(data.project_id) as any,
model: VisitModel,
from: data.from, to: data.to, slice: 'day',
customMatch: {}
}
if (data.website) query.customMatch.website = data.website;
if (data.page) query.customMatch.page = data.page;
const timelineData = await executeAdvancedTimelineAggregation(query);
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, 'day', data.from, data.to);
return { data: timelineFilledMerged };
},
tool: getVisitsTimelineTool
}
})
}
}
export const AiVisitsInstance = new AiVisits();

View File

@@ -1,8 +1,5 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { AiChatModel } from "@schema/ai/AiChatSchema"; import { AiChatModel } from "@schema/ai/AiChatSchema";
import { sendMessageOnChat } from "~/server/services/AiService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {

View File

@@ -1,8 +1,7 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { AiChatModel } from "@schema/ai/AiChatSchema"; import { AiChatModel } from "@schema/ai/AiChatSchema";
import { sendMessageOnChat } from "~/server/services/AiService"; import type OpenAI from "openai";
import { getChartsInMessage } from "~/server/services/AiService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
@@ -19,11 +18,14 @@ export default defineEventHandler(async event => {
const chat = await AiChatModel.findOne({ _id: chat_id, project_id }); const chat = await AiChatModel.findOne({ _id: chat_id, project_id });
if (!chat) return; if (!chat) return;
const messages = chat.messages.filter(e => { return (chat.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[])
return (e.role == 'user' || (e.role == 'assistant' && e.content != undefined)) .filter(e => e.role === 'assistant' || e.role === 'user')
}).map(e => { .map(e => {
return { role: e.role, content: e.content } const charts = getChartsInMessage(e);
}); const content = e.content;
return { role: e.role, content, charts }
return messages; })
.filter(e=>{
return e.charts.length > 0 || e.content
})
}); });

View File

@@ -23,5 +23,6 @@ export default defineEventHandler(async event => {
if (chatsRemaining <= 0) return setResponseStatus(event, 400, 'CHAT_LIMIT_REACHED'); if (chatsRemaining <= 0) return setResponseStatus(event, 400, 'CHAT_LIMIT_REACHED');
const response = await sendMessageOnChat(text, project._id.toString(), chat_id); const response = await sendMessageOnChat(text, project._id.toString(), chat_id);
return response || 'Error getting response';
return response;
}); });

View File

@@ -1,51 +0,0 @@
import OpenAI from "openai";
import { EventModel } from "@schema/metrics/EventSchema";
export const AI_EventsFunctions = {
getEventsCount: ({ pid, from, to, name, metadata }: any) => {
return getEventsCountForAI(pid, from, to, name, metadata);
}
}
export const getEventsCountForAIDeclaration: OpenAI.Chat.Completions.ChatCompletionTool = {
type: 'function',
function: {
name: 'getEventsCount',
description: 'Gets the number of events received on a date range, can also specify the event name and the metadata associated',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date including hours' },
to: { type: 'string', description: 'ISO string of end date including hours' },
name: { type: 'string', description: 'Name of the events to get' },
metadata: { type: 'object', description: 'Metadata of events to get' },
},
required: ['from', 'to']
}
}
}
export const AI_EventsTools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
getEventsCountForAIDeclaration
]
export async function getEventsCountForAI(project_id: string, from?: string, to?: string, name?: string, metadata?: string) {
const query: any = {
project_id,
created_at: {
$gt: from ? new Date(from).getTime() : new Date(2023).getTime(),
$lt: to ? new Date(to).getTime() : new Date().getTime(),
}
}
if (metadata) query.metadata = metadata;
if (name) query.name = name;
const result = await EventModel.countDocuments(query);
return { count: result };
}

View File

@@ -1,14 +0,0 @@
import { VisitModel } from "@schema/metrics/VisitSchema";
export async function getVisitsCountFromDateRange(project_id: string, from?: string, to?: string) {
const result = await VisitModel.countDocuments({
project_id,
created_at: {
$gt: from ? new Date(from).getTime() : new Date(2023).getTime(),
$lt: to ? new Date(to).getTime() : new Date().getTime(),
}
});
return { count: result };
}

View File

@@ -0,0 +1,72 @@
import { createUserJwt } from '~/server/AuthManager';
import { UserModel } from '@schema/UserSchema';
import EmailService from '@services/EmailService';
const config = useRuntimeConfig();
export default defineEventHandler(async event => {
const { code } = getQuery(event);
console.log('CODE', code);
const redirect_uri = 'http://127.0.0.1:3000'
const res = await fetch(`https://github.com/login/oauth/access_token?client_id=${config.GITHUB_AUTH_CLIENT_ID}&client_secret=${config.GITHUB_AUTH_CLIENT_SECRET}&code=${code}&redirect_url=${redirect_uri}`, {
headers: {
"Accept": "application/json",
"Accept-Encoding": "application/json",
},
});
const data = await res.json();
const access_token = data.access_token;
console.log(data);
return sendRedirect(event,`http://127.0.0.1:3000/login?github_access_token=${access_token}`)
// const origin = event.headers.get('origin');
// const tokenResponse = await client.getToken({
// code: body.code,
// redirect_uri: origin || ''
// });
// const tokens = tokenResponse.tokens;
// const ticket = await client.verifyIdToken({
// idToken: tokens.id_token || '',
// audience: GOOGLE_AUTH_CLIENT_ID,
// });
// const payload = ticket.getPayload();
// if (!payload) return { error: true, access_token: '' };
// const user = await UserModel.findOne({ email: payload.email });
// if (user) return { error: false, access_token: createUserJwt({ email: user.email, name: user.name }) }
// const newUser = new UserModel({
// email: payload.email,
// given_name: payload.given_name,
// name: payload.name,
// locale: payload.locale,
// picture: payload.picture,
// created_at: Date.now()
// });
// const savedUser = await newUser.save();
// setImmediate(() => {
// console.log('SENDING WELCOME EMAIL TO', payload.email);
// if (payload.email) EmailService.sendWelcomeEmail(payload.email);
// });
// return { error: false, access_token: createUserJwt({ email: savedUser.email, name: savedUser.name }) }
});

View File

@@ -29,7 +29,7 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found'); if (!project) return setResponseStatus(event, 400, 'Project not found');
if (project.owner.toString() != userData.id) { if (project.owner.toString() != userData.id) {
return setResponseStatus(event, 400, 'You are not the owner'); return setResponseStatus(event, 400, 'You are not the owner');
} }

View File

@@ -2,7 +2,7 @@ import { EventModel } from "@schema/metrics/EventSchema";
import { getTimeline } from "./generic"; import { getTimeline } from "./generic";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService"; import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService"; import { executeTimelineAggregation, fillAndMergeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event); const project_id = getRequestProjectId(event);
@@ -27,7 +27,7 @@ export default defineEventHandler(async event => {
model: EventModel, model: EventModel,
from, to, slice from, to, slice
}); });
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice); const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged; return timelineFilledMerged;
}); });

View File

@@ -1,9 +1,7 @@
import { getTimeline } from "./generic";
import { VisitModel } from "@schema/metrics/VisitSchema"; import { VisitModel } from "@schema/metrics/VisitSchema";
import DateService from "@services/DateService";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService"; import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { executeAdvancedTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService"; import { executeAdvancedTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event); const project_id = getRequestProjectId(event);
@@ -31,7 +29,7 @@ export default defineEventHandler(async event => {
referrer referrer
} }
}); });
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice); const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged; return timelineFilledMerged;
}); });

View File

@@ -2,7 +2,7 @@ import { getTimeline } from "./generic";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService"; import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { SessionModel } from "@schema/metrics/SessionSchema"; import { SessionModel } from "@schema/metrics/SessionSchema";
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService"; import { executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event); const project_id = getRequestProjectId(event);
@@ -28,7 +28,7 @@ export default defineEventHandler(async event => {
model: SessionModel, model: SessionModel,
from, to, slice from, to, slice
}); });
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice); const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged; return timelineFilledMerged;
}); });

View File

@@ -2,7 +2,7 @@ import { getTimeline } from "./generic";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService"; import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { SessionModel } from "@schema/metrics/SessionSchema"; import { SessionModel } from "@schema/metrics/SessionSchema";
import { executeAdvancedTimelineAggregation, executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService"; import { executeAdvancedTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event); const project_id = getRequestProjectId(event);
@@ -45,7 +45,7 @@ export default defineEventHandler(async event => {
count: { $divide: ["$duration", "$count"] } count: { $divide: ["$duration", "$count"] }
}, },
}); });
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice); const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from ,to);
return timelineFilledMerged; return timelineFilledMerged;
}); });

View File

@@ -2,7 +2,7 @@ import { VisitModel } from "@schema/metrics/VisitSchema";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService"; import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import DateService from "@services/DateService"; import DateService from "@services/DateService";
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService"; import { executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event); const project_id = getRequestProjectId(event);
@@ -28,11 +28,9 @@ export default defineEventHandler(async event => {
model: VisitModel, model: VisitModel,
from, to, slice, from, to, slice,
}); });
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice); const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged; return timelineFilledMerged;
}); });
}); });

View File

@@ -1,6 +0,0 @@
export default defineEventHandler(async event => {
console.log('TEST');
return;
});

View File

@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
if (!project_id) return; if (!project_id) return;
const user = getRequestUser(event); const user = getRequestUser(event);
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
const project = await getUserProjectFromId(project_id, user); const project = await getUserProjectFromId(project_id, user);
if (!project) return; if (!project) return;
if (project.owner.toString() != user.id) return setResponseStatus(event, 400, 'You cannot upgrade a project as guest');
const body = await readBody(event); const body = await readBody(event);
const { planId } = body; const { planId } = body;

View File

@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
if (!project_id) return; if (!project_id) return;
const user = getRequestUser(event); const user = getRequestUser(event);
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
const project = await getUserProjectFromId(project_id, user); const project = await getUserProjectFromId(project_id, user);
if (!project) return; if (!project) return;
if (project.owner.toString() != user.id) return setResponseStatus(event, 400, 'You cannot upgrade a project as guest');
const body = await readBody(event); const body = await readBody(event);
const { planId } = body; const { planId } = body;

View File

@@ -4,6 +4,8 @@ import type Event from 'stripe';
import { ProjectModel } from '@schema/ProjectSchema'; import { ProjectModel } from '@schema/ProjectSchema';
import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM'; import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM';
import { ProjectLimitModel } from '@schema/ProjectsLimits'; import { ProjectLimitModel } from '@schema/ProjectsLimits';
import EmailService from '@services/EmailService'
import { UserModel } from '@schema/UserSchema';
@@ -87,6 +89,14 @@ async function onPaymentOnetimeSuccess(event: Event.PaymentIntentSucceededEvent)
await addSubscriptionToProject(project._id.toString(), PLAN, subscription.id, subscription.current_period_start, subscription.current_period_end) await addSubscriptionToProject(project._id.toString(), PLAN, subscription.id, subscription.current_period_start, subscription.current_period_end)
const user = await UserModel.findOne({ _id: project.owner });
if (!user) return { ok: false, error: 'USER NOT EXIST FOR PROJECT' + project.id }
setTimeout(() => {
EmailService.sendPurchaseEmail(user.email, project.name);
}, 1);
return { ok: true }; return { ok: true };
} }
@@ -125,6 +135,15 @@ async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
await addSubscriptionToProject(project._id.toString(), PLAN, subscription_id, currentSubscription.current_period_start, currentSubscription.current_period_end) await addSubscriptionToProject(project._id.toString(), PLAN, subscription_id, currentSubscription.current_period_start, currentSubscription.current_period_end)
const user = await UserModel.findOne({ _id: project.owner });
if (!user) return { ok: false, error: 'USER NOT EXIST FOR PROJECT' + project.id }
setTimeout(() => {
if (PLAN.ID == 0) return;
EmailService.sendPurchaseEmail(user.email, project.name);
}, 1);
return { ok: true }; return { ok: true };

View File

@@ -18,6 +18,8 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not exist'); if (!project) return setResponseStatus(event, 400, 'Project not exist');
if (userData.id != project.owner.toString()) return setResponseStatus(event, 400, 'You cannot delete a project as guest');
const projects = await ProjectModel.countDocuments({ owner: userData.id }); const projects = await ProjectModel.countDocuments({ owner: userData.id });
if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project'); if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project');

View File

@@ -2,39 +2,47 @@ import mongoose from "mongoose";
import { Redis } from "~/server/services/CacheService"; import { Redis } from "~/server/services/CacheService";
import EmailService from '@services/EmailService'; import EmailService from '@services/EmailService';
import StripeService from '~/server/services/StripeService'; import StripeService from '~/server/services/StripeService';
import { anomalyLoop } from "./services/AnomalyService";
import { logger } from "./Logger";
const config = useRuntimeConfig(); const config = useRuntimeConfig();
let connection: mongoose.Mongoose; let connection: mongoose.Mongoose;
export default async () => { export default async () => {
console.log('[SERVER] Initializing'); logger.info('[SERVER] Initializing');
if (config.EMAIL_SERVICE) { if (config.EMAIL_SERVICE) {
EmailService.init(config.BREVO_API_KEY); EmailService.init(config.BREVO_API_KEY);
console.log('[EMAIL] Initialized') logger.info('[EMAIL] Initialized');
} }
if (config.STRIPE_SECRET) { if (config.STRIPE_SECRET) {
StripeService.init(config.STRIPE_SECRET, config.STRIPE_WH_SECRET, false); StripeService.init(config.STRIPE_SECRET, config.STRIPE_WH_SECRET, false);
console.log('[STRIPE] Initialized') logger.info('[STRIPE] Initialized');
} else { } else {
StripeService.disable(); StripeService.disable();
console.log('[STRIPE] No stripe key - Disabled mode') logger.warn('[STRIPE] No stripe key - Disabled mode');
} }
if (!connection || connection.connection.readyState == mongoose.ConnectionStates.disconnected) { if (!connection || connection.connection.readyState == mongoose.ConnectionStates.disconnected) {
console.log('[DATABASE] Connecting'); logger.info('[DATABASE] Connecting');
connection = await mongoose.connect(config.MONGO_CONNECTION_STRING); connection = await mongoose.connect(config.MONGO_CONNECTION_STRING);
console.log('[DATABASE] Connected'); logger.info('[DATABASE] Connected');
} }
console.log('[REDIS] Connecting'); logger.info('[REDIS] Connecting');
await Redis.init(); await Redis.init();
console.log('[REDIS] Connected'); logger.info('[REDIS] Connected');
console.log('[SERVER] Completed'); logger.info('[SERVER] Completed');
logger.warn('[ANOMALY LOOP] Disabled');
// anomalyLoop();
}; };

View File

@@ -0,0 +1,7 @@
import { logger } from "../Logger"
export default defineEventHandler(async (event) => {
const start = Date.now();
event.context['performance-start'] = start.toString();
});

View File

@@ -24,29 +24,21 @@ async function authorizationMiddleware(event: H3Event<EventHandlerRequest>) {
const authorization = event.headers.get('Authorization'); const authorization = event.headers.get('Authorization');
if (!authorization) { if (!authorization) {
event.context.auth = { logged: false, }
event.context.auth = { logged: false }
} else { } else {
const [type, token] = authorization.split(' '); const [type, token] = authorization.split(' ');
const valid = readUserJwt(token); const valid = readUserJwt(token);
if (!valid) return event.context.auth = { logged: false } if (!valid) return event.context.auth = { logged: false }
const user = await UserModel.findOne({ email: valid.email }) const user = await UserModel.findOne({ email: valid.email })
if (!user) return event.context.auth = { logged: false }; if (!user) return event.context.auth = { logged: false };
const premium: any = null;//await PremiumModel.findOne({ user_id: user.id });
const roles: string[] = []; const roles: string[] = [];
if (premium && premium.ends_at.getTime() < Date.now()) {
// await PremiumModel.deleteOne({ user_id: user.id });
} else if (premium) {
roles.push('PREMIUM');
roles.push('PREMIUM_' + premium.type);
}
if (ADMIN_EMAILS.includes(user.email)) { if (ADMIN_EMAILS.includes(user.email)) {
roles.push('ADMIN'); roles.push('ADMIN');
} }
@@ -61,6 +53,7 @@ async function authorizationMiddleware(event: H3Event<EventHandlerRequest>) {
}, },
id: user._id.toString() id: user._id.toString()
} }
event.context.auth = authContext; event.context.auth = authContext;
} }

View File

@@ -0,0 +1,28 @@
import { logger } from "../Logger"
export default defineEventHandler(async (event) => {
const ip = getRequestAddress(event);
const user = getRequestUser(event);
event.node.res.on('finish', () => {
if (!event.context['performance-start']) return;
const start = parseInt(event.context['performance-start']);
if (isNaN(start)) return;
const end = Date.now();
const duration = (end - start);
if (!user) {
logger.debug('Request without user', { path: event.path, method: event.method, ip, duration });
} else if (!user.logged) {
logger.debug('Request as guest', { path: event.path, method: event.method, ip, duration });
} else {
logger.debug(`(${duration}ms) [${event.method}] ${event.path} { ${user.user.email} }`, { ip });
}
// event.node.res.setHeader('X-Total-Response-Time', `${duration.toFixed(2)} ms`);
});
})

View File

@@ -1,59 +1,35 @@
import { getVisitsCountFromDateRange } from '~/server/api/ai/functions/AI_Visits';
import OpenAI from "openai"; import OpenAI from "openai";
import { AiChatModel } from '@schema/ai/AiChatSchema'; import { AiChatModel } from '@schema/ai/AiChatSchema';
import { AI_EventsFunctions, AI_EventsTools } from '../api/ai/functions/AI_Events';
import { ProjectCountModel } from '@schema/ProjectsCounts'; import { ProjectCountModel } from '@schema/ProjectsCounts';
import { ProjectLimitModel } from '@schema/ProjectsLimits'; import { ProjectLimitModel } from '@schema/ProjectsLimits';
import { AiEventsInstance } from '../ai/functions/AI_Events';
import { AiVisitsInstance } from '../ai/functions/AI_Visits';
import { AiComposableChartInstance } from '../ai/functions/AI_ComposableChart';
const { AI_ORG, AI_PROJECT, AI_KEY } = useRuntimeConfig(); const { AI_ORG, AI_PROJECT, AI_KEY } = useRuntimeConfig();
const OPENAI_MODEL: OpenAI.Chat.ChatModel = 'gpt-4o-mini';
const openai = new OpenAI({ const openai = new OpenAI({
organization: AI_ORG, organization: AI_ORG,
project: AI_PROJECT, project: AI_PROJECT,
apiKey: AI_KEY apiKey: AI_KEY
}); });
// const get_current_date: OpenAI.Chat.Completions.ChatCompletionTool = {
// type: 'function',
// function: {
// name: 'get_current_date',
// description: 'Gets the current date as ISO string',
// }
// }
const get_visits_count_Schema: OpenAI.Chat.Completions.ChatCompletionTool = {
type: 'function',
function: {
name: 'get_visits_count',
description: 'Gets the number of visits received on a date range',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date including hours' },
to: { type: 'string', description: 'ISO string of end date including hours' }
},
required: ['from', 'to']
}
}
}
const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
get_visits_count_Schema, ...AiVisitsInstance.getTools(),
...AI_EventsTools ...AiEventsInstance.getTools(),
...AiComposableChartInstance.getTools()
] ]
const functions: any = { const functions: any = {
get_current_date: async ({ }) => { ...AiVisitsInstance.getHandlers(),
return new Date().toISOString(); ...AiEventsInstance.getHandlers(),
}, ...AiComposableChartInstance.getHandlers()
get_visits_count: async ({ pid, from, to }: any) => {
return await getVisitsCountFromDateRange(pid, from, to);
},
...AI_EventsFunctions
} }
@@ -81,6 +57,14 @@ async function setChatTitle(title: string, chat_id?: string) {
await AiChatModel.updateOne({ _id: chat_id }, { title }); await AiChatModel.updateOne({ _id: chat_id }, { title });
} }
export function getChartsInMessage(message: OpenAI.Chat.Completions.ChatCompletionMessageParam) {
if (message.role != 'assistant') return [];
if (!message.tool_calls) return [];
if (message.tool_calls.length == 0) return [];
return message.tool_calls.filter(e => e.function.name === 'createComposableChart').map(e => e.function.arguments);
}
export async function sendMessageOnChat(text: string, pid: string, initial_chat_id?: string) { export async function sendMessageOnChat(text: string, pid: string, initial_chat_id?: string) {
@@ -92,7 +76,8 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_
messages.push(...chatMessages); messages.push(...chatMessages);
} else { } else {
const roleMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = { const roleMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = {
role: 'system', content: "Today is " + new Date().toISOString() role: 'system',
content: + "Today is " + new Date().toISOString()
} }
messages.push(roleMessage); messages.push(roleMessage);
await addMessageToChat(roleMessage, chat_id); await addMessageToChat(roleMessage, chat_id);
@@ -100,43 +85,36 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_
await setChatTitle(text.substring(0, 110), chat_id); await setChatTitle(text.substring(0, 110), chat_id);
} }
const userMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = { const userMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = { role: 'user', content: text }
role: 'user', content: text
}
messages.push(userMessage); messages.push(userMessage);
await addMessageToChat(userMessage, chat_id); await addMessageToChat(userMessage, chat_id);
let response = await openai.chat.completions.create({ model: 'gpt-3.5-turbo', messages, n: 1, tools }); let response = await openai.chat.completions.create({ model: OPENAI_MODEL, messages, n: 1, tools });
let responseMessage = response.choices[0].message; const chartsData: string[][] = [];
let toolCalls = responseMessage.tool_calls;
await addMessageToChat(responseMessage, chat_id); while ((response.choices[0].message.tool_calls?.length || 0) > 0) {
messages.push(responseMessage); await addMessageToChat(response.choices[0].message, chat_id);
messages.push(response.choices[0].message);
if (response.choices[0].message.tool_calls) {
console.log('Tools to call', response.choices[0].message.tool_calls.length);
chartsData.push(getChartsInMessage(response.choices[0].message));
if (toolCalls) { for (const toolCall of response.choices[0].message.tool_calls) {
console.log({ toolCalls: toolCalls.length }); const functionName = toolCall.function.name;
for (const toolCall of toolCalls) { console.log('Calling tool function', functionName);
const functionName = toolCall.function.name; const functionToCall = functions[functionName];
const functionToCall = functions[functionName]; const functionArgs = JSON.parse(toolCall.function.arguments);
const functionArgs = JSON.parse(toolCall.function.arguments); const functionResponse = await functionToCall({ project_id: pid, ...functionArgs });
console.log('CALLING FUNCTION', functionName, 'WITH PARAMS', functionArgs); messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) });
const functionResponse = await functionToCall({ pid, ...functionArgs }); await addMessageToChat({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }, chat_id);
console.log('RESPONSE FUNCTION', functionName, 'WITH VALUE', functionResponse); }
messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) });
await addMessageToChat({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }, chat_id);
} }
response = await openai.chat.completions.create({ model: 'gpt-4o', messages, n: 1, tools }); response = await openai.chat.completions.create({ model: OPENAI_MODEL, messages, n: 1, tools });
responseMessage = response.choices[0].message;
toolCalls = responseMessage.tool_calls;
await addMessageToChat(responseMessage, chat_id);
} }
await addMessageToChat(response.choices[0].message, chat_id);
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { ai_messages: 1 } }) await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { ai_messages: 1 } })
return { content: response.choices[0].message.content, charts: chartsData.filter(e => e.length > 0).flat() };
return responseMessage.content;
} }

View File

@@ -0,0 +1,152 @@
import mongoose from "mongoose";
import { executeTimelineAggregation } from "./TimelineService";
import { VisitModel } from "@schema/metrics/VisitSchema";
import { AnomalyDomainModel } from '@schema/anomalies/AnomalyDomainSchema';
import { AnomalyVisitModel } from '@schema/anomalies/AnomalyVisitSchema';
import { AnomalyEventsModel } from '@schema/anomalies/AnomalyEventsSchema';
import { EventModel } from "@schema/metrics/EventSchema";
import EmailService from "@services/EmailService";
import * as url from 'url';
import { ProjectModel } from "@schema/ProjectSchema";
import { UserModel } from "@schema/UserSchema";
type TAvgInput = { _id: string, count: number }
const anomalyData = { minutes: 0 }
async function anomalyCheckAll() {
const start = performance.now();
console.log('[ANOMALY] START ANOMALY CHECK');
const projects = await ProjectModel.find({}, { _id: 1 });
for (const project of projects) {
await findAnomalies(project.id);
}
const end = performance.now() - start;
console.log('END ANOMALY CHECK', end, 'ms');
}
export function anomalyLoop() {
if (anomalyData.minutes == 60 * 12) {
anomalyCheckAll();
anomalyData.minutes = 0;
}
anomalyData.minutes++;
setTimeout(() => anomalyLoop(), 1000 * 60);
}
function movingAverageAnomaly(visits: TAvgInput[], windowSize: number, threshold: number): TAvgInput[] {
const anomalies: TAvgInput[] = [];
for (let i = windowSize; i < visits.length; i++) {
const window = visits.slice(i - windowSize, i);
const mean = window.reduce((a, b) => a + b.count, 0) / window.length;
const stdDev = Math.sqrt(window.reduce((sum, visit) => sum + Math.pow(visit.count - mean, 2), 0) / window.length);
const currentVisit = visits[i];
if (Math.abs(currentVisit.count - mean) > threshold * stdDev) {
if (currentVisit.count <= mean) continue;
anomalies.push(currentVisit);
}
}
return anomalies;
}
function getUrlFromString(str: string) {
const res = str.startsWith('http') ? str : 'http://' + str;
return res;
}
export async function findAnomalies(project_id: string) {
const THRESHOLD = 6;
const WINDOW_SIZE = 14;
const pid = new mongoose.Types.ObjectId(project_id) as any;
const from = Date.now() - 1000 * 60 * 60 * 24 * 30;
const to = Date.now() - 1000 * 60 * 60 * 24;
const visitsTimelineData = await executeTimelineAggregation({
projectId: pid,
model: VisitModel,
from, to, slice: 'day'
});
const eventsTimelineData = await executeTimelineAggregation({
projectId: pid,
model: EventModel,
from, to, slice: 'day'
});
const websites: { _id: string, count: number }[] = await VisitModel.aggregate([
{ $match: { project_id: pid, created_at: { $gte: new Date(from), $lte: new Date(to) } }, },
{ $group: { _id: "$website", count: { $sum: 1, } } }
]);
const detectedWebsites: string[] = [];
if (websites.length > 0) {
const rootWebsite = websites.reduce((a, e) => {
return a.count > e.count ? a : e;
});
const rootDomain = new url.URL(getUrlFromString(rootWebsite._id)).hostname;
for (const website of websites) {
const websiteDomain = new url.URL(getUrlFromString(website._id)).hostname;
if (websiteDomain === 'localhost') continue;
if (websiteDomain === '127.0.0.1') continue;
if (websiteDomain === '0.0.0.0') continue;
if (!websiteDomain.includes(rootDomain)) { detectedWebsites.push(website._id); }
}
}
const visitAnomalies = movingAverageAnomaly(visitsTimelineData, WINDOW_SIZE, THRESHOLD);
const eventAnomalies = movingAverageAnomaly(eventsTimelineData, WINDOW_SIZE, THRESHOLD);
const shouldSendMail = {
visitsEvents: false,
domains: false
}
for (const visit of visitAnomalies) {
const anomalyAlreadyExist = await AnomalyVisitModel.findOne({ visitDate: visit._id }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyVisitModel.create({ project_id: pid, visitDate: visit._id, created_at: Date.now() });
shouldSendMail.visitsEvents = true;
}
for (const event of eventAnomalies) {
const anomalyAlreadyExist = await AnomalyEventsModel.findOne({ eventDate: event._id }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyEventsModel.create({ project_id: pid, eventDate: event._id, created_at: Date.now() });
shouldSendMail.visitsEvents = true;
}
for (const website of detectedWebsites) {
const anomalyAlreadyExist = await AnomalyDomainModel.findOne({ domain: website }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyDomainModel.create({ project_id: pid, domain: website, created_at: Date.now() });
shouldSendMail.domains = true;
}
const project = await ProjectModel.findById(pid);
if (!project) return { ok: false, error: 'Cannot find project with id ' + pid.toString() }
const user = await UserModel.findById(project.owner);
if (!user) return { ok: false, error: 'Cannot find user with id ' + project.owner.toString() }
if (shouldSendMail.visitsEvents === true) {
await EmailService.sendAnomalyVisitsEventsEmail(user.email, project.name);
}
if (shouldSendMail.domains === true) {
await EmailService.sendAnomalyDomainEmail(user.email, project.name);
}
return { ok: true };
}

View File

@@ -4,15 +4,14 @@ import { createClient } from 'redis';
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
export const DATA_EXPIRE_TIME = 30; export const DATA_EXPIRE_TIME = 30;
export const TIMELINE_EXPIRE_TIME = 60 * 5; export const TIMELINE_EXPIRE_TIME = 60;
export const COUNTS_EXPIRE_TIME = 10; export const COUNTS_EXPIRE_TIME = 10;
export const COUNTS_OLD_SESSIONS_EXPIRE_TIME = 60 * 5;
export const COUNTS_SESSIONS_EXPIRE_TIME = 60 * 3; export const COUNTS_SESSIONS_EXPIRE_TIME = 60 * 3;
export const EVENT_NAMES_EXPIRE_TIME = 60; export const EVENT_NAMES_EXPIRE_TIME = 60;
export const EVENT_METADATA_FIELDS_EXPIRE_TIME = 120; export const EVENT_METADATA_FIELDS_EXPIRE_TIME = 30;
export class Redis { export class Redis {

View File

@@ -1,4 +1,4 @@
import { getPlanFromId, getPlanFromTag } from '@data/PREMIUM'; import { getPlanFromId, getPlanFromTag, PREMIUM_TAG } from '@data/PREMIUM';
import Stripe from 'stripe'; import Stripe from 'stripe';
class StripeService { class StripeService {
@@ -133,9 +133,23 @@ class StripeService {
return deleted; return deleted;
} }
async createOneTimeCoupon() { async createStripeCode(plan: PREMIUM_TAG) {
if (this.disabledMode) return; if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized'); if (!this.stripe) throw Error('Stripe not initialized');
const INCUBATION_COUPON = 'sDD7Weh3';
if (plan === 'INCUBATION') {
await this.stripe.promotionCodes.create({
coupon: INCUBATION_COUPON,
active: true,
code: 'TESTCACCA1',
max_redemptions: 1,
})
return true;
}
return false;
} }
async createOneTimeSubscriptionDummy(customer_id: string, planId: number) { async createOneTimeSubscriptionDummy(customer_id: string, planId: number) {

View File

@@ -61,4 +61,10 @@ export function fillAndMergeTimelineAggregation(timeline: { _id: string, count:
const filledDates = DateService.fillDates(timeline.map(e => e._id), slice); const filledDates = DateService.fillDates(timeline.map(e => e._id), slice);
const merged = DateService.mergeFilledDates(filledDates, timeline, '_id', slice, { count: 0 }); const merged = DateService.mergeFilledDates(filledDates, timeline, '_id', slice, { count: 0 });
return merged; return merged;
}
export function fillAndMergeTimelineAggregationV2(timeline: { _id: string, count: number }[], slice: Slice, from: string, to: string) {
const filledDates = DateService.createBetweenDates(from, to, slice);
const merged = DateService.mergeFilledDates(filledDates.dates, timeline, '_id', slice, { count: 0 });
return merged;
} }

View File

@@ -6,7 +6,7 @@ import { RedisStreamService } from "@services/RedisStreamService";
const router = Router(); const router = Router();
const allowAnyType = () => true; const allowAnyType = () => true;
const jsonOptions = { limit: '5mb', type: allowAnyType } const jsonOptions = { limit: '25kb', type: allowAnyType }
const streamName = requireEnv('STREAM_NAME'); const streamName = requireEnv('STREAM_NAME');

View File

@@ -9,7 +9,7 @@ const app = express();
app.use(cors()); app.use(cors());
const allowAnyType = () => true; const allowAnyType = () => true;
const jsonOptions = { limit: '5mb', type: allowAnyType } const jsonOptions = { limit: '25kb', type: allowAnyType }
const streamName = requireEnv('STREAM_NAME'); const streamName = requireEnv('STREAM_NAME');

View File

@@ -23,7 +23,8 @@ export type PREMIUM_DATA = {
AI_MESSAGE_LIMIT: number, AI_MESSAGE_LIMIT: number,
PRICE: string, PRICE: string,
PRICE_TEST: string, PRICE_TEST: string,
ID: number ID: number,
COST: number
} }
export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = { export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
@@ -32,98 +33,116 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
COUNT_LIMIT: 5_000, COUNT_LIMIT: 5_000,
AI_MESSAGE_LIMIT: 10, AI_MESSAGE_LIMIT: 10,
PRICE: 'price_1POKCMB2lPUiVs9VLe3QjIHl', PRICE: 'price_1POKCMB2lPUiVs9VLe3QjIHl',
PRICE_TEST: 'price_1PNbHYB2lPUiVs9VZP32xglF' PRICE_TEST: 'price_1PNbHYB2lPUiVs9VZP32xglF',
COST: 0
}, },
PLAN_1: { PLAN_1: {
ID: 1, ID: 1,
COUNT_LIMIT: 150_000, COUNT_LIMIT: 150_000,
AI_MESSAGE_LIMIT: 100, AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1POKCOB2lPUiVs9VC13s2rQw', PRICE: 'price_1POKCOB2lPUiVs9VC13s2rQw',
PRICE_TEST: 'price_1PNZjVB2lPUiVs9VrsTbJL04' PRICE_TEST: 'price_1PNZjVB2lPUiVs9VrsTbJL04',
COST: 0
}, },
PLAN_2: { PLAN_2: {
ID: 2, ID: 2,
COUNT_LIMIT: 500_000, COUNT_LIMIT: 500_000,
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
}, },
CUSTOM_1: { CUSTOM_1: {
ID: 1001, ID: 1001,
COUNT_LIMIT: 10_000_000, COUNT_LIMIT: 10_000_000,
AI_MESSAGE_LIMIT: 100_000, AI_MESSAGE_LIMIT: 100_000,
PRICE: 'price_1POKZyB2lPUiVs9VMAY6jXTV', PRICE: 'price_1POKZyB2lPUiVs9VMAY6jXTV',
PRICE_TEST: '' PRICE_TEST: '',
COST: 0
}, },
INCUBATION: { INCUBATION: {
ID: 101, ID: 101,
COUNT_LIMIT: 50_000, COUNT_LIMIT: 50_000,
AI_MESSAGE_LIMIT: 30, AI_MESSAGE_LIMIT: 30,
PRICE: 'price_1PdsyzB2lPUiVs9V4J246Jw0', PRICE: 'price_1PdsyzB2lPUiVs9V4J246Jw0',
PRICE_TEST: '' PRICE_TEST: '',
COST: 499
}, },
ACCELERATION: { ACCELERATION: {
ID: 102, ID: 102,
COUNT_LIMIT: 150_000, COUNT_LIMIT: 150_000,
AI_MESSAGE_LIMIT: 100, AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1Pdt5bB2lPUiVs9VhkuCouEt', PRICE: 'price_1Pdt5bB2lPUiVs9VhkuCouEt',
PRICE_TEST: '' PRICE_TEST: '',
COST: 999
}, },
GROWTH: { GROWTH: {
ID: 103, ID: 103,
COUNT_LIMIT: 500_000, COUNT_LIMIT: 500_000,
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PdszrB2lPUiVs9VIdkT3thv', PRICE: 'price_1PdszrB2lPUiVs9VIdkT3thv',
PRICE_TEST: '' PRICE_TEST: '',
COST: 2999
}, },
EXPANSION: { EXPANSION: {
ID: 104, ID: 104,
COUNT_LIMIT: 1_000_000, COUNT_LIMIT: 1_000_000,
AI_MESSAGE_LIMIT: 5_000, AI_MESSAGE_LIMIT: 5_000,
PRICE: 'price_1Pdt0xB2lPUiVs9V0Rdt80Fe', PRICE: 'price_1Pdt0xB2lPUiVs9V0Rdt80Fe',
PRICE_TEST: '' PRICE_TEST: '',
COST: 5999
}, },
SCALING: { SCALING: {
ID: 105, ID: 105,
COUNT_LIMIT: 2_500_000, COUNT_LIMIT: 2_500_000,
AI_MESSAGE_LIMIT: 10_000, AI_MESSAGE_LIMIT: 10_000,
PRICE: 'price_1Pdt1UB2lPUiVs9VUmxntSwZ', PRICE: 'price_1Pdt1UB2lPUiVs9VUmxntSwZ',
PRICE_TEST: '' PRICE_TEST: '',
COST: 9999
}, },
UNICORN: { UNICORN: {
ID: 106, ID: 106,
COUNT_LIMIT: 5_000_000, COUNT_LIMIT: 5_000_000,
AI_MESSAGE_LIMIT: 20_000, AI_MESSAGE_LIMIT: 20_000,
PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G', PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G',
PRICE_TEST: '' PRICE_TEST: '',
COST: 14999
}, },
LIFETIME_GROWTH_ONETIME: { LIFETIME_GROWTH_ONETIME: {
ID: 2001, ID: 2001,
COUNT_LIMIT: 500_000, COUNT_LIMIT: 500_000,
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
}, },
GROWTH_DUMMY: { GROWTH_DUMMY: {
ID: 5001, ID: 5001,
COUNT_LIMIT: 500_000, COUNT_LIMIT: 500_000,
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
} }
} }
CustomPremiumPriceModel.find({}).then(custom_prices => { try {
for (const custom_price of custom_prices) {
PREMIUM_PLAN[custom_price.tag] = { CustomPremiumPriceModel.find({}).then(custom_prices => {
ID: custom_price.price_id, for (const custom_price of custom_prices) {
COUNT_LIMIT: custom_price.count_limit, PREMIUM_PLAN[custom_price.tag] = {
AI_MESSAGE_LIMIT: custom_price.ai_message_limit, ID: custom_price.price_id,
PRICE: custom_price.price, COUNT_LIMIT: custom_price.count_limit,
PRICE_TEST: custom_price.price_test || '' AI_MESSAGE_LIMIT: custom_price.ai_message_limit,
PRICE: custom_price.price,
PRICE_TEST: custom_price.price_test || ''
}
} }
} });
});
} catch (ex) {
}
export function getPlanFromTag(tag: PREMIUM_TAG) { export function getPlanFromTag(tag: PREMIUM_TAG) {

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyDomain = {
project_id: Schema.Types.ObjectId
domain: string,
created_at: Date
}
const AnomalyDomainSchema = new Schema<TAnomalyDomain>({
project_id: { type: Types.ObjectId, required: true },
domain: { type: String, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyDomainModel = model<TAnomalyDomain>('anomaly_domains', AnomalyDomainSchema);

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyEvents = {
project_id: Schema.Types.ObjectId
eventDate: Date,
created_at: Date
}
const AnomalyEventsSchema = new Schema<TAnomalyEvents>({
project_id: { type: Types.ObjectId, required: true },
eventDate: { type: Date, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyEventsModel = model<TAnomalyEvents>('anomaly_events', AnomalyEventsSchema);

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyVisit = {
project_id: Schema.Types.ObjectId
visitDate: Date,
created_at: Date
}
const AnomalyVisitSchema = new Schema<TAnomalyVisit>({
project_id: { type: Types.ObjectId, required: true },
visitDate: { type: Date, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyVisitModel = model<TAnomalyVisit>('anomaly_visits', AnomalyVisitSchema);

View File

@@ -102,6 +102,18 @@ class DateService {
} }
} }
createBetweenDates(from: string, to: string, slice: Slice) {
let start = dayjs(from);
const end = dayjs(to);
const filledDates: dayjs.Dayjs[] = [];
while (start.isBefore(end) || start.isSame(end)) {
filledDates.push(start);
start = start.add(1, slice);
}
return { dates: filledDates, from, to };
}
fillDates(dates: string[], slice: Slice) { fillDates(dates: string[], slice: Slice) {
const allDates: dayjs.Dayjs[] = []; const allDates: dayjs.Dayjs[] = [];
const firstDate = dayjs(dates.at(0)); const firstDate = dayjs(dates.at(0));
@@ -109,7 +121,7 @@ class DateService {
let currentDate = firstDate.clone(); let currentDate = firstDate.clone();
allDates.push(currentDate); allDates.push(currentDate);
while (currentDate.isBefore(lastDate, slice)) { while (currentDate.isBefore(lastDate, slice)) {
currentDate = currentDate.add(1, slice); currentDate = currentDate.add(1, slice);
allDates.push(currentDate); allDates.push(currentDate);
@@ -121,7 +133,7 @@ class DateService {
mergeFilledDates<T extends Record<string, any>, K extends keyof T>(dates: dayjs.Dayjs[], items: T[], dateField: K, slice: Slice, fillData: Omit<T, K>) { mergeFilledDates<T extends Record<string, any>, K extends keyof T>(dates: dayjs.Dayjs[], items: T[], dateField: K, slice: Slice, fillData: Omit<T, K>) {
const result = new Array<T>(); const result = new Array<T>();
for (const date of dates) { for (const date of dates) {
const item = items.find(e => dayjs(e[dateField]).isSame(date), slice); const item = items.find(e => dayjs(e[dateField]).isSame(date, slice));
result.push(item ?? { ...fillData, [dateField]: date.format() } as T); result.push(item ?? { ...fillData, [dateField]: date.format() } as T);
} }
return result; return result;

View File

@@ -3,6 +3,9 @@ import { WELCOME_EMAIL } from './email_templates/WelcomeEmail';
import { LIMIT_50_EMAIL } from './email_templates/Limit50Email'; import { LIMIT_50_EMAIL } from './email_templates/Limit50Email';
import { LIMIT_90_EMAIL } from './email_templates/Limit90Email'; import { LIMIT_90_EMAIL } from './email_templates/Limit90Email';
import { LIMIT_MAX_EMAIL } from './email_templates/LimitMaxEmail'; import { LIMIT_MAX_EMAIL } from './email_templates/LimitMaxEmail';
import { PURCHASE_EMAIL } from './email_templates/PurchaseEmail';
import { ANOMALY_VISITS_EVENTS_EMAIL } from './email_templates/AnomalyUsageEmail';
import { ANOMALY_DOMAIN_EMAIL } from './email_templates/AnomalyDomainEmail';
class EmailService { class EmailService {
@@ -17,7 +20,7 @@ class EmailService {
try { try {
const sendSmtpEmail = new SendSmtpEmail(); const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "You've reached 50% limit on Litlyx"; sendSmtpEmail.subject = "You've reached 50% limit on Litlyx";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" }; sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }]; sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = LIMIT_50_EMAIL sendSmtpEmail.htmlContent = LIMIT_50_EMAIL
@@ -36,7 +39,7 @@ class EmailService {
try { try {
const sendSmtpEmail = new SendSmtpEmail(); const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "You've reached 90% limit on Litlyx"; sendSmtpEmail.subject = "You've reached 90% limit on Litlyx";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" }; sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }]; sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = LIMIT_90_EMAIL sendSmtpEmail.htmlContent = LIMIT_90_EMAIL
.replace(/\[Project Name\]/, projectName) .replace(/\[Project Name\]/, projectName)
@@ -53,7 +56,7 @@ class EmailService {
try { try {
const sendSmtpEmail = new SendSmtpEmail(); const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "You've reached your limit on Litlyx!"; sendSmtpEmail.subject = "You've reached your limit on Litlyx!";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" }; sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }]; sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = LIMIT_MAX_EMAIL sendSmtpEmail.htmlContent = LIMIT_MAX_EMAIL
.replace(/\[Project Name\]/, projectName) .replace(/\[Project Name\]/, projectName)
@@ -70,7 +73,7 @@ class EmailService {
try { try {
const sendSmtpEmail = new SendSmtpEmail(); const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "Welcome to Litlyx!"; sendSmtpEmail.subject = "Welcome to Litlyx!";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "no-reply@litlyx.com" }; sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }]; sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = WELCOME_EMAIL; sendSmtpEmail.htmlContent = WELCOME_EMAIL;
await this.apiInstance.sendTransacEmail(sendSmtpEmail); await this.apiInstance.sendTransacEmail(sendSmtpEmail);
@@ -81,6 +84,57 @@ class EmailService {
} }
} }
async sendPurchaseEmail(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "Thank You for Upgrading Your Litlyx Plan!";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = PURCHASE_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();;
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
async sendAnomalyVisitsEventsEmail(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "🚨 Unexpected Activity Detected by our AI";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = ANOMALY_VISITS_EVENTS_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();;
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
async sendAnomalyDomainEmail(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "🚨 Anomaly detected by our AI";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = ANOMALY_DOMAIN_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();;
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
} }
const instance = new EmailService(); const instance = new EmailService();

View File

@@ -36,7 +36,11 @@ export class RedisStreamService {
setTimeout(() => this.readingLoop(options, processFunction), 1); setTimeout(() => this.readingLoop(options, processFunction), 1);
return; return;
} }
await processFunction(result); try {
await processFunction(result);
} catch (ex) {
console.error('Error on processing function');
}
RedisStreamService.processed++; RedisStreamService.processed++;
await new Promise(r => setTimeout(r, options.delay?.base || 100)); await new Promise(r => setTimeout(r, options.delay?.base || 100));
setTimeout(() => this.readingLoop(options, processFunction), 1); setTimeout(() => this.readingLoop(options, processFunction), 1);
@@ -46,10 +50,12 @@ export class RedisStreamService {
static async startReadingLoop(options: ReadingLoopOptions, processFunction: (content: Record<string, string>) => Promise<any>) { static async startReadingLoop(options: ReadingLoopOptions, processFunction: (content: Record<string, string>) => Promise<any>) {
setInterval(() => { setInterval(() => {
console.log('Processed:', (RedisStreamService.processed / 30).toFixed(), '/s'); if (RedisStreamService.processed > 0) {
RedisStreamService.processed = 0; console.log('Processed:', (RedisStreamService.processed / 30).toFixed(2), '/s');
RedisStreamService.processed = 0;
}
}, 30_000) }, 30_000)
try { try {
console.log('Start reading loop'); console.log('Start reading loop');
await this.client.xGroupCreate(options.streamName, 'broker', '0', { MKSTREAM: true, }); await this.client.xGroupCreate(options.streamName, 'broker', '0', { MKSTREAM: true, });

View File

@@ -0,0 +1,43 @@
export const ANOMALY_DOMAIN_EMAIL = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>❗️ Anomaly detected by our AI</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has an anomaly that our AI agent detected.</p>
<p>You can analyze a suspicious DNS on your Litlyx dashboard. We put a symbol next to each suspicious DNS to let users know something might be wrong!</p>
<h3>What can I do?</h3>
<p>To resolve this issue, you should reach out to the webmasters of the websites that have duplicated your content and request them to remove it or give you proper attribution (if available).</p>
<p>You can also use <a href="https://www.whois.com/whois/" style="color: #D32F2F; text-decoration: none;">https://www.whois.com/whois/</a> to get the contact details of the webmaster or domain owner.</p>
<p>If webmasters don't respond or cooperate, <strong>you can file a DMCA complaint here:</strong> <a href="https://support.google.com/legal/answer/3110420?hl=en" style="color: #D32F2F; text-decoration: none;">https://support.google.com/legal/answer/3110420?hl=en</a> <strong>with Google to request the removal of the duplicate content from their search results.</strong></p>
<h3>Please refer to this for more information:</h3>
<ul>
<li><a href="https://support.google.com/legal/answer/3110420?hl=en&sjid=14235884554806745995-AP&authuser=2" style="color: #D32F2F; text-decoration: none;">Report Content for Legal Reasons</a></li>
<li><a href="https://www.dmca.com/FAQ/How-can-I-get-a-webpage-removed-from-Google-search-results" style="color: #D32F2F; text-decoration: none;">How can I get a webpage removed from Google search results?</a></li>
</ul>
<p>Your safety is our main priority.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>
`

View File

@@ -0,0 +1,42 @@
export const ANOMALY_VISITS_EVENTS_EMAIL = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🚨 Unexpected Activity Detected by our AI</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> is receiving an unexpected amount of visits or events. This could indicate unusual activity that might require your attention.</p>
<p>If this spike in activity is expected, theres no need to worry. However, if you believe this could be unexpected or suspicious, we recommend taking a closer look at your data on the <strong>Litlyx Dashboard</strong>. You can analyze your recent traffic and event logs to identify any irregularities or potential issues.</p>
<h3>What can I do?</h3>
<p>To better understand the situation, you can:</p>
<ol>
<li>Review your traffic sources to see where the visits or events are coming from.</li>
<li>Check for any unexpected patterns, such as a high number of visits from unknown sources or abnormal event triggers.</li>
<li>Check your code to find bugs on a specific action that is triggered in loops.</li>
</ol>
<p>If you need help understanding this activity or have any concerns, feel free to reach out to our support team at <a href="mailto:help@litlyx.com" style="color: #D32F2F; text-decoration: none;"><strong>help@litlyx.com</strong></a>. We are here to assist you!</p>
<p><strong>Your safety and data integrity are our top priorities.</strong></p>
<p>Thank you for trusting Litlyx as your analytics tool!</p>
<p>Best regards,</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>
`

View File

@@ -0,0 +1,45 @@
export const PURCHASE_EMAIL = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thank You for Upgrading Your Litlyx Plan!</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<p>Dear User,</p>
<p>We are thrilled to inform you that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has successfully been upgraded to a higher plan! Thank you for choosing to elevate your experience with us and for believing in our project.</p>
<p>We appreciate your trust in Litlyx and are committed to providing you with the best analytics experience. Your support helps us to continually improve our platform and bring new features to make your analytics journey even better.</p>
<p>You can find your current plan details and download your invoices under <strong>Settings > Billing Tab</strong>.</p>
<h3>What does this mean for you?</h3>
<p>With your upgraded plan, you can now enjoy more data collection, advanced features, and additional benefits to enhance your data analysis capabilities:</p>
<ol>
<li>Access to more storage and increased data limits.</li>
<li>Advanced analytics tools like IP-Company Matching, download CSV for your raw data, AI insights, and more.</li>
<li>Priority support to help you make the most of your Litlyx experience on Slack or Discord!</li>
</ol>
<p>If you have any questions about your new plan or need assistance, feel free to reach out to our support team at <a href="mailto:help@litlyx.com" style="color: #28a745; text-decoration: none;"><strong>help@litlyx.com</strong></a>. Were here to help you make the most out of your upgraded plan!</p>
<p><strong>Thank you for using Litlyx every day as your analytics tool and for being a part of our journey.</strong></p>
<p>We look forward to continuing to support your growth and success!</p>
<p>Best regards,</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>
`

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Youve reached 50% limit on Litlyx</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<h2 style="color: #FF5733;">Youve reached 50% limit on Litlyx</h2>
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached 50% of its data collection limit for this month.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #FF5733; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Youve reached 90% limit on Litlyx</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<h2 style="color: #FF0000;">Youve reached 90% limit on Litlyx</h2>
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached 90% of its data collection limit for this month.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #FF0000; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>❗️ Youve reached your limit on Litlyx!</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<h2 style="color: #D32F2F;">❗️ Youve reached your limit on Litlyx!</h2>
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has reached the limit of your current plan.</p>
<p>To avoid losing precious data, please remember to monitor your usage on the <strong>Litlyx Dashboard</strong>. You can find your current usage details under <strong>Settings > Billing Tab</strong>.</p>
<p>If you need more data collection storage, you may consider upgrading your plan to get additional benefits and ensure uninterrupted data collection.</p>
<p>Feel free to reply to this email or contact us at <a href="mailto:help@litlyx.com" style="color: #D32F2F; text-decoration: none;">help@litlyx.com</a> if you have any questions or need assistance.</p>
<p>Thank you for choosing Litlyx every day as your analytics tool.</p>
<p>Have a nice day!</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>

View File

@@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Litlyx!</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Subject -->
<h2 style="color: #007BFF;">Welcome to Litlyx!</h2>
<p>Were happy to have you onboard,</p>
<p>At Litlyx, were committed to creating the best analytics collection experience for everybody, starting from developers.</p>
<p>Here are a few things you can do to get started tracking analytics today:</p>
<ol>
<li><strong><a href="https://dashboard.litlyx.com" style="color: #007BFF; text-decoration: none;">Create a new project</a></strong> by just naming it</li>
<li><strong><a style="color: #0a0a0a; text-decoration: none;">Copy the universal Script</a></strong> we provide you the snippets to copy in your index.html file and start instantly to track metrics on your website or web app.</li>
<li><strong><a style="color: #0a0a0a; text-decoration: none;">Third Step</a></strong> Encourage engagement or interaction.</li>
</ol>
<p>If you have any questions or need support, visit <a href="http://docs.litlyx.com" style="color: #007BFF;">docs.litlyx.com</a>.</p>
<p>Feel free to reply to this email or reach out to our team at <a href="mailto:help@litlyx.com" style="color: #007BFF;">help@litlyx.com</a>. Were here to help!</p>
<p>Link to Discord for developer support: <a href="https://discord.com/invite/9cQykjsmWX" style="color: #007BFF;">https://discord.com/invite/9cQykjsmWX</a></p>
<p>Thank you for joining us, and we look forward to seeing you around.</p>
<p>We want to make analytics the freshest thing on the web.</p>
<p>Antonio,</p>
<p>CEO | Litlyx</p>
</body>
</html>