mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-09 23:48:36 +01:00
add referrers bar chart
This commit is contained in:
@@ -6,6 +6,7 @@ Lit.init('6643cd08a1854e3b81722ab5');
|
|||||||
|
|
||||||
const debugMode = process.dev;
|
const debugMode = process.dev;
|
||||||
|
|
||||||
|
const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDialog();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -22,6 +23,18 @@ const debugMode = process.dev;
|
|||||||
<div class="poppins hidden 2xl:flex"> 2XL - WIDE SCREEN </div>
|
<div class="poppins hidden 2xl:flex"> 2XL - WIDE SCREEN </div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showDialog"
|
||||||
|
class="custom-dialog flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 w-full h-full z-[100] backdrop-blur-[2px] bg-black/50">
|
||||||
|
<div class="bg-menu w-full h-full rounded-xl relative">
|
||||||
|
<div class="flex justify-end absolute z-[100] right-8 top-8">
|
||||||
|
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center w-full h-full p-4">
|
||||||
|
<component class="w-full" v-if="dialogComponent" v-bind="dialogParams" :is="dialogComponent"></component>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage></NuxtPage>
|
<NuxtPage></NuxtPage>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
|||||||
109
dashboard/components/AdvancedBarChart.vue
Normal file
109
dashboard/components/AdvancedBarChart.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useBarChart, BarChart } from 'vue-chart-3';
|
||||||
|
registerChartComponents();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: any[],
|
||||||
|
labels: string[]
|
||||||
|
color: string,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'bar'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
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: [
|
||||||
|
{
|
||||||
|
data: props.data,
|
||||||
|
backgroundColor: [props.color + '77'],
|
||||||
|
borderColor: props.color,
|
||||||
|
borderWidth: 4,
|
||||||
|
hoverBackgroundColor: props.color,
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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[0].data = props.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BarChart v-bind="barChartProps"> </BarChart>
|
||||||
|
</template>
|
||||||
@@ -36,7 +36,7 @@ const { isAdmin } = useUserRoles();
|
|||||||
:class="{ '!w-[18rem] shadow-[0_0_20px_#000000] rounded-r-2xl': isOpen }">
|
:class="{ '!w-[18rem] shadow-[0_0_20px_#000000] rounded-r-2xl': isOpen }">
|
||||||
<div :class="{ 'w-[18rem]': isOpen }">
|
<div :class="{ 'w-[18rem]': isOpen }">
|
||||||
<div class="flex gap-4 items-center py-6 px-[.9rem] pb-8">
|
<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">
|
<div class="bg-black h-[2.8rem] aspect-[1/1] flex items-center justify-center rounded-lg">
|
||||||
<img class="h-[2.4rem]" :src="'/logo.png'">
|
<img class="h-[2.4rem]" :src="'/logo.png'">
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isOpen" class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div>
|
<div v-if="isOpen" class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div>
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ function showDetails(id: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex h-full">
|
||||||
|
|
||||||
<div class="text-text flex flex-col items-start gap-4 w-full relative">
|
<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="w-full h-full p-4 flex flex-col bg-menu rounded-xl gap-8 card-shadow">
|
||||||
|
|
||||||
<div class="flex justify-between mb-3">
|
<div class="flex justify-between mb-3">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -67,8 +67,9 @@ function showDetails(id: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="rawButton" class="hidden lg:flex">
|
<div v-if="rawButton" class="hidden lg:flex">
|
||||||
<div @click="$emit('showRawData')"
|
<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">
|
class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]">
|
||||||
Raw data
|
<div> Raw data </div>
|
||||||
|
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +88,8 @@ function showDetails(id: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div v-if="props.data.length > 0" class="flex justify-between items-center" v-for="element of props.data">
|
<div v-if="props.data.length > 0" class="flex justify-between items-center"
|
||||||
|
v-for="element of props.data">
|
||||||
<div class="w-10/12 relative" @click="showDetails(element._id)"
|
<div class="w-10/12 relative" @click="showDetails(element._id)"
|
||||||
:class="{ 'cursor-pointer line-active': interactive }">
|
:class="{ 'cursor-pointer line-active': interactive }">
|
||||||
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
||||||
@@ -99,13 +101,13 @@ function showDetails(id: string) {
|
|||||||
:src="iconProvider(element._id)?.[1]">
|
:src="iconProvider(element._id)?.[1]">
|
||||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
||||||
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
|
||||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{ formatNumberK(element.count) }} </div>
|
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
|
||||||
|
formatNumberK(element.count) }} </div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.data.length == 0"
|
<div v-if="props.data.length == 0"
|
||||||
class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function showMore() {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2 h-full">
|
||||||
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()" desc="Most frequent user events triggered in this project" @dataReload="refresh" :data="events || []" :loading="pending" label="Top Events"
|
<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>
|
sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
|
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
|
||||||
import type { IconProvider } from './BarsCard.vue';
|
import type { IconProvider } from './BarsCard.vue';
|
||||||
|
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
|
||||||
|
|
||||||
const activeProject = await useActiveProject();
|
const activeProject = await useActiveProject();
|
||||||
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders());
|
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders());
|
||||||
@@ -20,6 +21,16 @@ function elementTextTransformer(element: string) {
|
|||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
const customDialog = useCustomDialog();
|
||||||
|
|
||||||
|
function onShowDetails(referrer: string) {
|
||||||
|
|
||||||
|
customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function showMore() {
|
function showMore() {
|
||||||
|
|
||||||
|
|
||||||
@@ -43,9 +54,9 @@ function showMore() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<DashboardBarsCard @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
|
<DashboardBarsCard @showDetails="onShowDetails" @showMore="showMore()"
|
||||||
:iconProvider="iconProvider" @dataReload="refresh" :data="events || []"
|
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider" @dataReload="refresh"
|
||||||
desc="Where users find your website." :dataIcons="true" :loading="pending" label="Top Referrers"
|
:data="events || []" :interactive="true" desc="Where users find your website." :dataIcons="true"
|
||||||
sub-label="Referrers"></DashboardBarsCard>
|
:loading="pending" label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function dataReload() {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2 h-full">
|
||||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
|
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
|
||||||
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
|
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
|
||||||
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
||||||
|
|||||||
41
dashboard/components/referrer/ReferrerBarChart.vue
Normal file
41
dashboard/components/referrer/ReferrerBarChart.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<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, referrer: string }>();
|
||||||
|
|
||||||
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
|
||||||
|
const response = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/timeline/referrers`, {
|
||||||
|
method: 'POST',
|
||||||
|
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ slice: 'day', referrer: props.referrer })
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
<AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
|
||||||
|
</AdvancedBarChart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
20
dashboard/composables/useCustomDialog.ts
Normal file
20
dashboard/composables/useCustomDialog.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Component } from "vue";
|
||||||
|
|
||||||
|
|
||||||
|
const showDialog = ref<boolean>(false);
|
||||||
|
const dialogParams = ref<any>({});
|
||||||
|
const dialogComponent = ref<Component>();
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
showDialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(component: Component, params: any) {
|
||||||
|
dialogComponent.value = component;
|
||||||
|
dialogParams.value = params;
|
||||||
|
showDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomDialog() {
|
||||||
|
return { showDialog, closeDialog, openDialog, dialogParams, dialogComponent };
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ const sections: Section[] = [
|
|||||||
|
|
||||||
const { showDialog, closeDialog } = useBarCardDialog();
|
const { showDialog, closeDialog } = useBarCardDialog();
|
||||||
|
|
||||||
|
|
||||||
const { open, isOpen, close } = useMenu();
|
const { open, isOpen, close } = useMenu();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -99,6 +100,7 @@ const { open, isOpen, close } = useMenu();
|
|||||||
v-if="showDialog">
|
v-if="showDialog">
|
||||||
<DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard>
|
<DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ const selectLabelsEvents = [
|
|||||||
{ label: 'Month', value: 'month' },
|
{ label: 'Month', value: 'month' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type MetricsTimeline = {
|
|||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTimeline(model: Model<any>, project_id: string, slice: 'hour' | 'day' | 'month' | 'year' = 'day', duration?: number, customOptions: AggregateOptions = {}, customGroup: Object = {}, customProjection: Object = {}, customGroupId: Object = {}) {
|
export async function getTimeline(model: Model<any>, project_id: string, slice: 'hour' | 'day' | 'month' | 'year' = 'day', duration?: number, customOptions: AggregateOptions = {}, customGroup: Object = {}, customProjection: Object = {}, customGroupId: Object = {}, customMatch: Object = {}) {
|
||||||
|
|
||||||
const groupId: any = {};
|
const groupId: any = {};
|
||||||
const sort: any = {};
|
const sort: any = {};
|
||||||
@@ -53,7 +53,8 @@ export async function getTimeline(model: Model<any>, project_id: string, slice:
|
|||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
project_id: new Types.ObjectId(project_id),
|
project_id: new Types.ObjectId(project_id),
|
||||||
created_at: { $gte: from, $lte: to }
|
created_at: { $gte: from, $lte: to },
|
||||||
|
...customMatch
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ $group: { _id: { ...groupId, ...customGroupId }, count: { $sum: 1 }, ...customGroup } },
|
{ $group: { _id: { ...groupId, ...customGroupId }, count: { $sum: 1 }, ...customGroup } },
|
||||||
@@ -61,6 +62,7 @@ export async function getTimeline(model: Model<any>, project_id: string, slice:
|
|||||||
{ $project: { _id: { $dateFromParts: fromParts }, count: "$count", ...customProjection } }
|
{ $project: { _id: { $dateFromParts: fromParts }, count: "$count", ...customProjection } }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
const result: MetricsTimeline[] = await model.aggregate(aggregation, customOptions);
|
const result: MetricsTimeline[] = await model.aggregate(aggregation, customOptions);
|
||||||
|
|
||||||
return { data: result, from, to };
|
return { data: result, from, to };
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { getTimeline } from "./generic";
|
||||||
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
|
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||||
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
const project_id = getRequestProjectId(event);
|
||||||
|
if (!project_id) return;
|
||||||
|
|
||||||
|
const user = getRequestUser(event);
|
||||||
|
const project = await getUserProjectFromId(project_id, user);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const { slice, duration, referrer } = await readBody(event);
|
||||||
|
|
||||||
|
// return await Redis.useCache({ key: `timeline:referrers:${project_id}:${slice}`, exp: TIMELINE_EXPIRE_TIME }, async () => {
|
||||||
|
const timelineReferrers = await getTimeline(VisitModel, project_id, slice, duration,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ referrer: "$_id.referrer" },
|
||||||
|
{ referrer: "$referrer" },
|
||||||
|
{ referrer }
|
||||||
|
);
|
||||||
|
return timelineReferrers;
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user