This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-13 11:25:14 +01:00
parent 034be63ac0
commit 7f2c0f6cf0
64 changed files with 5820 additions and 1160 deletions

View File

@@ -0,0 +1,11 @@
import { getLiveVisitors } from '@mixan/db';
import type { LiveCounterProps } from './live-counter';
import LiveCounter from './live-counter';
export default async function ServerLiveCounter(
props: Omit<LiveCounterProps, 'data'>
) {
const count = await getLiveVisitors(props.projectId);
return <LiveCounter data={count} {...props} />;
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useRef, useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
export interface LiveCounterProps {
data: number;
projectId: string;
}
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
ssr: false,
loading: () => <div>0</div>,
});
const FIFTEEN_SECONDS = 1000 * 15;
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
const client = useQueryClient();
const [counter, setCounter] = useState(data);
const [socketUrl] = useState(`${ws}/live/visitors/${projectId}`);
const lastRefresh = useRef(Date.now());
useWebSocket(socketUrl, {
shouldReconnect: () => true,
onMessage(event) {
const value = parseInt(event.data, 10);
if (!isNaN(value)) {
setCounter(value);
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
lastRefresh.current = Date.now();
toast('Refreshed data');
client.refetchQueries({
type: 'active',
});
}
}
},
});
return (
<Tooltip>
<TooltipTrigger>
<div className="border border-border rounded h-8 px-3 leading-none flex items-center font-medium gap-2">
<div className="relative">
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all',
counter === 0 && 'bg-destructive opacity-0'
)}
></div>
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all',
counter === 0 && 'bg-destructive'
)}
></div>
</div>
<AnimatedNumbers
includeComma
transitions={(index) => ({
type: 'spring',
duration: index + 0.3,
damping: 10,
stiffness: 200,
})}
animateToNumber={counter}
locale="en"
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter} unique visitors last 5 minutes
</TooltipContent>
</Tooltip>
);
}

View File

@@ -20,6 +20,28 @@ export function OverviewFiltersButtons() {
<strong>{options.referrer}</strong>
</Button>
)}
{options.referrerName && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setReferrerName(null)}
>
<span className="mr-1">Referrer name is</span>
<strong>{options.referrerName}</strong>
</Button>
)}
{options.referrerType && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setReferrerType(null)}
>
<span className="mr-1">Referrer type is</span>
<strong>{options.referrerType}</strong>
</Button>
)}
{options.device && (
<Button
size="sm"

View File

@@ -8,8 +8,10 @@ import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFilters() {
const { projectId } = useAppParams();
interface OverviewFiltersProps {
projectId: string;
}
export function OverviewFilters({ projectId }: OverviewFiltersProps) {
const options = useOverviewOptions();
const { data: referrers } = api.chart.values.useQuery({

View File

@@ -0,0 +1,76 @@
'use client';
import { api } from '@/app/_trpc/client';
import { pushModal } from '@/modals';
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import type { ShareOverview } from '@mixan/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
interface OverviewShareProps {
data: ShareOverview | null;
}
export function OverviewShare({ data }: OverviewShareProps) {
const router = useRouter();
const mutation = api.share.shareOverview.useMutation({
onSuccess() {
router.refresh();
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={data && data.public ? Globe2Icon : LockIcon} responsive>
{data && data.public ? 'Public' : 'Private'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{(!data || data.public === false) && (
<DropdownMenuItem onClick={() => pushModal('ShareOverviewModal')}>
<Globe2Icon size={16} className="mr-2" />
Make public
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem asChild>
<Link
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/overview/${data.id}`}
>
<EyeIcon size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem
onClick={() => {
mutation.mutate({
public: false,
projectId: data?.project_id,
organizationId: data?.organization_slug,
password: null,
});
}}
>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -10,7 +10,12 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopDevices() {
interface OverviewTopDevicesProps {
projectId: string;
}
export default function OverviewTopDevices({
projectId,
}: OverviewTopDevicesProps) {
const {
filters,
interval,
@@ -18,19 +23,15 @@ export default function OverviewTopDevices() {
previous,
setBrowser,
setBrowserVersion,
browser,
browserVersion,
setOS,
setOSVersion,
os,
osVersion,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
btn: 'Devices',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'user',
@@ -58,7 +59,7 @@ export default function OverviewTopDevices() {
title: 'Top browser',
btn: 'Browser',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'user',
@@ -86,7 +87,7 @@ export default function OverviewTopDevices() {
title: 'Top Browser Version',
btn: 'Browser Version',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'user',
@@ -114,7 +115,7 @@ export default function OverviewTopDevices() {
title: 'Top OS',
btn: 'OS',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'user',
@@ -142,7 +143,7 @@ export default function OverviewTopDevices() {
title: 'Top OS version',
btn: 'OS Version',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'user',

View File

@@ -10,14 +10,19 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopEvents() {
interface OverviewTopEventsProps {
projectId: string;
}
export default function OverviewTopEvents({
projectId,
}: OverviewTopEventsProps) {
const { filters, interval, range, previous } = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',
btn: 'All',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',

View File

@@ -10,7 +10,10 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopGeo() {
interface OverviewTopGeoProps {
projectId: string;
}
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
@@ -18,7 +21,7 @@ export default function OverviewTopGeo() {
title: 'Map',
btn: 'Map',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -46,7 +49,7 @@ export default function OverviewTopGeo() {
title: 'Top countries',
btn: 'Countries',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -74,7 +77,7 @@ export default function OverviewTopGeo() {
title: 'Top regions',
btn: 'Regions',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -102,7 +105,7 @@ export default function OverviewTopGeo() {
title: 'Top cities',
btn: 'Cities',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',

View File

@@ -10,14 +10,17 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopPages() {
interface OverviewTopPagesProps {
projectId: string;
}
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { filters, interval, range, previous, setPage } = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: {
title: 'Top pages',
btn: 'Top pages',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -45,7 +48,7 @@ export default function OverviewTopPages() {
title: 'Entry Pages',
btn: 'Entries',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -73,7 +76,7 @@ export default function OverviewTopPages() {
title: 'Exit Pages',
btn: 'Exits',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',

View File

@@ -10,7 +10,12 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopSources() {
interface OverviewTopSourcesProps {
projectId: string;
}
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const {
filters,
interval,
@@ -22,13 +27,43 @@ export default function OverviewTopSources() {
setUtmCampaign,
setUtmTerm,
setUtmContent,
setReferrerName,
setReferrerType,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
all: {
title: 'Top sources',
btn: 'All',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top groups',
range: range,
previous: previous,
metric: 'sum',
},
},
domain: {
title: 'Top urls',
btn: 'URLs',
chart: {
projectId,
events: [
{
segment: 'event',
@@ -52,11 +87,39 @@ export default function OverviewTopSources() {
metric: 'sum',
},
},
type: {
title: 'Top types',
btn: 'Types',
chart: {
projectId,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_type',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top types',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_source: {
title: 'UTM Source',
btn: 'Source',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -84,7 +147,7 @@ export default function OverviewTopSources() {
title: 'UTM Medium',
btn: 'Medium',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -112,7 +175,7 @@ export default function OverviewTopSources() {
title: 'UTM Campaign',
btn: 'Campaign',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -140,7 +203,7 @@ export default function OverviewTopSources() {
title: 'UTM Term',
btn: 'Term',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -168,7 +231,7 @@ export default function OverviewTopSources() {
title: 'UTM Content',
btn: 'Content',
chart: {
projectId: '',
projectId,
events: [
{
segment: 'event',
@@ -220,8 +283,16 @@ export default function OverviewTopSources() {
onClick={(item) => {
switch (widget.key) {
case 'all':
setReferrerName(item.name);
setWidget('domain');
break;
case 'domain':
setReferrer(item.name);
break;
case 'type':
setReferrerType(item.name);
setWidget('domain');
break;
case 'utm_source':
setUtmSource(item.name);
break;

View File

@@ -33,7 +33,7 @@ export function WidgetButtons({
}: WidgetHeadProps) {
const container = useRef<HTMLDivElement>(null);
const sizes = useRef<number[]>([]);
const [slice, setSlice] = useState(Children.count(children) - 1);
const [slice, setSlice] = useState(-1);
const gap = 8;
const handleResize = useThrottle(() => {

View File

@@ -30,12 +30,22 @@ export function useOverviewOptions() {
);
// Filters
const [page, setPage] = useQueryState(
'page',
parseAsString.withOptions(nuqsOptions)
);
// Referrer
const [referrer, setReferrer] = useQueryState(
'referrer',
parseAsString.withOptions(nuqsOptions)
);
const [page, setPage] = useQueryState(
'page',
const [referrerName, setReferrerName] = useQueryState(
'referrer_name',
parseAsString.withOptions(nuqsOptions)
);
const [referrerType, setReferrerType] = useQueryState(
'referrer_type',
parseAsString.withOptions(nuqsOptions)
);
@@ -99,14 +109,6 @@ export function useOverviewOptions() {
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer',
value: [referrer],
});
}
if (page) {
filters.push({
@@ -126,6 +128,33 @@ export function useOverviewOptions() {
});
}
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer',
value: [referrer],
});
}
if (referrerName) {
filters.push({
id: 'referrer_name',
operator: 'is',
name: 'referrer_name',
value: [referrerName],
});
}
if (referrerType) {
filters.push({
id: 'referrer_type',
operator: 'is',
name: 'referrer_type',
value: [referrerType],
});
}
if (utmSource) {
filters.push({
id: 'utm_source',
@@ -236,9 +265,11 @@ export function useOverviewOptions() {
return filters;
}, [
referrer,
page,
device,
referrer,
referrerName,
referrerType,
utmSource,
utmMedium,
utmCampaign,
@@ -260,8 +291,6 @@ export function useOverviewOptions() {
setRange,
metric,
setMetric,
referrer,
setReferrer,
page,
setPage,
@@ -269,6 +298,14 @@ export function useOverviewOptions() {
interval,
filters,
// Refs
referrer,
setReferrer,
referrerName,
setReferrerName,
referrerType,
setReferrerType,
// UTM
utmSource,
setUtmSource,