add dashboard

This commit is contained in:
Litlyx
2024-06-01 15:27:40 +02:00
parent 75f0787c3b
commit df4faf366f
201 changed files with 91267 additions and 0 deletions

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, () => {
console.log('UPDATE')
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,116 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useBarChart, BarChart } from 'vue-chart-3';
registerChartComponents();
const props = defineProps<{
datasets: any[],
labels: string[],
}>();
const chartOptions = ref<ChartOptions<'bar'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
stacked: true,
ticks: { display: true },
grid: {
display: false,
drawBorder: false,
color: '#CCCCCC22',
},
},
x: {
stacked: true,
ticks: { display: true },
grid: {
display: false,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: {
display: true,
position: 'right',
},
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<'bar'>>({
labels: props.labels,
datasets: props.datasets.map(e => {
return {
data: e.data,
label: e.label || '?',
backgroundColor: [e.color],
borderWidth: 0,
borderRadius: 8
}
})
});
const { barChartProps, barChartRef } = useBarChart({ 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, () => {
console.log('UPDATE')
chartData.value.labels = props.labels;
chartData.value.datasets.length = 0;
chartData.value.datasets = props.datasets.map(e => {
return {
data: e.data,
label: e.label || '?',
backgroundColor: [e.color],
borderWidth: 0,
borderRadius: 8
}
})
});
});
</script>
<template>
<BarChart v-bind="barChartProps"> </BarChart>
</template>

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
const props = defineProps<{ label: string, disabled?: boolean, loading?: boolean }>();
</script>
<template>
<div :class="{ '!bg-[#354a87] !text-text/50 !cursor-not-allowed': (disabled || loading) }"
class="bg-accent text-text px-4 py-2 text-center cursor-pointer hover:bg-[#5075e2] hover:text-text-sub">
<span v-show="!loading">{{ label }}</span>
<i v-if="loading" class="fas fa-loader animate-[spin_2s_linear_infinite]"></i>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
const props = defineProps<{ modelValue: string, placeholder?: string, readonly?: boolean }>();
const emits = defineEmits(['update:modelValue', 'change']);
function updateText(e: any) {
emits('update:modelValue', e.target.value);
}
function emitChange(e: any) {
emits('change', e.target.value);
}
</script>
<template>
<div>
<input @change="emitChange" :readonly="readonly" :value="modelValue" @input="updateText"
class="placeholder:text-text-sub/70 w-full read-only:bg-white/10 read-only:text-text-sub/60 placeholder:text-text-sub border-gray-400 bg-bg border-[1px] text-text rounded-md px-4 py-2"
:placeholder="placeholder" type="text">
</div>
</template>
<style lang="scss" scoped>
input:focus {
outline: none;
}
</style>

View File

@@ -0,0 +1,97 @@
<script lang="ts" setup>
export type Entry = {
label: string,
disabled?: boolean,
to?: string,
icon?: string,
action?: () => any,
adminOnly?: boolean,
external?: boolean
}
export type Section = {
title: string,
entries: Entry[]
}
type Props = {
sections: Section[]
}
const { isOpen, open, close, toggle } = useMenu()
const route = useRoute();
const props = defineProps<Props>();
const { isAdmin } = useUserRoles();
</script>
<template>
<div class="w-0 md:w-[5rem] absolute top-0 md:relative h-full">
<div @mouseover="open()" @mouseleave="close()"
class="CVerticalNavigation absolute z-[80] bg-menu h-full overflow-hidden w-0 md:w-[5rem]"
:class="{ '!w-[18rem] shadow-[0_0_20px_#000000] rounded-r-2xl': isOpen }">
<div :class="{ 'w-[18rem]': isOpen }">
<div class="flex gap-4 items-center py-6 px-[.9rem] pb-8">
<div class="bg-accent h-[2.8rem] aspect-[1/1] flex items-center justify-center rounded-lg">
<img class="h-[2.4rem]" :src="'/logo.png'">
</div>
<div v-if="isOpen" class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div>
</div>
<div class="pb-8" v-for="section of sections">
<div class="flex flex-col px-3 gap-2">
<template v-for="entry of section.entries">
<NuxtLink @click="entry.action?.()" :target="entry.external ? '_blank' : ''"
v-if="entry.to && (!entry.adminOnly || (isAdmin && !isAdminHidden))" tag="div"
:to="entry.to || '/'"
class="text-[#a3a9b6] flex w-full items-center gap-3 p-3 rounded-lg cursor-pointer hover:bg-[#363638] hover:text-[#ffffff]"
:class="{
'brightness-[.4] pointer-events-none': entry.disabled,
'bg-[#363638] shadow-[0px_0px_2px_#ffffff20_inset] border-[#ffffff20] border-[1px] !text-[#ffffff]': route.path == (entry.to || '#')
}">
<div class="flex items-center text-[1.4rem] w-[1.8rem] justify-center">
<i :class="entry.icon"></i>
</div>
<div v-if="isOpen" class="text-[.9rem] font-bold manrope"> {{ entry.label }} </div>
</NuxtLink>
<div v-if="!entry.to" @click="entry.action?.()"
class="text-[#a3a9b6] flex w-full items-center gap-3 p-3 rounded-lg cursor-pointer hover:bg-[#363638] hover:text-[#ffffff]">
<div class="flex items-center text-[1.4rem] w-[1.8rem] justify-center">
<i :class="entry.icon"></i>
</div>
<div v-if="isOpen" class="text-[.9rem] font-bold manrope"> {{ entry.label }} </div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.CVerticalNavigation {
transition: all .25s ease-in-out;
}
.CVerticalNavigation * {
font-family: 'Inter';
}
input:focus {
outline: none;
}
</style>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
</script>
<template>
<div class="bg-card card-shadow rounded-xl">
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
const props = defineProps<{ title: string, sub?: string }>();
</script>
<template>
<Card>
<div class="flex flex-col gap-4">
<div class="flex items-center">
<div class="flex flex-col grow">
<div class="poppins font-semibold text-[1.1rem] md:text-[1.4rem] text-text">
{{ props.title }}
</div>
<div v-if="props.sub" class="poppins text-[.8rem] md:text-[1.1rem] text-text-sub">
{{ props.sub }}
</div>
</div>
<slot name="header"></slot>
</div>
<div>
<slot></slot>
</div>
</div>
</Card>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
</script>
<template>
<div>
<div class="lg:hidden">
<slot></slot>
</div>
<div class="hidden lg:flex">
<slot name="desktop"></slot>
</div>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
type Props = {
options: { label: string }[],
currentIndex: number
}
const props = defineProps<Props>();
const emits = defineEmits<{
(evt: 'changeIndex', newIndex: number): void;
}>();
</script>
<template>
<div class="flex gap-2 border-[1px] border-gray-400 p-1 md:p-2 rounded-xl">
<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="{ 'bg-accent hover:!bg-accent': currentIndex == index }">
{{ opt.label }}
</div>
</div>
</template>
<style scoped lang="scss">
.select-btn-animated {
transition: all .4s linear;
}
</style>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
const props = defineProps<{ text: string }>();
const currentText = ref<string>("");
onMounted(()=>{
});
</script>
<template>
<div>{{ text }}</div>
</template>

View File

@@ -0,0 +1,156 @@
<script lang="ts" setup>
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
type Props = {
data: { _id: string, count: number }[],
iconProvider?: IconProvider,
elementTextTransformer?: (text: string) => string,
label: string,
subLabel: string,
desc: string,
loading?: boolean,
interactive?: boolean,
isDetailView?: boolean,
rawButton?: boolean,
hideShowMore?: boolean
}
const props = defineProps<Props>();
const emits = defineEmits<{
(e: 'dataReload'): void,
(e: 'showDetails', id: string): void,
(e: 'showRawData'): void,
(e: 'showGeneral'): void,
(e: 'showMore'): void,
}>();
const maxData = computed(() => {
const counts = props.data.map(e => e.count);
return Math.max(...counts);
});
function reloadData() {
emits('dataReload');
}
function showDetails(id: string) {
emits('showDetails', id);
}
</script>
<template>
<div class="flex">
<div class="text-text flex flex-col items-start gap-4 w-full relative">
<div class="w-full p-4 flex flex-col bg-menu rounded-xl gap-8 card-shadow">
<div class="flex justify-between mb-3">
<div class="flex flex-col gap-1">
<div class="flex gap-4 items-center">
<div class="poppins font-semibold text-[1.4rem] text-text">
{{ label }}
</div>
<div class="flex items-center">
<i @click="reloadData()"
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
</div>
</div>
<div class="poppins text-[1rem] text-text-sub/90">
{{ desc }}
</div>
</div>
<div v-if="rawButton" class="hidden lg:flex">
<div @click="$emit('showRawData')"
class="cursor-pointer hover:bg-accent/60 flex items-center justify-center poppins bg-accent rounded-lg py-2 px-8">
Raw data
</div>
</div>
</div>
<div>
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center">
<i @click="$emit('showGeneral')"
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
</div>
<div> {{ subLabel }} </div>
</div>
<div> Count </div>
</div>
<div class="flex flex-col gap-1">
<div v-if="props.data.length > 0" class="flex justify-between" v-for="element of props.data">
<div class="w-10/12 relative" @click="showDetails(element._id)"
:class="{ 'cursor-pointer line-active': interactive }">
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4">
<div v-if="iconProvider && iconProvider(element._id) != undefined"
class="flex items-center h-[1.3rem]">
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
:src="iconProvider(element._id)?.[1]">
<i v-else :class="iconProvider(element._id)?.[1]"></i>
</div>
<span
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
{{ elementTextTransformer?.(element._id) || element._id }}
</span>
</div>
</div>
<div class="text-text font-semibold manrope"> {{ formatNumberK(element.count) }} </div>
</div>
<div v-if="props.data.length == 0"
class="flex justify-center text-text-sub font-bold text-[1.1rem]">
No visits yet
</div>
</div>
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
<div @click="$emit('showMore')"
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
Show more
</div>
</div>
</div>
<div v-if="loading"
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
<i
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.line-active:hover {
.absolute {
@apply bg-accent/20
}
}
.ui-font {
font-feature-settings: normal;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
font-variation-settings: normal;
font-weight: 600;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4
}
</style>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { BrowsersAggregated } from '~/server/api/metrics/[project_id]/data/browsers';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders());
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
desc="The browsers most used to search your website." :dataIcons="false" :loading="pending"
label="Top Browsers" sub-label="Browsers"></DashboardBarsCard>
</div>
</template>

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
const props = defineProps<{
icon: string,
value: string,
text: string,
avg?: string,
trend?: number,
color: string,
data?: number[],
labels?: string[],
ready?: boolean
}>();
</script>
<template>
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
<div class="flex p-4 items-start">
<div class="flex items-center mt-2 mr-4">
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
</div>
<div class="flex flex-col grow">
<div class="flex items-end gap-2">
<div class="brockmann text-text-dirty text-[1.6rem] 2xl:text-[2rem]"> {{ value }} </div>
<div class="poppins text-text-sub text-[.7rem] 2xl:text-[.85rem] mb-2"> {{ avg }} </div>
</div>
<div class="poppins text-text-sub text-[.9rem] 2xl:text-base"> {{ text }} </div>
</div>
<div v-if="trend" class="flex items-center gap-3 rounded-xl 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>
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0">
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []" :color="props.color">
</DashboardEmbedChartCard>
</div>
</Card>
<!-- <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="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>

View File

@@ -0,0 +1,40 @@
<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>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { DevicesAggregated } from '~/server/api/metrics/[project_id]/data/devices';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders());
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" :dataIcons="false"
desc="The devices most used to access your website." :loading="pending" label="Top Devices"
sub-label="Devices"></DashboardBarsCard>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
const { dialogBarData, isDataLoading } = useBarCardDialog();
const columns = [
{ key: '_id', label: 'Value' },
{ key: 'count', label: 'Count' },
];
</script>
<template>
<div class="w-full h-full bg-bg rounded-xl p-8">
<div class="full h-full overflow-y-auto">
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
<template #count-data="{ row }">
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
</template>
<template #_id-data="{ row }">
<div class="flex items-center gap-3">
<div v-if="row.icon && row.icon[0] == 'img'" class="w-5 h-5">
<img class="w-full h-full" :src="row.icon[1]">
</div>
<i v-if="row.icon && row.icon[0] == 'icon'" :class="row.icon[1]"></i>
<div> {{ row._id }} </div>
</div>
</template>
</UTable>
</div>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script lang="ts" setup>
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
if (process.client) Chart.register(...registerables);
const props = defineProps<{
data: any[],
labels: string[]
color: string,
}>();
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
aspectRatio: 0,
scales: {
y: {
ticks: { display: false },
grid: { display: false }
},
x: {
ticks: { display: false },
grid: { display: false }
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: { enabled: false },
subtitle: { display: false },
decimation: { enabled: false },
},
layout: {
padding: {
top: 6,
bottom: -8,
left: -8,
right: -8
},
autoPadding: false
},
});
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
},
],
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
// onMounted(() => { })
</script>
<template>
<LineChart class="max-h-full max-w-full w-full h-full" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</template>

View File

@@ -0,0 +1,41 @@
<script lang="ts" setup>
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/visits/events';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
const router = useRouter();
function goToView() {
router.push('/dashboard/events');
}
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()" desc="Most frequent user events triggered in this project" @dataReload="refresh" :data="events || []" :loading="pending" label="Top Events"
sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie';
definePageMeta({ layout: 'dashboard' });
if (process.client) Chart.register(...registerables);
const chartOptions = ref<ChartOptions<'doughnut'>>({
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
ticks: { display: false },
grid: { display: false, drawBorder: false },
},
x: {
ticks: { display: false },
grid: { display: false, drawBorder: false },
},
// r: {
// ticks: { display: false },
// grid: {
// display: true,
// drawBorder: false,
// color: '#CCCCCC22',
// borderDash: [20, 8]
// },
// }
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'center',
labels: {
color: 'white',
font: {
family: 'Poppins',
size: 16
}
}
},
title: {
display: false
},
},
});
const chartData = ref<ChartData<'doughnut'>>({
labels: [],
datasets: [
{
rotation: 1,
data: [],
backgroundColor: ['#6bbbe3','#5655d0', '#a6d5cb', '#fae0b9'],
borderColor: ['#1d1d1f'],
borderWidth: 2
},
]
});
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
onMounted(async () => {
const activeProject = useActiveProject()
const eventsData = await $fetch<EventsPie[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
chartData.value.labels = eventsData.map(e => {
return `${e._id}`;
});
chartData.value.datasets[0].data = eventsData.map(e => e.count);
doughnutChartRef.value?.update();
if (window.innerWidth < 800) {
if (chartOptions.value?.plugins?.legend?.display) {
chartOptions.value.plugins.legend.display = false;
}
}
})
</script>
<template>
<DoughnutChart v-bind="doughnutChartProps"> </DoughnutChart>
</template>

View File

@@ -0,0 +1,73 @@
<!-- <script lang="ts" setup>
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
import { PieChart, usePieChart } from 'vue-chart-3';
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie';
if (process.client) {
Chart.register(...registerables);
}
const { project } = await useCurrentProject();
const { data: eventsPieData } = await useFetch<EventsPie[]>(`/api/metrics/${project.value?._id}/events_pie`, signHeaders());
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
title: {
display: true,
text: 'Events',
color: '#EEECF6',
},
},
});
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
labels: (eventsPieData.value || []).map((e: EventsPie) => {
return e._id;
}),
datasets: [
{
data: (eventsPieData.value || []).map((e: EventsPie) => {
return e.count;
}),
backgroundColor: [
"#295270",
"#304F71",
"#374C72",
"#3E4A73",
"#444773",
"#4B4474",
"#524175",
],
borderColor: '#222222'
},
],
}));
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
</script>
<template>
<div>
<div class="graph">
<PieChart v-bind="pieChartProps">
</PieChart>
</div>
</div>
</template> -->

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
import type { CountriesAggregated } from '~/server/api/metrics/[project_id]/data/countries';
const activeProject = await useActiveProject();
const { data: countries, pending, refresh } = await useFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders());
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="countries || []" :dataIcons="false" :loading="pending"
label="Top Countries" sub-label="Countries" desc=" Lists the countries where users access your website."></DashboardBarsCard>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
definePageMeta({ layout: 'dashboard' });
if (process.client) Chart.register(...registerables);
type Props = { xs?: any[], ys?: any[], color: string, border: string }
const props = defineProps<Props>();
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
ticks: { display: false },
grid: { display: false, drawBorder: false },
},
x: {
ticks: { display: false },
grid: { display: false, drawBorder: false }
}
},
plugins: {
legend: { display: false },
title: { display: false },
},
});
const chartData = computed<ChartData<'line'>>(() => ({
labels: (props.xs || []),
datasets: [
{
data: (props.ys || []),
backgroundColor: [props.color],
borderColor: props.border,
borderWidth: 3,
fill: true,
pointRadius: 0,
lineTension: 0.3,
},
],
}));
const { lineChartProps } = useLineChart({ chartData: chartData, options: chartOptions });
</script>
<template>
<LineChart v-bind="lineChartProps"> </LineChart>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { OssAggregated } from '~/server/api/metrics/[project_id]/data/oss';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders());
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
:loading="pending" label="Top OS" sub-label="OSs"></DashboardBarsCard>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script lang="ts" setup>
type Prop = {
title: string,
subtitle: string,
chip?: string,
active: boolean
}
const props = defineProps<Prop>();
</script>
<template>
<div
class="flex flex-col rounded-xl overflow-hidden hover:shadow-[0_0_50px_#2969f1] hover:outline hover:outline-[2px] hover:outline-accent cursor-pointer">
<div class="h-[14rem] aspect-[9/7] bg-[#2f2a64] flex relative">
<img class="object-cover" :src="'/report/card_image.png'">
<div v-if="chip"
class="absolute px-4 py-1 rounded-lg poppins left-2 flex gap-2 bottom-2 bg-orange-500/80 items-center">
<div class="flex items-center"> <i class="far fa-fire text-[1.1rem]"></i></div>
<div class="poppins text-[1rem] font-semibold"> {{ chip }} </div>
</div>
</div>
<div class="bg-[#444444cc] p-4 h-[7rem] relative">
<div class="poppins text-[1.2rem] font-bold text-text">
{{ title }}
</div>
<div class="poppins text-[1rem] text-text-sub/90">
{{ subtitle }}
</div>
<div class="absolute right-4 bottom-3">
<i class="fas fa-arrow-right text-[1.2rem]"></i>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
import type { IconProvider } from './BarsCard.vue';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders());
function iconProvider(id: string): ReturnType<IconProvider> {
if (id === 'self') return ['icon', 'fas fa-link'];
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
}
function elementTextTransformer(element: string) {
if (element === 'self') return 'Direct Link';
return element;
}
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() {
showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data.map(e => {
return { ...e, icon: iconProvider(e._id) }
});
isDataLoading.value = false;
});
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
:iconProvider="iconProvider" @dataReload="refresh" :data="events || []"
desc="Where users find your website." :dataIcons="true" :loading="pending" label="Top Referrers"
sub-label="Referrers"></DashboardBarsCard>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
const data = ref<number[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
const key = ref<string>('0');
const props = defineProps<{ slice: SliceName }>();
async function loadData() {
const response = await useTimelineData('sessions', props.slice);
if (!response) return;
data.value = response.data;
labels.value = response.labels;
ready.value = true;
key.value = Date.now().toString();
}
onMounted(async () => {
await loadData();
watch(props, async () => { await loadData(); });
})
</script>
<template>
<div>
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#f56523"></AdvancedLineChart>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script lang="ts" setup>
import type { MetricsTimeline } from '~/server/api/metrics/[project_id]/timeline/generic';
const { data: metricsInfo } = useMetricsData();
const avgVisitDay = computed(() => {
if (!metricsInfo.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
const avg = metricsInfo.value.visitsCount / Math.max(days, 1);
return avg.toFixed(2);
});
const avgEventsDay = computed(() => {
if (!metricsInfo.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24;
const avg = metricsInfo.value.eventsCount / Math.max(days, 1);
return avg.toFixed(2);
});
const avgSessionsDay = computed(() => {
if (!metricsInfo.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1);
return avg.toFixed(2);
});
const avgSessionDuration = computed(() => {
if (!metricsInfo.value) return '0.00';
const avg = metricsInfo.value.avgSessionDuration;
let hours = 0;
let minutes = 0;
let seconds = 0;
seconds += avg * 60;
while (seconds > 60) {
seconds -= 60;
minutes += 1;
}
while (minutes > 60) {
minutes -= 60;
hours += 1;
}
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
});
type Data = {
data: number[],
labels: string[],
trend: number,
ready: boolean
}
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
async function loadData(timelineEndpointName: string, target: Data) {
const response = await useTimelineData(timelineEndpointName, 'day');
if (!response) return;
target.data = response.data;
target.labels = response.labels;
target.trend = response.trend;
target.ready = true;
}
onMounted(async () => {
await loadData('visits', visitsData);
await loadData('events', eventsData);
await loadData('sessions', sessionsData);
await loadData('sessions_duration', sessionsDurationData);
});
</script>
<template>
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-4" v-if="metricsInfo">
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'"
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7">
</DashboardCountCard>
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'"
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
</DashboardCountCard>
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'"
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
</DashboardCountCard>
<DashboardCountCard :ready="sessionsDurationData.ready" icon="far fa-timer" text="Avg session time"
:value="avgSessionDuration" :trend="sessionsDurationData.trend" :data="sessionsDurationData.data"
:labels="sessionsDurationData.labels" color="#f56523">
</DashboardCountCard>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script lang="ts" setup>
const activeProject = useActiveProject();
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
onMounted(()=> startWatching());
onUnmounted(() => stopWatching());
function copyProjectId() {
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
alert('Copiato !');
}
</script>
<template>
<div
class="w-full px-6 py-2 lg:py-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg lg:text-2xl gap-2 xl:gap-12">
<div class="flex gap-2 items-center text-text/90">
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
<div> {{ onlineUsers }} Online users</div>
</div>
<div class="grow"></div>
<div class="flex gap-2">
<div>Project:</div>
<div class="text-text/90"> {{ activeProject?.name || 'Loading...' }} </div>
</div>
<div class="flex gap-2">
<div>Project id:</div>
<div class="text-text/90 text-[.9rem] lg:text-2xl">
{{ activeProject?._id || 'Loading...' }}
</div>
<div class="flex items-center ml-3">
<i @click="copyProjectId()" class="far fa-copy hover:text-text cursor-pointer text-[1.2rem]"></i>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
const data = ref<number[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
const props = defineProps<{ slice: SliceName }>();
async function loadData() {
const response = await useTimelineDataRaw('visits', props.slice);
if (!response) return;
const fixed = fixMetrics(response, props.slice);
console.log(fixed);
data.value = fixed.data;
labels.value = fixed.labels;
ready.value = true;
}
onMounted(async () => {
await loadData();
watch(props, async () => { await loadData(); });
})
</script>
<template>
<div>
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#5655d7"></AdvancedLineChart>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
const { data: websites, pending, refresh } = useWebsitesData();
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value);
watch(pending, () => {
currentViewData.value = websites.value;
})
const isPagesView = ref<boolean>(false);
const isLoading = ref<boolean>(false);
async function showDetails(website: string) {
if (isPagesView.value == true) return;
isLoading.value = true;
isPagesView.value = true;
const { data: pagesData, pending } = usePagesData(website, 10);
watch(pending, () => {
currentViewData.value = pagesData.value;
isLoading.value = false;
})
}
const router = useRouter();
function goToView() {
router.push('/dashboard/visits');
}
function setDefaultData() {
currentViewData.value = websites.value;
isPagesView.value = false;
}
async function dataReload() {
await refresh();
setDefaultData();
}
</script>
<template>
<div class="flex flex-col gap-2">
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
:sub-label="isPagesView ? 'Page' : 'Website'"
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
</DashboardBarsCard>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
const builtinEvents = [
{ name: 'INFO', color: '#33a0e8' },
{ name: 'WARNING', color: '#ef8d44' },
{ name: 'ERROR', color: '#e47069' },
]
</script>
<template>
<div class="flex flex-col gap-2">
<div class="bg-[#2a2e34] outline outline-[1px] outline-[#bdbdbd70] rounded-lg p-2 px-4 flex gap-2 items-center w-full" v-for="e of builtinEvents">
<div :style="`background-color: ${e.color}`" class="w-[1rem] h-[1rem] rounded-full"> </div>
<div class="poppins"> {{ e.name }} </div>
<div class="grow"></div>
<!-- <div> <i class="far fa-trash"></i> </div> -->
</div>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
const datasets = ref<any[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
const props = defineProps<{ slice: SliceName }>();
async function loadData() {
const response = await useTimelineDataRaw('events_stacked', props.slice);
if (!response) return;
const fixed = fixMetrics(response, props.slice, { advanced: true, advancedGroupKey: 'name' });
const parsedDatasets: any[] = [];
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
for (let i = 0; i < fixed.allKeys.length; i++) {
const line: any = {
data: [],
color: colors[i] || '#FF0000',
label: fixed.allKeys[i]
};
parsedDatasets.push(line)
fixed.data.forEach((e: { key: string, value: number }[]) => {
const target = e.find(e => e.key == fixed.allKeys[i]);
if (!target) return;
line.data.push(target.value);
});
}
datasets.value = parsedDatasets;
labels.value = fixed.labels;
ready.value = true;
}
onMounted(async () => {
await loadData();
watch(props, async () => { await loadData(); });
})
</script>
<template>
<div>
<AdvancedStackedBarChart v-if="ready" :datasets="datasets" :labels="labels">
</AdvancedStackedBarChart>
</div>
</template>

View File

@@ -0,0 +1,108 @@
<script lang="ts" setup>
type Props = {
size: number,
spacing: number,
opacity: string
}
const props = defineProps<Props>();
const sizeArr = new Array(props.size).fill('a');
function calculateOpacity(x: number, y: number) {
const distanceFromCenter = Math.sqrt(Math.pow(x - props.size / 2, 2) + Math.pow(y - props.size / 2, 2));
const normalizedDistance = distanceFromCenter / (props.size / 2);
return (1 - normalizedDistance).toFixed(1);
}
const widthHeight = computed(() => {
return 9 + props.size * props.spacing;
});
</script>
<template>
<div class="w-fit h-fit">
<svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`"
fill="none">
<template v-for="(p, x) of sizeArr">
<template v-for="(p, y) of sizeArr">
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" fill="#fff"
:fill-opacity="calculateOpacity(x, y)" />
</template>
</template>
<!-- <circle cx="27" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="135" r="1" fill="#fff" fill-opacity=".9" />
-->
</svg>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
const props = defineProps<{ title: string, text: string, icon: string }>();
</script>
<template>
<div class="bg-menu flex flex-col justify-center items-center px-8 py-10 w-[20rem] gap-4 rounded-xl">
<div>
<div class="bg-[#36363f] p-6 flex items-center justify-center aspect-[1/1] rounded-2xl">
<i :class="props.icon" class="text-text text-[1.6rem]"></i>
</div>
</div>
<div class="text-text text-[1.3rem] poppins font-semibold text-center mt-6">
{{ props.title }}
</div>
<div class="text-text-sub/80 text-[1rem] poppins text-center">
{{ props.text }}
</div>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script lang="ts" setup>
type Prop = {
title: string,
icon: string,
list: { text: string, icon: string }[],
price: string,
}
const props = defineProps<Prop>();
</script>
<template>
<div class="bg-menu py-6 rounded-lg w-full h-full flex flex-col items-center justify-normal px-6 relative">
<div
class="absolute rounded-full top-[-2.1rem] bg-accent w-[4.2rem] h-[4.2rem] flex items-center justify-center">
<i :class="icon" class="text-[2.5rem]"></i>
</div>
<div class="poppins mt-6 font-semibold text-[1.4rem]">
{{ title }}
</div>
<div class="bg-gray-400/50 h-[1px] w-full mt-6 mb-10"></div>
<div class="flex flex-col gap-4">
<div class="flex gap-3 items-center" v-for="element of list">
<div class="shrink-0 flex items-center bg-accent w-[2rem] h-[2rem] justify-center rounded-full">
<i :class="element.icon" class="text-[.9rem]"></i>
</div>
<div class="poppins">
{{ element.text }}
</div>
</div>
</div>
<div class="bg-gray-400/50 h-[1px] w-full mt-10 mb-6"></div>
<div class="flex gap-2 justify-between w-full">
<div class="flex gap-2 items-end">
<div class="manrope text-[2.5rem] font-bold text-text"> {{ price }} </div>
<div class="poppins text-text-sub/90 mb-1">/month</div>
</div>
<div>
Tasto bello
</div>
</div>
</div>
</template>