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

@@ -1,93 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import AnimatedNumbers from 'react-animated-numbers';
import { toast } from 'sonner';
import { getSafeJson } from '@mixan/common';
import type { IServiceCreateEventPayload } from '@mixan/db';
interface LiveCounterProps {
initialCount: number;
}
export function LiveCounter({ initialCount = 0 }: LiveCounterProps) {
const client = useQueryClient();
const [counter, setCounter] = useState(initialCount);
const { projectId } = useAppParams();
const [es] = useState(
typeof window != 'undefined' &&
new EventSource(`http://localhost:3333/live/events/${projectId}`)
);
useEffect(() => {
if (!es) {
return () => {};
}
function handler(event: MessageEvent<string>) {
const parsed = getSafeJson<{
visitors: number;
event: IServiceCreateEventPayload | null;
}>(event.data);
if (parsed) {
setCounter(parsed.visitors);
if (parsed.event) {
client.refetchQueries({
type: 'active',
});
toast('New event', {
description: `${parsed.event.name}`,
duration: 2000,
});
}
}
}
es.addEventListener('message', handler);
return () => es.removeEventListener('message', handler);
}, []);
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,
})}
animateToNumber={counter}
locale="en"
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter} unique visitors last 5 minutes
</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,45 +1,27 @@
'use client';
import { Suspense } from 'react';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { MetricCardLoading } from '@/components/report/chart/MetricCard';
import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Widget, WidgetBody } from '@/components/Widget';
import type { IChartInput } from '@/types';
import { cn } from '@/utils/cn';
import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
import Link from 'next/link';
import { StickyBelowHeader } from './layout-sticky-below-header';
import { LiveCounter } from './live-counter';
interface OverviewMetricsProps {
projectId: string;
}
export default function OverviewMetrics() {
const { previous, range, setRange, interval, metric, setMetric, filters } =
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { previous, range, interval, metric, setMetric, filters } =
useOverviewOptions();
const reports = [
{
id: 'Unique visitors',
projectId: '', // TODO: Remove
projectId,
events: [
{
segment: 'user',
@@ -60,7 +42,7 @@ export default function OverviewMetrics() {
},
{
id: 'Total sessions',
projectId: '', // TODO: Remove
projectId,
events: [
{
segment: 'event',
@@ -81,7 +63,7 @@ export default function OverviewMetrics() {
},
{
id: 'Total pageviews',
projectId: '', // TODO: Remove
projectId,
events: [
{
segment: 'event',
@@ -102,7 +84,7 @@ export default function OverviewMetrics() {
},
{
id: 'Views per session',
projectId: '', // TODO: Remove
projectId,
events: [
{
segment: 'user_average',
@@ -123,7 +105,7 @@ export default function OverviewMetrics() {
},
{
id: 'Bounce rate',
projectId: '', // TODO: Remove
projectId,
events: [
{
segment: 'event',
@@ -161,7 +143,7 @@ export default function OverviewMetrics() {
},
{
id: 'Visit duration',
projectId: '', // TODO: Remove
projectId,
events: [
{
segment: 'property_average',
@@ -196,90 +178,37 @@ export default function OverviewMetrics() {
const selectedMetric = reports[metric]!;
return (
<Sheet>
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<ReportRange value={range} onChange={(value) => setRange(value)} />
<SheetTrigger asChild>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
</div>
<div className="flex gap-2">
<LiveCounter initialCount={0} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={Globe2Icon} responsive>
Public
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/project/4e2798cb-e255-4e9d-960d-c9ad095aabd7`}
>
<Eye size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={(event) => {}}>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</StickyBelowHeader>
<div className="p-4 flex gap-2 flex-wrap">
<OverviewFiltersButtons />
</div>
<div className="p-4 grid gap-4 grid-cols-6">
{reports.map((report, index) => (
<button
key={index}
className="relative col-span-6 md:col-span-3 lg:col-span-2 group"
onClick={() => {
setMetric(index);
}}
>
<Suspense fallback={<MetricCardLoading />}>
<Chart hideID {...report} />
</Suspense>
{/* add active border */}
<div
className={cn(
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-chart-0 ring-chart-0',
metric === index ? 'opacity-100' : 'opacity-0'
)}
/>
</button>
))}
<Widget className="col-span-6">
<WidgetHead>
<div className="title">{selectedMetric.events[0]?.displayName}</div>
</WidgetHead>
<WidgetBody>
<Suspense fallback={<ChartLoading />}>
<Chart hideID {...selectedMetric} chartType="linear" />
</Suspense>
</WidgetBody>
</Widget>
<OverviewTopSources />
<OverviewTopPages />
<OverviewTopDevices />
<OverviewTopEvents />
<div className="col-span-6">
<OverviewTopGeo />
</div>
</div>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFilters />
</SheetContent>
</Sheet>
<>
{reports.map((report, index) => (
<button
key={index}
className="relative col-span-6 md:col-span-3 lg:col-span-2 group"
onClick={() => {
setMetric(index);
}}
>
<Suspense fallback={<MetricCardLoading />}>
<Chart hideID {...report} />
</Suspense>
<div
className={cn(
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-chart-0 ring-chart-0',
metric === index ? 'opacity-100' : 'opacity-0'
)}
/>
{/* add active border */}
</button>
))}
<Widget className="col-span-6">
<WidgetHead>
<div className="title">{selectedMetric.events[0]?.displayName}</div>
</WidgetHead>
<WidgetBody>
<Suspense fallback={<ChartLoading />}>
<Chart hideID {...selectedMetric} chartType="linear" />
</Suspense>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';
import { SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
export function OverviewReportRange() {
const { previous, range, setRange, interval, metric, setMetric, filters } =
useOverviewOptions();
return <ReportRange value={range} onChange={(value) => setRange(value)} />;
}
export function OverviewFilterSheetTrigger() {
const { previous, range, setRange, interval, metric, setMetric, filters } =
useOverviewOptions();
return (
<SheetTrigger asChild>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
);
}

View File

@@ -1,7 +1,24 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import { OverviewShare } from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { getExists } from '@/server/pageExists';
import { db } from '@mixan/db';
import { StickyBelowHeader } from './layout-sticky-below-header';
import OverviewMetrics from './overview-metrics';
import {
OverviewFilterSheetTrigger,
OverviewReportRange,
} from './overview-sticky-header';
interface PageProps {
params: {
@@ -9,14 +26,49 @@ interface PageProps {
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
const [share] = await Promise.all([
db.shareOverview.findUnique({
where: {
project_id: projectId,
},
}),
getExists(organizationId, projectId),
]);
return (
<PageLayout title="Overview" organizationSlug={organizationId}>
<OverviewMetrics />
<Sheet>
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFilterSheetTrigger />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
<OverviewShare data={share} />
</div>
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
<div className="col-span-6 flex flex-wrap gap-2">
<OverviewFiltersButtons />
</div>
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<div className="col-span-6">
<OverviewTopGeo projectId={projectId} />
</div>
</div>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFilters projectId={projectId} />
</SheetContent>
</Sheet>
</PageLayout>
);
}

View File

@@ -1,36 +0,0 @@
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
import { Logo } from '@/components/Logo';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getProjectById } from '@/server/services/project.service';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const project = await getProjectById(projectId);
const organization = await getOrganizationBySlug(organizationId);
return (
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-end mb-4">
<div className="leading-none">
<span className="text-white mb-4">{organization?.name}</span>
<h1 className="text-white text-xl font-medium">{project?.name}</h1>
</div>
<a href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share">
<Logo className="text-white" />
</a>
</div>
<div className="bg-white rounded-lg shadow">
<OverviewMetrics />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
import {
OverviewFilterSheetTrigger,
OverviewReportRange,
} from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
import { Logo } from '@/components/Logo';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { notFound } from 'next/navigation';
import { getShareOverviewById } from '@mixan/db';
interface PageProps {
params: {
id: string;
};
}
export default async function Page({ params: { id } }: PageProps) {
const share = await getShareOverviewById(id);
if (!share) {
return notFound();
}
const projectId = share.project_id;
const organization = await getOrganizationBySlug(share.organization_slug);
return (
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-end mb-4">
<div className="leading-none">
<span className="text-white mb-4">{organization?.name}</span>
<h1 className="text-white text-xl font-medium">
{share.project?.name}
</h1>
</div>
<a href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share">
<Logo className="text-white" />
</a>
</div>
<div className="bg-white rounded-lg shadow ring-8 ring-blue-600/50">
<Sheet>
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFilterSheetTrigger />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
</div>
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
<div className="col-span-6 flex flex-wrap gap-2">
<OverviewFiltersButtons />
</div>
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<div className="col-span-6">
<OverviewTopGeo projectId={projectId} />
</div>
</div>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFilters projectId={projectId} />
</SheetContent>
</Sheet>
</div>
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { ClerkProvider } from '@clerk/nextjs';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpLink } from '@trpc/client';
import { Provider as ReduxProvider } from 'react-redux';
import { Toaster } from 'sonner';
import superjson from 'superjson';
export default function Providers({ children }: { children: React.ReactNode }) {
@@ -49,6 +50,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}>
{children}
<Toaster />
<ModalProvider />
</TooltipProvider>
</QueryClientProvider>