feat: add countries and devices charts (#783)
Co-authored-by: orig <oriorigranot@gamil.com>
@ -12,8 +12,46 @@ export class AnalyticsController {
|
||||
|
||||
@Get(':key')
|
||||
async getAnalytics(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
|
||||
const link = await this.findLink(key, user.id);
|
||||
const data = await this.analyticsService.getClicksOverTime(link.id, days);
|
||||
return { url: link.url, clicksOverTime: data };
|
||||
}
|
||||
|
||||
@Get(':key/devices')
|
||||
async getDevices(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
|
||||
return this.getGroupedData(key, user.id, days, 'device');
|
||||
}
|
||||
|
||||
@Get(':key/os')
|
||||
async getOs(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
|
||||
return this.getGroupedData(key, user.id, days, 'os');
|
||||
}
|
||||
|
||||
@Get(':key/browsers')
|
||||
async getBrowsers(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
|
||||
return this.getGroupedData(key, user.id, days, 'browser');
|
||||
}
|
||||
|
||||
@Get(':key/countries')
|
||||
async getCountries(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
|
||||
return this.getGroupedData(key, user.id, days, 'country');
|
||||
}
|
||||
|
||||
@Get(':key/regions')
|
||||
async getRegions(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
|
||||
return this.getGroupedData(key, user.id, days, 'region');
|
||||
}
|
||||
|
||||
@Get(':key/cities')
|
||||
async getCities(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
|
||||
return this.getGroupedData(key, user.id, days, 'city', {
|
||||
country: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async findLink(key: string, userId: string) {
|
||||
const link = await this.prismaService.link.findUnique({
|
||||
where: { key, userId: user.id },
|
||||
where: { key, userId },
|
||||
select: { id: true, url: true },
|
||||
});
|
||||
|
||||
@ -21,11 +59,12 @@ export class AnalyticsController {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const data = await this.analyticsService.getClicksOverTime(link.id, days);
|
||||
return link;
|
||||
}
|
||||
|
||||
return {
|
||||
url: link.url,
|
||||
clicksOverTime: data,
|
||||
};
|
||||
private async getGroupedData(key: string, userId: string, days: number, field: string, include?: Record<string, boolean>) {
|
||||
const link = await this.findLink(key, userId);
|
||||
const data = await this.analyticsService.getGroupedByField(link.id, field, days, include);
|
||||
return { url: link.url, data };
|
||||
}
|
||||
}
|
||||
|
@ -18,11 +18,11 @@ export class AnalyticsService {
|
||||
});
|
||||
}
|
||||
|
||||
async getClicksOverTime(linkId: string, durationDays = 30): Promise<any[]> {
|
||||
async getClicksOverTime(linkId: string, durationDays = 30): Promise<{ day: string; count: string }[]> {
|
||||
const fromDate = sub(new Date(), { days: durationDays });
|
||||
const trunc = durationDays === 1 ? 'hour' : 'day';
|
||||
|
||||
return this.prismaService.$queryRaw`
|
||||
return this.prismaService.$queryRaw<{ day: string; count: string }[]>`
|
||||
SELECT
|
||||
date_trunc(${trunc}, "createdAt")::text AS day,
|
||||
COUNT(*)::text AS count
|
||||
@ -37,4 +37,37 @@ export class AnalyticsService {
|
||||
day;
|
||||
`;
|
||||
}
|
||||
|
||||
async getGroupedByField(
|
||||
linkId: string,
|
||||
field: string,
|
||||
durationDays = 30,
|
||||
include?: Record<string, boolean>
|
||||
): Promise<{ field: string; count: string }[]> {
|
||||
const fromDate = sub(new Date(), { days: durationDays });
|
||||
|
||||
const includeFields = include
|
||||
? Object.keys(include)
|
||||
.map((k) => `"${k}"`)
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
"${field}" AS field,
|
||||
${includeFields ? `${includeFields},` : ''}
|
||||
COUNT(*)::text AS count
|
||||
FROM
|
||||
"Visit"
|
||||
WHERE
|
||||
"linkId" = $1
|
||||
AND "createdAt" >= $2
|
||||
GROUP BY
|
||||
"${field}" ${includeFields ? `, ${includeFields}` : ''}
|
||||
ORDER BY
|
||||
COUNT(*) DESC;
|
||||
`;
|
||||
|
||||
return this.prismaService.$queryRawUnsafe<{ field: string; count: string }[]>(query, linkId, fromDate);
|
||||
}
|
||||
}
|
||||
|
BIN
apps/frontend/public/images/icons/browsers/chrome.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/frontend/public/images/icons/browsers/edge.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/frontend/public/images/icons/browsers/facebook.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/frontend/public/images/icons/browsers/firefox.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
apps/frontend/public/images/icons/browsers/ie.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
apps/frontend/public/images/icons/browsers/instagram.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
apps/frontend/public/images/icons/browsers/opera.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/frontend/public/images/icons/browsers/safari.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/frontend/public/images/icons/browsers/ucbrowser.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/frontend/public/images/icons/browsers/yandex.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/frontend/public/images/icons/os/android.png
Normal file
After Width: | Height: | Size: 742 B |
BIN
apps/frontend/public/images/icons/os/apple.png
Normal file
After Width: | Height: | Size: 528 B |
BIN
apps/frontend/public/images/icons/os/linux.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/frontend/public/images/icons/os/macos.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/frontend/public/images/icons/os/windows.png
Normal file
After Width: | Height: | Size: 294 B |
BIN
apps/frontend/public/images/unkown-favicon-small.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
@ -0,0 +1,15 @@
|
||||
/* Hide scrollbar for WebKit-based browsers */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge, and Firefox */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* Ensure scrollbar is hidden for WebKit-based browsers */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
@ -51,7 +51,8 @@ export const ClicksChart = component$((props: ClicksChartProps) => {
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: 'Clicks' },
|
||||
stepSize: 1,
|
||||
min: 0,
|
||||
forceNiceScale: true,
|
||||
},
|
||||
tooltip: { x: { format: 'dd MMM yyyy HH:mm' } },
|
||||
dataLabels: { enabled: false },
|
||||
@ -73,17 +74,20 @@ export const ClicksChart = component$((props: ClicksChartProps) => {
|
||||
theme: { mode: isDarkMode() ? 'dark' : 'light', palette: 'palette1' },
|
||||
};
|
||||
|
||||
chartInstance.value = noSerialize(new ApexCharts(chartRef.value!, options));
|
||||
chartInstance.value!.render();
|
||||
window.addEventListener('theme-toggled', (ev) => {
|
||||
const theme = (ev as CustomEvent).detail.theme;
|
||||
chartInstance.value!.updateOptions({
|
||||
theme: { mode: theme, palette: 'palette1' },
|
||||
grid: {
|
||||
borderColor: isDarkMode() ? '#374151' : '#bfc4cf',
|
||||
},
|
||||
// Ensure the chartRef is available before rendering the chart
|
||||
if (chartRef.value) {
|
||||
chartInstance.value = noSerialize(new ApexCharts(chartRef.value, options));
|
||||
await chartInstance.value!.render();
|
||||
window.addEventListener('theme-toggled', (ev) => {
|
||||
const theme = (ev as CustomEvent).detail.theme;
|
||||
chartInstance.value!.updateOptions({
|
||||
theme: { mode: theme, palette: 'palette1' },
|
||||
grid: {
|
||||
borderColor: isDarkMode() ? '#374151' : '#bfc4cf',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
isInitialized.value = true;
|
||||
} else {
|
||||
chartInstance.value.updateOptions({
|
||||
@ -105,6 +109,11 @@ export const ClicksChart = component$((props: ClicksChartProps) => {
|
||||
<p class="text-base font-normal text-gray-500 dark:text-gray-400">{`Clicks for the last ${chartDescription.value}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isInitialized.value ? (
|
||||
<div class="flex items-center justify-center min-h-[365px]">
|
||||
<span class="loading loading-spinner loading-lg" />
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={chartRef} class="chart-container" />
|
||||
</div>
|
||||
</>
|
||||
@ -128,7 +137,6 @@ const updateChartDescription = (chartDescription: Signal<string>, daysDuration:
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to fetch chart data
|
||||
async function fetchChartData(key: string, days: number) {
|
||||
try {
|
||||
const response = await authorizedFetch(`${process.env.CLIENTSIDE_API_DOMAIN}/api/v1/analytics/${key}?days=${days}`);
|
||||
|
@ -0,0 +1,106 @@
|
||||
import { component$, useSignal, useStylesScoped$, useVisibleTask$ } from '@builder.io/qwik';
|
||||
import { NoData } from '../../empty-data/no-data';
|
||||
import countryLookup from 'country-code-lookup';
|
||||
import styles from '../analytics-chart.css?inline';
|
||||
import { fetchAnalyticsChartData } from '../utils';
|
||||
|
||||
interface Location {
|
||||
field: string;
|
||||
country?: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface CountriesChartProps {
|
||||
urlKey: string;
|
||||
daysDuration: number;
|
||||
initialData: Location[];
|
||||
}
|
||||
|
||||
export const CountriesChart = component$((props: CountriesChartProps) => {
|
||||
useStylesScoped$(styles);
|
||||
|
||||
const selectedCategory = useSignal<'countries' | 'cities'>('countries');
|
||||
const searchQuery = useSignal('');
|
||||
const locations = useSignal<Location[]>(props.initialData);
|
||||
|
||||
useVisibleTask$(async ({ track }) => {
|
||||
track(() => selectedCategory.value);
|
||||
track(() => props.daysDuration);
|
||||
|
||||
const data = await fetchAnalyticsChartData(props.urlKey, selectedCategory.value, props.daysDuration);
|
||||
locations.value = data;
|
||||
});
|
||||
|
||||
const getCountryCode = (item: Location) => {
|
||||
return item.country ? item.country : item.field;
|
||||
};
|
||||
|
||||
const filteredData = locations.value.filter((item) => {
|
||||
const country = countryLookup.byIso(getCountryCode(item));
|
||||
return country && country.country.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="relative z-0 h-[400px] dark:bg-slate-800 bg-white px-5 py-5 rounded-lg shadow overflow-hidden">
|
||||
<div class="mb-3 flex justify-between">
|
||||
<h1 class="text-lg font-semibold">Locations</h1>
|
||||
<div class="relative inline-flex items-center space-x-1">
|
||||
<button
|
||||
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'countries' ? 'btn-active' : ''}`}
|
||||
onClick$={() => {
|
||||
selectedCategory.value = 'countries';
|
||||
}}
|
||||
>
|
||||
Countries
|
||||
</button>
|
||||
<button
|
||||
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'cities' ? 'btn-active' : ''}`}
|
||||
onClick$={() => {
|
||||
selectedCategory.value = 'cities';
|
||||
}}
|
||||
>
|
||||
Cities
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Need to think about how to design this input field */}
|
||||
{/* <input
|
||||
type="text"
|
||||
placeholder="Search country..."
|
||||
class="input input-bordered w-full mb-3"
|
||||
onInput$={(e) => (searchQuery.value = (e.target as HTMLInputElement).value)}
|
||||
/> */}
|
||||
|
||||
<div class="flex flex-col gap-1 overflow-y-auto h-[300px] pb-4 scrollbar-hide relative">
|
||||
{filteredData.length === 0 ? (
|
||||
<div class="pt-12">
|
||||
<NoData title="No Data Available" description="No locations to display." />
|
||||
</div>
|
||||
) : (
|
||||
filteredData.map((item) => {
|
||||
const countryCode = getCountryCode(item);
|
||||
const country = countryLookup.byIso(countryCode);
|
||||
const displayName = selectedCategory.value === 'cities' ? item.field : country?.country || item.field;
|
||||
return (
|
||||
<div key={item.field} class="group flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-500/10">
|
||||
<div class="relative z-10 flex h-8 w-full max-w-[calc(100%-2rem)] items-center">
|
||||
<div class="z-10 flex items-center space-x-2 px-2">
|
||||
<img alt={countryCode} src={`https://flag.vercel.app/m/${countryCode}.svg`} class="h-3 w-5" />
|
||||
<div class="truncate text-sm text-gray-800 dark:text-gray-200 underline-offset-4 group-hover:underline">
|
||||
{displayName}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute h-full origin-left rounded-sm bg-green-100 dark:bg-green-500/30"
|
||||
style={{ width: `${(item.count / filteredData[0].count) * 100}%`, transform: 'scaleX(1)' }}
|
||||
></div>
|
||||
</div>
|
||||
<p class="z-10 px-2 text-sm text-gray-600 dark:text-gray-200">{item.count}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,108 @@
|
||||
import { component$, useSignal, useStylesScoped$, useVisibleTask$ } from '@builder.io/qwik';
|
||||
import { NoData } from '../../empty-data/no-data';
|
||||
import { fetchAnalyticsChartData } from '../utils';
|
||||
import styles from '../analytics-chart.css?inline';
|
||||
import { browserIcons, deviceIcons, osIcons } from './icons';
|
||||
import { UNKNOWN_FAVICON_SMALL } from '../../../temporary-links/utils';
|
||||
|
||||
interface Device {
|
||||
field: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface DevicesChartProps {
|
||||
urlKey: string;
|
||||
daysDuration: number;
|
||||
initialData: Device[];
|
||||
}
|
||||
|
||||
export const DevicesChart = component$((props: DevicesChartProps) => {
|
||||
useStylesScoped$(styles);
|
||||
|
||||
const selectedCategory = useSignal<'devices' | 'os' | 'browsers'>('devices');
|
||||
const devices = useSignal<Device[]>(props.initialData);
|
||||
|
||||
useVisibleTask$(async ({ track }) => {
|
||||
track(() => selectedCategory.value);
|
||||
track(() => props.daysDuration);
|
||||
|
||||
const data = await fetchAnalyticsChartData(props.urlKey, selectedCategory.value, props.daysDuration);
|
||||
devices.value = data;
|
||||
});
|
||||
|
||||
const getIcon = (item: Device) => {
|
||||
let icon: string | undefined = undefined;
|
||||
const name = item.field.toLowerCase();
|
||||
switch (selectedCategory.value) {
|
||||
case 'devices':
|
||||
icon = deviceIcons[name];
|
||||
break;
|
||||
case 'os':
|
||||
icon = osIcons[name];
|
||||
break;
|
||||
case 'browsers':
|
||||
icon = browserIcons[name];
|
||||
break;
|
||||
}
|
||||
|
||||
return icon || UNKNOWN_FAVICON_SMALL;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative z-0 h-[400px] dark:bg-slate-800 bg-white px-5 py-5 rounded-lg shadow overflow-hidden">
|
||||
<div class="mb-3 flex justify-between">
|
||||
<h1 class="text-lg font-semibold">Devices</h1>
|
||||
<div class="relative inline-flex items-center space-x-1">
|
||||
<button
|
||||
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'devices' ? 'btn-active' : ''}`}
|
||||
onClick$={() => {
|
||||
selectedCategory.value = 'devices';
|
||||
}}
|
||||
>
|
||||
Type
|
||||
</button>
|
||||
<button
|
||||
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'os' ? 'btn-active' : ''}`}
|
||||
onClick$={() => {
|
||||
selectedCategory.value = 'os';
|
||||
}}
|
||||
>
|
||||
OS
|
||||
</button>
|
||||
<button
|
||||
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'browsers' ? 'btn-active' : ''}`}
|
||||
onClick$={() => {
|
||||
selectedCategory.value = 'browsers';
|
||||
}}
|
||||
>
|
||||
Browsers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 overflow-y-auto h-[300px] pb-4 scrollbar-hide relative">
|
||||
{devices.value.length === 0 ? (
|
||||
<div class="pt-12">
|
||||
<NoData title="No Data Available" description="No devices to display." />
|
||||
</div>
|
||||
) : (
|
||||
devices.value.map((item) => (
|
||||
<div key={item.field} class="group flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-500/10">
|
||||
<div class="relative z-10 flex h-8 w-full max-w-[calc(100%-2rem)] items-center">
|
||||
<div class="z-10 flex items-center space-x-2 px-2">
|
||||
<img alt={item.field} src={getIcon(item)} class="h-4 w-4" />
|
||||
<div class="truncate text-sm text-gray-800 dark:text-gray-200 underline-offset-4 group-hover:underline">{item.field}</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute h-full origin-left rounded-sm bg-orange-100 dark:bg-orange-500/30"
|
||||
style={{ width: `${(item.count / devices.value[0].count) * 100}%`, transform: 'scaleX(1)' }}
|
||||
></div>
|
||||
</div>
|
||||
<p class="z-10 px-2 text-sm text-gray-600 dark:text-gray-200">{item.count}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,30 @@
|
||||
export const deviceIcons: Record<string, string> = {
|
||||
desktop: 'https://faisalman.github.io/ua-parser-js/images/types/default.png',
|
||||
mobile: 'https://faisalman.github.io/ua-parser-js/images/types/mobile.png',
|
||||
};
|
||||
|
||||
export const osIcons: Record<string, string> = {
|
||||
windows: '/images/icons/os/windows.png',
|
||||
'mac os': '/images/icons/os/macos.png',
|
||||
ios: '/images/icons/os/apple.png',
|
||||
android: '/images/icons/os/android.png',
|
||||
linux: '/images/icons/os/linux.png',
|
||||
chromeoS: 'https://faisalman.github.io/ua-parser-js/images/os/chromeos.png',
|
||||
windowsphone: 'https://faisalman.github.io/ua-parser-js/images/os/windowsphone.png',
|
||||
blacknerry: 'https://faisalman.github.io/ua-parser-js/images/os/blackberry.png',
|
||||
firefoxos: 'https://faisalman.github.io/ua-parser-js/images/os/firefoxos.png',
|
||||
};
|
||||
|
||||
export const browserIcons: Record<string, string> = {
|
||||
chrome: '/images/icons/browsers/chrome.png',
|
||||
firefox: '/images/icons/browsers/firefox.png',
|
||||
facebook: '/images/icons/browsers/facebook.png',
|
||||
safari: '/images/icons/browsers/safari.png',
|
||||
'mobile safari': '/images/icons/browsers/safari.png',
|
||||
ie: '/images/icons/browsers/ie.png',
|
||||
edge: '/images/icons/browsers/edge.png',
|
||||
opera: '/images/icons/browsers/opera.png',
|
||||
yandex: '/images/icons/browsers/yandex.png',
|
||||
instagram: '/images/icons/browsers/instagram.png',
|
||||
ucbrowser: '/images/icons/browsers/ucbrowser.png',
|
||||
};
|
12
apps/frontend/src/components/dashboard/analytics/utils.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { authorizedFetch } from '../../../shared/auth.service';
|
||||
|
||||
export const fetchAnalyticsChartData = async (key: string, category: string, days: number) => {
|
||||
try {
|
||||
const response = await authorizedFetch(`${process.env.CLIENTSIDE_API_DOMAIN}/api/v1/analytics/${key}/${category}?days=${days}`);
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
} catch (err) {
|
||||
console.error(`Could not fetch ${category} chart data`, err);
|
||||
return [];
|
||||
}
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
export const UNKNOWN_FAVICON = '/images/unkown-favicon.png';
|
||||
export const UNKNOWN_FAVICON_SMALL = '/images/unkown-favicon-small.png';
|
||||
|
||||
export const getFavicon = async (url: string) => {
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?sz=128&domain_url=${url}`;
|
||||
|
@ -2,18 +2,31 @@ import { DocumentHead, routeLoader$ } from '@builder.io/qwik-city';
|
||||
import { serverSideFetch } from '../../../../shared/auth.service';
|
||||
import { component$, useSignal } from '@builder.io/qwik';
|
||||
import { ClicksChart } from '../../../../components/dashboard/analytics/clicks-chart/clicks-chart';
|
||||
import { CountriesChart } from '../../../../components/dashboard/analytics/contries-chart/countries-chart';
|
||||
import { DevicesChart } from '../../../../components/dashboard/analytics/devices-chart/devices-chart';
|
||||
|
||||
export const useGetAnalytics = routeLoader$(async ({ params: { key }, cookie, redirect }) => {
|
||||
const res = await serverSideFetch(`${process.env.API_DOMAIN}/api/v1/analytics/${key}?days=7`, cookie);
|
||||
const [clicksResponse, countriesResponse, devicesResponse] = await Promise.all([
|
||||
serverSideFetch(`${process.env.API_DOMAIN}/api/v1/analytics/${key}?days=7`, cookie),
|
||||
serverSideFetch(`${process.env.API_DOMAIN}/api/v1/analytics/${key}/countries?days=7`, cookie),
|
||||
serverSideFetch(`${process.env.API_DOMAIN}/api/v1/analytics/${key}/devices?days=7`, cookie),
|
||||
]);
|
||||
|
||||
if (res.status !== 200) {
|
||||
if (clicksResponse.status !== 200 || countriesResponse.status !== 200 || devicesResponse.status !== 200) {
|
||||
throw redirect(302, '/unknown');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const [clicks, countries, devices] = await Promise.all([clicksResponse.json(), countriesResponse.json(), devicesResponse.json()]);
|
||||
|
||||
const data = { clicksOverTime: clicks, countries, devices };
|
||||
return {
|
||||
key,
|
||||
data,
|
||||
data: {
|
||||
clicksOverTime: clicks.clicksOverTime,
|
||||
countries: countries.data,
|
||||
devices: devices.data,
|
||||
url: clicks.url,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@ -54,6 +67,10 @@ export default component$(() => {
|
||||
initialData={analytics.value.data.clicksOverTime}
|
||||
url={analytics.value.data.url}
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 pt-4">
|
||||
<CountriesChart urlKey={analytics.value.key} daysDuration={daysDuration.value} initialData={analytics.value.data.countries} />
|
||||
<DevicesChart urlKey={analytics.value.key} daysDuration={daysDuration.value} initialData={analytics.value.data.devices} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
6
package-lock.json
generated
@ -38,6 +38,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"country-code-lookup": "^0.1.3",
|
||||
"geoip-lite": "^1.4.10",
|
||||
"isbot": "^5.1.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
@ -14746,6 +14747,11 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/country-code-lookup": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/country-code-lookup/-/country-code-lookup-0.1.3.tgz",
|
||||
"integrity": "sha512-gLu+AQKHUnkSQNTxShKgi/4tYd0vEEait3JMrLNZgYlmIZ9DJLkHUjzXE9qcs7dy3xY/kUx2/nOxZ0Z3D9JE+A=="
|
||||
},
|
||||
"node_modules/create-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||
|
@ -93,6 +93,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"country-code-lookup": "^0.1.3",
|
||||
"geoip-lite": "^1.4.10",
|
||||
"isbot": "^5.1.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
|