mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-09 23:48:36 +01:00
Merge branch 'snapshot-rework'
This commit is contained in:
@@ -105,8 +105,6 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
});
|
||||
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -208,8 +206,7 @@ const pricingDrawer = usePricingDrawer();
|
||||
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
|
||||
<div class="flex gap-1 items-center justify-center text-lyx-text-dark">
|
||||
<div class="poppins">
|
||||
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim()
|
||||
}}
|
||||
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
|
||||
</div>
|
||||
<div class="poppins"> to </div>
|
||||
<div class="poppins">
|
||||
@@ -217,7 +214,7 @@ const pricingDrawer = usePricingDrawer();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
|
||||
<div class="mt-2" v-if="('default' in snapshot == false)">
|
||||
<UPopover placement="bottom">
|
||||
<LyxUiButton type="danger" class="w-full text-center">
|
||||
Delete current snapshot
|
||||
|
||||
20
dashboard/components/LyxUi/Button.vue
Normal file
20
dashboard/components/LyxUi/Button.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'outlined' | 'danger';
|
||||
|
||||
const props = defineProps<{ type: ButtonType, link?: string, target?: string, disabled?: boolean }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
|
||||
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-text" :class="{
|
||||
'bg-lyx-primary-dark outline-lyx-primary hover:bg-lyx-primary-hover': type === 'primary',
|
||||
'bg-lyx-widget-lighter outline-lyx-widget-lighter hover:bg-lyx-widget-light': type === 'secondary',
|
||||
'bg-lyx-transparent outline-lyx-widget-lighter hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
|
||||
'bg-lyx-danger-dark outline-lyx-danger hover:bg-lyx-danger': type === 'danger',
|
||||
'!bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
|
||||
}">
|
||||
<slot></slot>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
10
dashboard/components/LyxUi/Card.vue
Normal file
10
dashboard/components/LyxUi/Card.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-fit h-fit rounded-md bg-lyx-widget p-4 outline outline-[1px] outline-lyx-background-lighter">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
11
dashboard/components/LyxUi/Icon.vue
Normal file
11
dashboard/components/LyxUi/Icon.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{ icon: string }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="material-symbols-outlined">
|
||||
{{ props.icon }}
|
||||
</span>
|
||||
</template>
|
||||
24
dashboard/components/LyxUi/Input.vue
Normal file
24
dashboard/components/LyxUi/Input.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
const props = defineProps<{ placeholder?: string, modelValue: string, type?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void
|
||||
}>();
|
||||
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emits('update:modelValue', target.value);
|
||||
};
|
||||
|
||||
|
||||
//TODO: FUNCTIONALITY + PLACEHOLDER DARK
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
class="bg-lyx-widget-light text-lyx-text-dark poppins rounded-md outline outline-[1px] outline-lyx-widget-lighter"
|
||||
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
|
||||
</template>
|
||||
175
dashboard/components/Onboarding.vue
Normal file
175
dashboard/components/Onboarding.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: needsOnboarding } = useFetch("/api/onboarding/exist", {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false, useTimeOffset: false })
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const analyticsList = [
|
||||
"I have no prior analytics tool",
|
||||
"Google Analytics 4",
|
||||
"Plausible",
|
||||
"Umami",
|
||||
"MixPanel",
|
||||
"Simple Analytics",
|
||||
"Matomo",
|
||||
"Fathom",
|
||||
"Adobe Analytics",
|
||||
"Other"
|
||||
]
|
||||
|
||||
const jobsList = [
|
||||
"Developer",
|
||||
"Marketing",
|
||||
"Product",
|
||||
"Startup founder",
|
||||
"Indie hacker",
|
||||
"Other",
|
||||
]
|
||||
|
||||
const selectedIndex = ref<number>(-1);
|
||||
const otherFieldVisisble = ref<boolean>(false);
|
||||
const otherText = ref<string>('');
|
||||
function selectIndex(index: number) {
|
||||
selectedIndex.value = index;
|
||||
otherFieldVisisble.value = index == analyticsList.length - 1;
|
||||
}
|
||||
|
||||
const selectedIndex2 = ref<number>(-1);
|
||||
const otherFieldVisisble2 = ref<boolean>(false);
|
||||
const otherText2 = ref<string>('');
|
||||
function selectIndex2(index: number) {
|
||||
selectedIndex2.value = index;
|
||||
otherFieldVisisble2.value = index == jobsList.length - 1;
|
||||
}
|
||||
|
||||
const page = ref<number>(0);
|
||||
|
||||
function onNextPage() {
|
||||
if (selectedIndex.value == -1) return;
|
||||
saveAnalyticsType();
|
||||
page.value = 1;
|
||||
}
|
||||
|
||||
function onFinish(skipped?: boolean) {
|
||||
if (skipped) return location.reload();
|
||||
if (selectedIndex2.value == -1) return;
|
||||
saveJobTitle();
|
||||
page.value = 2;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function saveAnalyticsType() {
|
||||
await $fetch('/api/onboarding/add', {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false, useTimeOffset: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
analytics:
|
||||
selectedIndex.value == analyticsList.length - 1 ?
|
||||
otherText.value :
|
||||
analyticsList[selectedIndex.value]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function saveJobTitle() {
|
||||
|
||||
await $fetch('/api/onboarding/add', {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false, useTimeOffset: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
job:
|
||||
selectedIndex2.value == jobsList.length - 1 ?
|
||||
otherText2.value :
|
||||
jobsList[selectedIndex2.value]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const showOnboarding = computed(() => {
|
||||
if (route.path === '/login') return false;
|
||||
if (route.path === '/register') return false;
|
||||
if (needsOnboarding.value?.exist === false) return true;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div v-if="showOnboarding" class="absolute top-0 left-0 w-full h-full z-[30] bg-black/80 flex justify-center">
|
||||
|
||||
|
||||
|
||||
<div v-if="page == 0" class="bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||
|
||||
<div class="text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||
|
||||
<div class="text-lyx-text mt-4">
|
||||
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
|
||||
going to be your first/main analytics?
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||
<div v-for="(e, i) of analyticsList">
|
||||
<div @click="selectIndex(i)"
|
||||
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
|
||||
class="bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||
{{ e }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||
v-model="otherText"></LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-center flex-col items-center">
|
||||
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
|
||||
type="primary"> Next </LyxUiButton>
|
||||
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-if="page == 1" class="bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||
|
||||
<div class="text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||
|
||||
<div class="text-lyx-text mt-4">
|
||||
What is your job title ?
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||
<div v-for="(e, i) of jobsList">
|
||||
<div @click="selectIndex2(i)"
|
||||
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
|
||||
class="bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||
{{ e }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||
v-model="otherText2"></LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-center flex-col items-center">
|
||||
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
|
||||
Finish </LyxUiButton>
|
||||
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TProject } from '@schema/ProjectSchema';
|
||||
import type { TProject } from '@schema/project/ProjectSchema';
|
||||
|
||||
const { user } = useLoggedUser()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type Props = {
|
||||
options: { label: string }[],
|
||||
options: { label: string, disabled?: boolean }[],
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
@@ -17,9 +17,12 @@ const emits = defineEmits<{
|
||||
<template>
|
||||
|
||||
<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="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||
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-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
|
||||
:class="{
|
||||
'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
|
||||
'hover:!bg-lyx-widget !cursor-not-allowed text-lyx-widget-lighter': opt.disabled
|
||||
}">
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,10 @@ const limitsInfo = await useFetch("/api/project/limits_info", {
|
||||
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
|
||||
const { project } = useProject()
|
||||
@@ -20,7 +20,8 @@ const isPremium = computed(() => {
|
||||
<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 forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout
|
||||
Launch offer: 25% off forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at
|
||||
checkout
|
||||
from Acceleration Plan and beyond.
|
||||
</div>
|
||||
<!-- <div class="poppins text-lyx-primary">
|
||||
|
||||
@@ -6,6 +6,23 @@ import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -24,9 +41,12 @@ const chartOptions = ref<ChartOptions<'line'>>({
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
stacked: false,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
@@ -65,12 +85,32 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
borderColor: '#5655d7',
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#5655d7',
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return '#5655d7'
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||
return undefined;
|
||||
},
|
||||
backgroundColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return createGradient('#5655d7');
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unique sessions',
|
||||
@@ -81,19 +121,21 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
hoverBackgroundColor: '#4abde8',
|
||||
hoverBorderColor: '#4abde8',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bar'
|
||||
type: 'bar',
|
||||
// barThickness: 20,
|
||||
borderSkipped: ['bottom']
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
data: [],
|
||||
backgroundColor: ['#fbbf24'],
|
||||
borderColor: '#fbbf24',
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#fbbf24',
|
||||
hoverBorderColor: '#fbbf24',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bubble',
|
||||
stack: 'combined'
|
||||
stack: 'combined',
|
||||
borderColor: ["#fbbf24"]
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -106,6 +148,17 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
||||
const { chart, tooltip } = context;
|
||||
const tooltipEl = externalTooltipElement.value;
|
||||
|
||||
const currentIndex = tooltip.dataPoints[0].parsed.x;
|
||||
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (todayIndex && todayIndex >= 0) {
|
||||
if (currentIndex > todayIndex - 1) {
|
||||
if (!tooltipEl) return;
|
||||
return tooltipEl.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
@@ -130,13 +183,29 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
||||
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||
|
||||
}
|
||||
|
||||
const { snapshotDuration } = useSnapshot();
|
||||
|
||||
const selectLabels: { label: string, value: Slice }[] = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
|
||||
const selectedSlice = computed(() => selectLabels[selectedLabelIndex.value].value);
|
||||
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
|
||||
return selectLabels.map(e => {
|
||||
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
|
||||
});
|
||||
})
|
||||
|
||||
const selectedSlice = computed<Slice>(() => {
|
||||
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
|
||||
if (!targetValue) return 'day';
|
||||
if (targetValue.disabled) {
|
||||
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
|
||||
}
|
||||
return selectLabelsAvailable.value[selectedLabelIndex.value].value
|
||||
});
|
||||
|
||||
const selectedLabelIndex = ref<number>(1);
|
||||
const allDatesFull = ref<string[]>([]);
|
||||
@@ -144,13 +213,18 @@ 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 labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value));
|
||||
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||
|
||||
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
|
||||
return { data, labels, todayIndex }
|
||||
}
|
||||
|
||||
function onResponseError(e: any) {
|
||||
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
|
||||
let message = e.response._data.message ?? 'Generic error';
|
||||
if (message == 'internal server error') message = 'Please change slice';
|
||||
errorData.value = { errored: true, text: message }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
@@ -180,21 +254,7 @@ watch(readyToDisplay, () => {
|
||||
})
|
||||
|
||||
|
||||
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() {
|
||||
if (!visitsData.data.value) return;
|
||||
@@ -208,15 +268,33 @@ function onDataReady() {
|
||||
|
||||
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 }
|
||||
const rValue = 20 / maxEventSize * e;
|
||||
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
});
|
||||
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||
|
||||
|
||||
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return true;
|
||||
return 'bottom';
|
||||
});
|
||||
|
||||
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return '#fbbf2400';
|
||||
return '#fbbf24';
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
updateChart();
|
||||
}
|
||||
|
||||
@@ -230,7 +308,8 @@ const currentTooltipData = ref<{ visits: number, events: number, sessions: numbe
|
||||
const tooltipNameIndex = ['visits', 'sessions', 'events'];
|
||||
|
||||
function onLegendChange(dataset: any, index: number, checked: any) {
|
||||
dataset.hidden = !checked;
|
||||
const newValue = !checked;
|
||||
dataset.hidden = newValue;
|
||||
}
|
||||
|
||||
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
|
||||
@@ -247,7 +326,7 @@ const legendClasses = ref<string[]>([
|
||||
<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">
|
||||
:options="selectLabelsAvailable">
|
||||
</SelectButton>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,23 +5,18 @@ const props = defineProps<{
|
||||
value: string,
|
||||
text: string,
|
||||
avg?: string,
|
||||
trend?: number,
|
||||
color: string,
|
||||
data?: number[],
|
||||
labels?: string[],
|
||||
ready?: boolean,
|
||||
slow?: boolean
|
||||
slow?: boolean,
|
||||
todayIndex: number,
|
||||
tooltipText: string
|
||||
}>();
|
||||
|
||||
const { snapshotDuration } = useSnapshot()
|
||||
|
||||
const uTooltipText = computed(() => {
|
||||
const duration = snapshotDuration.value;
|
||||
if (!duration) return '';
|
||||
if (duration > 25) return 'Monthly trend';
|
||||
if (duration > 7) return 'Weekly trend';
|
||||
return 'Daily trend';
|
||||
})
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -34,32 +29,25 @@ const uTooltipText = computed(() => {
|
||||
</div>
|
||||
<div class="flex flex-col grow">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="brockmann text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
||||
<div class="brockmann text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
|
||||
</div>
|
||||
<div v-if="trend" class="flex flex-col items-center gap-1">
|
||||
<UTooltip :text="uTooltipText">
|
||||
<div class="flex items-center gap-3 rounded-md px-2 py-1"
|
||||
:style="`background-color: ${props.color}33`">
|
||||
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'"
|
||||
class="far text-[.9rem] 2xl:text-[1rem]" :style="`color: ${props.color}`"></i>
|
||||
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
|
||||
{{ trend.toFixed(0) }} %
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<UTooltip :text="props.tooltipText">
|
||||
<i class="far fa-info-circle text-lyx-text-darker text-[1rem]"></i>
|
||||
</UTooltip>
|
||||
<!-- <div class="poppins text-text-sub text-[.7rem]"> Trend </div> -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||
v-if="((props.data?.length || 0) > 0) && ready">
|
||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
||||
:color="props.color">
|
||||
<DashboardEmbedChartCard v-if="ready" :todayIndex="todayIndex" :data="props.data || []"
|
||||
:labels="props.labels || []" :color="props.color">
|
||||
</DashboardEmbedChartCard>
|
||||
</div>
|
||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string,
|
||||
title: string,
|
||||
text: string,
|
||||
sub: string,
|
||||
color: string
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
||||
|
||||
<!-- <div class="absolute flex items-center justify-center right-4 top-4 cursor-pointer hover:text-blue-400">
|
||||
<i class="fal fa-info-circle text-[.9rem] lg:text-[1.4rem]"></i>
|
||||
</div> -->
|
||||
|
||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
||||
:style="`background: ${props.color}`">
|
||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
||||
</div>
|
||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center lg:items-end">
|
||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -7,8 +7,10 @@ const props = defineProps<{
|
||||
data: any[],
|
||||
labels: string[]
|
||||
color: string,
|
||||
todayIndex: number
|
||||
}>();
|
||||
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -48,10 +50,22 @@ const chartData = ref<ChartData<'line'>>({
|
||||
data: props.data,
|
||||
backgroundColor: [props.color + '77'],
|
||||
borderColor: props.color,
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
if (!props.todayIndex || props.todayIndex == -1) return props.color;
|
||||
if (ctx.p1DataIndex >= props.todayIndex) return props.color + '00';
|
||||
return props.color;
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
if (!props.todayIndex || props.todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == props.todayIndex -1) return [2, 4];
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
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, props.slice));
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,68 +3,57 @@
|
||||
import DateService from '@services/DateService';
|
||||
import type { Slice } from '@services/DateService';
|
||||
|
||||
const { snapshot, safeSnapshotDates } = useSnapshot()
|
||||
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
|
||||
|
||||
const snapshotDays = computed(() => {
|
||||
const to = new Date(safeSnapshotDates.value.to).getTime();
|
||||
const from = new Date(safeSnapshotDates.value.from).getTime();
|
||||
return (to - from) / 1000 / 60 / 60 / 24;
|
||||
});
|
||||
|
||||
const chartSlice = computed(() => {
|
||||
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 30) return 'day' as Slice;
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 90) return 'day' as Slice;
|
||||
if (snapshotDuration.value <= 3) return 'hour' as Slice;
|
||||
if (snapshotDuration.value <= 32) return 'day' as Slice;
|
||||
return 'month' as Slice;
|
||||
});
|
||||
|
||||
|
||||
function findFirstZeroOrNullIndex(arr: (number | null)[]) {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr.slice(i).every(val => val === 0 || val === null)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
|
||||
const data = input.map(e => e.count || 0);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), chartSlice.value));
|
||||
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
|
||||
|
||||
const pool = [...input.map(e => e.count || 0)];
|
||||
|
||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
||||
|
||||
const targets = input.slice(Math.floor(input.length / 4 * 3));
|
||||
const targetAvg = targets.reduce((a, e) => a + e.count, 0) / targets.length;
|
||||
|
||||
const diffPercent: number = (100 / avg * (targetAvg)) - 100;
|
||||
|
||||
const trend = Math.max(Math.min(diffPercent, 99), -99);
|
||||
|
||||
return { data, labels, trend }
|
||||
return { data, labels, input }
|
||||
|
||||
}
|
||||
|
||||
const visitsData = useFetch('/api/timeline/visits', {
|
||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
|
||||
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
|
||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
|
||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
|
||||
const avgVisitDay = computed(() => {
|
||||
if (!visitsData.data.value) return '0.00';
|
||||
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgSessionsDay = computed(() => {
|
||||
if (!sessionsData.data.value) return '0.00';
|
||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
@@ -97,6 +86,11 @@ const avgSessionDuration = computed(() => {
|
||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||
});
|
||||
|
||||
const todayIndex = computed(() => {
|
||||
if (!visitsData.data.value) return -1;
|
||||
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -104,29 +98,33 @@ const avgSessionDuration = computed(() => {
|
||||
<template>
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
|
||||
|
||||
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total visits"
|
||||
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
|
||||
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
|
||||
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data"
|
||||
tooltipText="Sum of all page views on your website."
|
||||
:labels="visitsData.data.value?.labels" color="#5655d7">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="!bouncingRateData.pending.value" icon="far fa-chart-user" text="Bouncing rate"
|
||||
:value="avgBouncingRate" :trend="bouncingRateData.data.value?.trend" :slow="true"
|
||||
:data="bouncingRateData.data.value?.data" :labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user"
|
||||
text="Bouncing rate" :value="avgBouncingRate" :slow="true" :data="bouncingRateData.data.value?.data"
|
||||
tooltipText="Percentage of users who leave quickly (lower is better)."
|
||||
:labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||
</DashboardCountCard>
|
||||
|
||||
|
||||
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visitors"
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user"
|
||||
text="Unique visitors"
|
||||
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
|
||||
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
tooltipText="Count of distinct users visiting your website."
|
||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :data="sessionsData.data.value?.data"
|
||||
:labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
</DashboardCountCard>
|
||||
|
||||
|
||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||
text="Visit duration" :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
||||
color="#f56523">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||
text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data"
|
||||
tooltipText="Average time users spend on your website."
|
||||
:labels="sessionsDurationData.data.value?.labels" color="#f56523">
|
||||
</DashboardCountCard>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
||||
onMounted(() => startWatching());
|
||||
onUnmounted(() => stopWatching());
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
@@ -62,7 +63,7 @@ function showAnomalyInfoAlert() {
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div v-if="!selfhosted" 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-[.9rem]"> AI Anomaly Detector </div>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
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, props.slice));
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const { closeDialog } = useCustomDialog();
|
||||
|
||||
import { sub, format, isSameDay, type Duration } from 'date-fns'
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
@@ -46,14 +46,14 @@ async function confirmSnapshot() {
|
||||
body: JSON.stringify({
|
||||
name: snapshotName.value,
|
||||
color: currentColor.value,
|
||||
from: selected.value.start.toISOString(),
|
||||
to: selected.value.end.toISOString()
|
||||
from: startOfDay(selected.value.start),
|
||||
to: endOfDay(selected.value.end)
|
||||
})
|
||||
});
|
||||
|
||||
await updateSnapshots();
|
||||
closeDialog();
|
||||
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
createAlert('Snapshot created', 'Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
13
dashboard/components/drawer/Docs.vue
Normal file
13
dashboard/components/drawer/Docs.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<iframe class="w-full h-full" src="https://docs.litlyx.com/introduction" frameborder="0"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
20
dashboard/components/drawer/Generic.vue
Normal file
20
dashboard/components/drawer/Generic.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emits = defineEmits<{ (evt: 'onCloseClick'): void }>();
|
||||
|
||||
const { drawerComponent } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
<i class="fas fa-close text-[1.6rem]"></i>
|
||||
</div>
|
||||
|
||||
<Component v-if="drawerComponent" :is="drawerComponent"></Component>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PricingCardProp } from './PricingCardGeneric.vue';
|
||||
|
||||
import type { PricingCardProp } from '../pricing/PricingCardGeneric.vue';
|
||||
|
||||
|
||||
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
|
||||
@@ -182,35 +181,11 @@ function getPricingsData() {
|
||||
return { freePricing, customPricing, slidePricings }
|
||||
}
|
||||
|
||||
const { projectId } = useProject();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
async function onLifetimeUpgradeClick() {
|
||||
const res = await $fetch<string>(`/api/pay/create-onetime`, {
|
||||
...signHeaders({
|
||||
'content-type': 'application/json',
|
||||
'x-pid': projectId.value ?? ''
|
||||
}),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planId: 2001 })
|
||||
})
|
||||
if (!res) alert('Something went wrong');
|
||||
window.open(res);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
<i class="fas fa-close text-[1.6rem]"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2">
|
||||
@@ -218,52 +193,6 @@ async function onLifetimeUpgradeClick() {
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
|
||||
</div>
|
||||
|
||||
<!-- <LyxUiCard class="w-full mt-6">
|
||||
<div class="flex">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<span class="text-lyx-primary font-semibold text-[1.4rem]">
|
||||
LIFETIME DEAL
|
||||
</span>
|
||||
<span class="text-lyx-text-dark text-[.8rem]"> (Growth plan) </span>
|
||||
</div>
|
||||
<div class="text-[2rem]"> € 2.399,00 </div>
|
||||
<div> Up to 500.000 visits/events per month </div>
|
||||
<LyxUiButton type="primary" @click="onLifetimeUpgradeClick()"> Purchase </LyxUiButton>
|
||||
</div>
|
||||
<div class="flex justify-evenly grow">
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Slack support </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Unlimited domanis </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Unlimited reports </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> AI Tokens: 3.000 / month </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Server type: SHARED </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Data retention: 5 Years </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard> -->
|
||||
|
||||
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="poppins text-[2rem] font-semibold">
|
||||
@@ -282,7 +211,5 @@ async function onLifetimeUpgradeClick() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,157 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { sub, isSameDay, type Duration } from 'date-fns'
|
||||
|
||||
type ChartType = 'bar' | 'line';
|
||||
const chartTypeOptions: { value: ChartType, label: string }[] = [
|
||||
{ value: 'bar', label: 'Bar chart' },
|
||||
{ value: 'line', label: 'Line chart' },
|
||||
]
|
||||
|
||||
type yAxisMode = 'count';
|
||||
const yAxisModeOptions: { value: yAxisMode, label: string }[] = [
|
||||
{ value: 'count', label: 'Count fields' },
|
||||
]
|
||||
|
||||
type Slice = 'day' | 'month';
|
||||
const sliceOptions: Slice[] = ['day', 'month'];
|
||||
|
||||
const chartType = ref<ChartType>('line');
|
||||
const tableName = ref<string>('');
|
||||
const xAxis = ref<string>('');
|
||||
const yAxisMode = ref<yAxisMode>('count');
|
||||
const slice = ref<Slice>('day');
|
||||
const visualizationName = ref<string>('');
|
||||
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||
{ label: 'Last year', duration: { years: 1 } }
|
||||
]
|
||||
const timeframe = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||
|
||||
function isRangeSelected(duration: Duration) {
|
||||
return isSameDay(timeframe.value.start, sub(new Date(), duration)) && isSameDay(timeframe.value.end, new Date())
|
||||
}
|
||||
|
||||
function selectRange(duration: Duration) {
|
||||
timeframe.value = { start: sub(new Date(), duration), end: new Date() }
|
||||
}
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
const { closeDialog } = useCustomDialog();
|
||||
const activeProjectId = useActiveProjectId();
|
||||
|
||||
const { integrationsCredentials,testConnection } = useSupabase();
|
||||
|
||||
async function generate() {
|
||||
const credentials = integrationsCredentials.data.value;
|
||||
if (!credentials?.supabase) return createAlert('Credentials not found', 'Please add supabase credentials on the integration page', 'far fa-error', 5000);
|
||||
const connectionStatus = await testConnection();
|
||||
if (!connectionStatus) return createAlert('Invalid supabase credentials', 'Please check your supabase credentials on the integration page', 'far fa-error', 5000);
|
||||
|
||||
try {
|
||||
const creation = await $fetch('/api/integrations/supabase/add', {
|
||||
...signHeaders({
|
||||
'x-pid': activeProjectId.data.value || '',
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: visualizationName.value,
|
||||
chart_type: chartType.value,
|
||||
table_name: tableName.value,
|
||||
xField: xAxis.value,
|
||||
yMode: yAxisMode.value,
|
||||
from: timeframe.value.start,
|
||||
to: timeframe.value.end,
|
||||
slice: slice.value
|
||||
})
|
||||
})
|
||||
|
||||
createAlert('Integration generated', 'Integration generated successfully', 'far fa-check-circle', 5000);
|
||||
closeDialog();
|
||||
} catch (ex: any) {
|
||||
createAlert('Error generating integrations', ex.response._data.message.toString(), 'far fa-error', 5000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div> Visualization name </div>
|
||||
<div>
|
||||
<LyxUiInput class="w-full px-2 py-1" v-model="visualizationName"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div> Chart type </div>
|
||||
<USelect v-model="chartType" :options="chartTypeOptions" />
|
||||
</div>
|
||||
<div>
|
||||
<div> Table name </div>
|
||||
<div>
|
||||
<LyxUiInput class="w-full px-2 py-1" v-model="tableName"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div> X axis field </div>
|
||||
<div>
|
||||
<LyxUiInput class="w-full px-2 py-1" v-model="xAxis"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div> Y axis mode </div>
|
||||
<div>
|
||||
<USelect v-model="yAxisMode" :options="yAxisModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div> Timeframe </div>
|
||||
<div>
|
||||
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
|
||||
<UButton class="w-full" color="primary" variant="solid">
|
||||
<div class="flex items-center justify-center w-full gap-2">
|
||||
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||
{{ timeframe.start.toLocaleDateString() }} - {{ timeframe.end.toLocaleDateString() }}
|
||||
</div>
|
||||
</UButton>
|
||||
<template #panel="{ close }">
|
||||
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||
<div class="hidden sm:flex flex-col py-4">
|
||||
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||
variant="ghost" class="rounded-none px-6"
|
||||
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||
truncate @click="selectRange(range.duration)" />
|
||||
</div>
|
||||
|
||||
<DatePicker v-model="timeframe" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div> View mode </div>
|
||||
<div>
|
||||
<USelect v-model="slice" :options="sliceOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiButton type="primary" @click="generate()">
|
||||
Generate
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,170 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TSupabaseIntegration } from '@schema/integrations/SupabaseIntegrationSchema';
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
|
||||
const props = defineProps<{ integration_id: string }>();
|
||||
|
||||
const activeProjectId = useActiveProjectId();
|
||||
|
||||
const supabaseData = ref<{ labels: string[], data: number[] }>();
|
||||
const supabaseError = ref<string | undefined>(undefined);
|
||||
const supabaseFetching = ref<boolean>(false);
|
||||
|
||||
const { getRemoteData } = useSupabase();
|
||||
|
||||
function createGradient() {
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `#34B67C22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `#34B67C99`);
|
||||
gradient.addColorStop(0.35, `#34B67C66`);
|
||||
gradient.addColorStop(1, `#34B67C22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
}
|
||||
|
||||
|
||||
|
||||
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: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [],
|
||||
backgroundColor: ['#34B67C' + '77'],
|
||||
borderColor: '#34B67C',
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#34B67C',
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
supabaseFetching.value = true;
|
||||
supabaseError.value = undefined;
|
||||
|
||||
const integrationData = await $fetch<TSupabaseIntegration>('/api/integrations/supabase/get', {
|
||||
...signHeaders({
|
||||
'x-pid': activeProjectId.data.value || '',
|
||||
'x-integration': props.integration_id
|
||||
})
|
||||
});
|
||||
|
||||
if (!integrationData) {
|
||||
supabaseError.value = 'Cannot get integration data';
|
||||
supabaseFetching.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await getRemoteData(
|
||||
integrationData.table_name,
|
||||
integrationData.xField,
|
||||
integrationData.yMode,
|
||||
integrationData.from.toString(),
|
||||
integrationData.to.toString(),
|
||||
integrationData.slice,
|
||||
);
|
||||
if (data.error) {
|
||||
supabaseError.value = data.error;
|
||||
supabaseFetching.value = false;
|
||||
return;
|
||||
}
|
||||
supabaseFetching.value = false;
|
||||
supabaseData.value = data.result;
|
||||
|
||||
chartData.value.labels = data.result?.labels || [];
|
||||
chartData.value.datasets[0].data = data.result?.data || [];
|
||||
|
||||
console.log(data.result);
|
||||
createGradient();
|
||||
} catch (ex: any) {
|
||||
if (!ex.response._data) {
|
||||
supabaseError.value = ex.message.toString();
|
||||
supabaseFetching.value = false;
|
||||
} else {
|
||||
supabaseError.value = ex.response._data.message.toString();
|
||||
supabaseFetching.value = false;
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div v-if="!supabaseFetching">
|
||||
<div v-if="!supabaseError">
|
||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
</div>
|
||||
<div v-if="supabaseError"> {{ supabaseError }} </div>
|
||||
</div>
|
||||
<div v-if="supabaseFetching">
|
||||
Getting remote data...
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,8 +15,6 @@ export type PricingCardProp = {
|
||||
|
||||
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const currentIndex = ref<number>(props.defaultIndex || 0);
|
||||
|
||||
const data = computed(() => {
|
||||
|
||||
@@ -111,8 +111,7 @@ async function saveBillingInfo() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -128,9 +127,11 @@ const { visible } = usePricingDrawer();
|
||||
<template #info>
|
||||
<div v-if="!isGuest">
|
||||
<div class="flex flex-col gap-4">
|
||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 1" v-model="currentBillingInfo.line1">
|
||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 1"
|
||||
v-model="currentBillingInfo.line1">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 2" v-model="currentBillingInfo.line2">
|
||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 2"
|
||||
v-model="currentBillingInfo.line2">
|
||||
</LyxUiInput>
|
||||
<div class="flex gap-4 w-full">
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="Country"
|
||||
@@ -141,9 +142,11 @@ const { visible } = usePricingDrawer();
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
<div class="flex gap-4 w-full">
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="City" v-model="currentBillingInfo.city">
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="City"
|
||||
v-model="currentBillingInfo.city">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="State" v-model="currentBillingInfo.state">
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="State"
|
||||
v-model="currentBillingInfo.state">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,7 +198,7 @@ const { visible } = usePricingDrawer();
|
||||
<div class="poppins"> Expire date:</div>
|
||||
<div> {{ prettyExpireDate }}</div>
|
||||
</div>
|
||||
<LyxUiButton v-if="!isGuest" @click="visible = true" type="primary">
|
||||
<LyxUiButton v-if="!isGuest" @click="showDrawer('PRICING')" type="primary">
|
||||
Upgrade plan
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user