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

6
apps/web/TOOODOO.md Normal file
View File

@@ -0,0 +1,6 @@
- new org
- create project
- all trpc mutations seems to break in prod
- top event convertions
- create events_meta (name, color, icon)
- edit event convertion

View File

@@ -12,9 +12,9 @@
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@clerk/nextjs": "^4.29.6",
"@clerk/nextjs": "^4.29.7",
"@clickhouse/client": "^0.2.9",
"@hookform/resolvers": "^3.3.2",
"@hookform/resolvers": "^3.3.4",
"@mixan/common": "workspace:^",
"@mixan/db": "workspace:^",
"@mixan/queue": "workspace:^",
@@ -33,28 +33,28 @@
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@reduxjs/toolkit": "^1.9.7",
"@t3-oss/env-nextjs": "^0.7.0",
"@tanstack/react-query": "^4.32.6",
"@tanstack/react-table": "^8.10.7",
"@trpc/client": "^10.37.1",
"@trpc/next": "^10.37.1",
"@trpc/react-query": "^10.37.1",
"@trpc/server": "^10.37.1",
"@t3-oss/env-nextjs": "^0.7.3",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.11.8",
"@trpc/client": "^10.45.1",
"@trpc/next": "^10.45.1",
"@trpc/react-query": "^10.45.1",
"@trpc/server": "^10.45.1",
"bcrypt": "^5.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.1",
"hamburger-react": "^2.5.0",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.323.0",
"mathjs": "^12.3.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1",
"next": "~14.0.4",
"next-auth": "^4.23.0",
"next-auth": "^4.24.5",
"next-themes": "^0.2.1",
"nuqs": "^1.15.2",
"nuqs": "^1.16.1",
"prisma-error-enum": "^0.1.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
@@ -62,45 +62,48 @@
"react-animate-height": "^3.2.3",
"react-animated-numbers": "^0.18.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.47.0",
"react-hook-form": "^7.50.1",
"react-in-viewport": "1.0.0-alpha.30",
"react-redux": "^8.1.3",
"react-responsive": "^9.0.2",
"react-social-icons": "^6.12.0",
"react-svg-worldmap": "2.0.0-alpha.16",
"react-syntax-highlighter": "^15.5.0",
"react-virtualized-auto-sizer": "^1.0.20",
"recharts": "^2.8.0",
"react-use-websocket": "^4.7.0",
"react-virtualized-auto-sizer": "^1.0.22",
"recharts": "^2.12.0",
"request-ip": "^3.3.0",
"short-unique-id": "^5.0.3",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"superjson": "^1.13.1",
"superjson": "^1.13.3",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^2.9.1",
"usehooks-ts": "^2.14.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"@types/bcrypt": "^5.0.0",
"@types/bcrypt": "^5.0.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^18.16.0",
"@types/ramda": "^0.29.6",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-syntax-highlighter": "^15.5.9",
"@types/node": "^18.19.15",
"@types/ramda": "^0.29.10",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react-syntax-highlighter": "^15.5.11",
"@types/request-ip": "^0.0.41",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.48.0",
"postcss": "^8.4.27",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2"
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
},
"ct3aMetadata": {
"initVersion": "7.21.0"

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>

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

@@ -1,60 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
import { useRef, 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 dynamic from 'next/dynamic';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
import { getSafeJson } from '@mixan/common';
import type { IServiceCreateEventPayload } from '@mixan/db';
interface LiveCounterProps {
initialCount: number;
export interface LiveCounterProps {
data: number;
projectId: string;
}
export function LiveCounter({ initialCount = 0 }: LiveCounterProps) {
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(initialCount);
const { projectId } = useAppParams();
const [es] = useState(
typeof window != 'undefined' &&
new EventSource(`http://localhost:3333/live/events/${projectId}`)
);
const [counter, setCounter] = useState(data);
const [socketUrl] = useState(`${ws}/live/visitors/${projectId}`);
const lastRefresh = useRef(Date.now());
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) {
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',
});
toast('New event', {
description: `${parsed.event.name}`,
duration: 2000,
});
}
}
}
es.addEventListener('message', handler);
return () => es.removeEventListener('message', handler);
}, []);
},
});
return (
<Tooltip>
@@ -79,6 +73,9 @@ export function LiveCounter({ initialCount = 0 }: LiveCounterProps) {
transitions={(index) => ({
type: 'spring',
duration: index + 0.3,
damping: 10,
stiffness: 200,
})}
animateToNumber={counter}
locale="en"

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,

View File

@@ -22,7 +22,7 @@ export function ChartEmpty() {
return (
<div
className={
'aspect-video w-full max-h-[400px] flex justify-center items-center'
'aspect-video w-full max-h-[400px] min-h-[200px] flex justify-center items-center'
}
>
No data

View File

@@ -1,7 +1,18 @@
import { createContext, memo, useContext, useMemo } from 'react';
'use client';
import {
createContext,
memo,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type { IChartSerie } from '@/server/api/routers/chart';
import type { IChartInput } from '@/types';
import { ChartLoading } from './ChartLoading';
export interface ChartContextType extends IChartInput {
editMode?: boolean;
hideID?: boolean;
@@ -53,6 +64,16 @@ export function withChartProivder<ComponentProps>(
WrappedComponent: React.FC<ComponentProps>
) {
const WithChartProvider = (props: ComponentProps & ChartContextType) => {
const [mounted, setMounted] = useState(props.chartType === 'metric');
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <ChartLoading />;
}
return (
<ChartProvider {...props}>
<WrappedComponent {...props} />

View File

@@ -1,3 +1,5 @@
'use client';
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useNumber } from '@/hooks/useNumerFormatter';

View File

@@ -41,7 +41,7 @@ export function ReportAreaChart({
<>
<div
className={cn(
'max-sm:-mx-3',
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>

View File

@@ -1,35 +1,23 @@
import { useMemo, useState } from 'react';
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
'use client';
import { useMemo } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Progress } from '@/components/ui/progress';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { NOT_SET_VALUE } from '@/utils/constants';
import { getChartColor } from '@/utils/theme';
import { createColumnHelper } from '@tanstack/react-table';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
interface ReportBarChartProps {
data: IChartData;
}
export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode, metric, unit, onClick } = useChartContext();
const { editMode, metric, onClick } = useChartContext();
const number = useNumber();
const series = useMemo(
() => (editMode ? data.series : data.series.slice(0, 20)),
@@ -62,7 +50,10 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
)}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
>
<div className="flex-1 break-all">{serie.name}</div>
<div className="flex-1 break-all flex items-center gap-2">
<SerieIcon name={serie.name} />
{serie.name}
</div>
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
<div className="font-bold">

View File

@@ -19,9 +19,16 @@ interface ReportHistogramChartProps {
interval: IInterval;
}
function BarHover(props: any) {
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
const bg = theme?.colors?.slate?.['200'] as string;
return <rect {...props} rx="8" fill={bg} fill-opacity={0.5} />;
return (
<rect
{...{ x, y, width, height, top, left, right, bottom }}
rx="8"
fill={bg}
fillOpacity={0.5}
/>
);
}
export function ReportHistogramChart({
@@ -38,7 +45,7 @@ export function ReportHistogramChart({
<>
<div
className={cn(
'max-sm:-mx-3',
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>

View File

@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
@@ -41,7 +43,7 @@ export function ReportLineChart({
<>
<div
className={cn(
'max-sm:-mx-3',
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>

View File

@@ -1,3 +1,5 @@
'use client';
import type { IChartData } from '@/app/_trpc/client';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';

View File

@@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/Pagination';

View File

@@ -0,0 +1,65 @@
import { NOT_SET_VALUE } from '@/utils/constants';
import type { LucideIcon, LucideProps } from 'lucide-react';
import {
ActivityIcon,
ExternalLinkIcon,
HelpCircleIcon,
MonitorIcon,
MonitorPlayIcon,
PhoneIcon,
SmartphoneIcon,
SquareAsteriskIcon,
TabletIcon,
TabletSmartphoneIcon,
TwitterIcon,
} from 'lucide-react';
import {
getKeys,
getNetworks,
networkFor,
register,
SocialIcon,
} from 'react-social-icons';
interface SerieIconProps extends LucideProps {
name: string;
}
const mapper: Record<string, LucideIcon> = {
screen_view: MonitorPlayIcon,
session_start: ActivityIcon,
link_out: ExternalLinkIcon,
mobile: SmartphoneIcon,
desktop: MonitorIcon,
tablet: TabletIcon,
[NOT_SET_VALUE]: HelpCircleIcon,
};
const networks = getNetworks();
register('duckduckgo', {
color: 'red',
path: 'https://duckduckgo.com/favicon.ico',
});
export function SerieIcon({ name, ...props }: SerieIconProps) {
let Icon = mapper[name] ?? null;
if (name.includes('http')) {
Icon = ((_props) => (
<SocialIcon network={networkFor(name)} />
)) as LucideIcon;
}
if (Icon === null && networks.includes(name.toLowerCase())) {
Icon = ((_props) => (
<SocialIcon network={name.toLowerCase()} />
)) as LucideIcon;
}
return (
<div className="w-4 h-4 flex-shrink-0 relative [&_a]:!w-4 [&_a]:!h-4 [&_svg]:!rounded">
{Icon ? <Icon size={16} {...props} /> : null}
</div>
);
}

View File

@@ -1,12 +1,12 @@
'use client';
import { memo } from 'react';
import { memo, useEffect, useState } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
import { ChartEmpty } from './ChartEmpty';
import { ChartLoading } from './ChartLoading';
import { withChartProivder } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
@@ -33,9 +33,8 @@ export const Chart = memo(
formula,
unit,
metric,
initialData,
projectId,
}: ReportChartProps) {
const params = useAppParams();
const [data] = api.chart.chart.useSuspenseQuery(
{
// dont send lineType since it does not need to be sent
@@ -48,7 +47,7 @@ export const Chart = memo(
range,
startDate: null,
endDate: null,
projectId: params.projectId,
projectId,
previous,
formula,
unit,
@@ -56,7 +55,6 @@ export const Chart = memo(
},
{
keepPreviousData: true,
initialData,
}
);

View File

@@ -1,11 +1,11 @@
import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import type { IChartEvent } from '@/types';
import { cn } from '@/utils/cn';
import { DatabaseIcon, FilterIcon } from 'lucide-react';
import { DatabaseIcon } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
import { changeEvent } from '../reportSlice';
interface EventPropertiesComboboxProps {
@@ -16,7 +16,7 @@ export function EventPropertiesCombobox({
event,
}: EventPropertiesComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useAppParams();
const { projectId } = useChartContext();
const query = api.chart.properties.useQuery(
{

View File

@@ -3,21 +3,21 @@
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartBreakdown } from '@/types';
import { SplitIcon } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
const params = useAppParams();
const { projectId } = useChartContext();
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery({
projectId: params.projectId,
projectId,
});
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
value: item,

View File

@@ -6,12 +6,12 @@ import { Dropdown } from '@/components/Dropdown';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types';
import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
import {
addEvent,
changeEvent,
@@ -28,9 +28,9 @@ export function ReportEvents() {
const previous = useSelector((state) => state.report.previous);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const params = useAppParams();
const { projectId } = useChartContext();
const eventsQuery = api.chart.events.useQuery({
projectId: params.projectId,
projectId,
});
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
value: item.name,

View File

@@ -4,7 +4,6 @@ import { Dropdown } from '@/components/Dropdown';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
import { useDispatch } from '@/redux';
import type {
@@ -14,8 +13,8 @@ import type {
} from '@/types';
import { operators } from '@/utils/constants';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { useParams } from 'next/navigation';
import { useChartContext } from '../../chart/ChartProvider';
import { changeEvent } from '../../reportSlice';
interface FilterProps {
@@ -24,7 +23,7 @@ interface FilterProps {
}
export function FilterItem({ filter, event }: FilterProps) {
const { projectId } = useAppParams();
const { projectId } = useChartContext();
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({

View File

@@ -1,10 +1,10 @@
import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import type { IChartEvent } from '@/types';
import { FilterIcon } from 'lucide-react';
import { useChartContext } from '../../chart/ChartProvider';
import { changeEvent } from '../../reportSlice';
interface FiltersComboboxProps {
@@ -13,7 +13,7 @@ interface FiltersComboboxProps {
export function FiltersCombobox({ event }: FiltersComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useAppParams();
const { projectId } = useChartContext();
const query = api.chart.properties.useQuery(
{

View File

@@ -1,3 +1,5 @@
'use client';
import { useMemo } from 'react';
import type { IChartData, IChartSerieDataItem } from '@/app/_trpc/client';
import { getChartColor } from '@/utils/theme';

View File

@@ -1,19 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react';
'use client';
import { useMemo, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
export type IVisibleSeries = ReturnType<typeof useVisibleSeries>['series'];
export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
const max = limit ?? 5;
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
const ref = useRef(false);
useEffect(() => {
if (!ref.current && data) {
setVisibleSeries(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
);
// ref.current = true;
}
}, [data, max]);
const [visibleSeries, setVisibleSeries] = useState<string[]>(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
);
return useMemo(() => {
return {

View File

@@ -4,7 +4,7 @@ import { authMiddleware } from '@clerk/nextjs';
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({
publicRoutes: [],
publicRoutes: ['/share/overview/:id', '/api/trpc/chart.chart'],
});
export const config = {

View File

@@ -0,0 +1,96 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useAppParams } from '@/hooks/useAppParams';
import { zShareOverview } from '@/utils/validation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = zShareOverview;
type IForm = z.infer<typeof validator>;
export default function ShareOverviewModal() {
const { projectId, organizationId: organizationSlug } = useAppParams();
const router = useRouter();
const { register, handleSubmit, formState, control } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
public: true,
password: '',
projectId,
organizationId: organizationSlug,
},
});
const mutation = api.share.shareOverview.useMutation({
onError: handleError,
onSuccess(res) {
router.refresh();
toast('Success', {
description: `Your overview is now ${
res.public ? 'public' : 'private'
}`,
});
popModal();
},
});
return (
<ModalContent>
<ModalHeader title="Overview access" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Controller
name="public"
control={control}
render={({ field }) => (
<label
htmlFor="public"
className="flex items-center gap-2 text-sm font-medium leading-none mb-4"
>
<Checkbox
id="public"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
Make it public!
</label>
)}
/>
<InputWithLabel
label="Password"
placeholder="Make your overview accessable with password"
{...register('password')}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" loading={mutation.isLoading}>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -42,6 +42,9 @@ const modals = {
EditReport: dynamic(() => import('./EditReport'), {
loading: Loading,
}),
ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), {
loading: Loading,
}),
};
const emitter = mitt<{

View File

@@ -8,6 +8,7 @@ import { organizationRouter } from './routers/organization';
import { profileRouter } from './routers/profile';
import { projectRouter } from './routers/project';
import { reportRouter } from './routers/report';
import { shareRouter } from './routers/share';
import { uiRouter } from './routers/ui';
import { userRouter } from './routers/user';
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
event: eventRouter,
profile: profileRouter,
ui: uiRouter,
share: shareRouter,
});
// export type definition of API

View File

@@ -226,10 +226,9 @@ export async function getChartData(payload: IGetChartDataInput) {
return Object.keys(series).map((key) => {
// If we have breakdowns, we want to use the breakdown key as the legend
// But only if it successfully broke it down, otherwise we use the getEventLabel
const serieName =
payload.breakdowns.length && !alphabetIds.includes(key as 'A')
? key
: getEventLegend(payload.event);
const isBreakdown =
payload.breakdowns.length && !alphabetIds.includes(key as 'A');
const serieName = isBreakdown ? key : getEventLegend(payload.event);
const data =
payload.chartType === 'area' ||
payload.chartType === 'linear' ||

View File

@@ -1,4 +1,8 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import type { IChartEvent, IChartInput, IChartRange } from '@/types';
import { getDaysOldDate } from '@/utils/date';
import { average, max, min, round, sum } from '@/utils/math';
@@ -103,7 +107,8 @@ export const chartRouter = createTRPCRouter({
)(properties);
}),
values: protectedProcedure
// TODO: Make this private
values: publicProcedure
.input(
z.object({
event: z.string(),
@@ -135,7 +140,8 @@ export const chartRouter = createTRPCRouter({
};
}),
chart: protectedProcedure.input(zChartInput).query(async ({ input }) => {
// TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
const current = getDatesFromRange(input.range);
let diff = 0;
@@ -313,6 +319,11 @@ export const chartRouter = createTRPCRouter({
}
});
// await new Promise((res) => {
// setTimeout(() => {
// res();
// }, 100);
// });
return final;
}),
});
@@ -329,8 +340,8 @@ function getPreviousMetric(
((current > previous
? current / previous
: current < previous
? previous / current
: 0) -
? previous / current
: 0) -
1) *
100,
1
@@ -345,8 +356,8 @@ function getPreviousMetric(
current > previous
? 'positive'
: current < previous
? 'negative'
: 'neutral',
? 'negative'
: 'neutral',
value: previous,
};
}

View File

@@ -0,0 +1,29 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { zShareOverview } from '@/utils/validation';
import ShortUniqueId from 'short-unique-id';
const uid = new ShortUniqueId({ length: 6 });
export const shareRouter = createTRPCRouter({
shareOverview: protectedProcedure
.input(zShareOverview)
.mutation(({ input }) => {
return db.shareOverview.upsert({
where: {
project_id: input.projectId,
},
create: {
id: uid.rnd(),
organization_slug: input.organizationId,
project_id: input.projectId,
public: input.public,
password: input.password || null,
},
update: {
public: input.public,
password: input.password,
},
});
}),
});

View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
const title = 'Openpanel.dev | An open-source alternative to Mixpanel';
const description =
'Unlock actionable insights effortlessly with Insightful, the open-source analytics library that combines the power of Mixpanel with the simplicity of Plausible. Enjoy a unified overview, predictable pricing, and a vibrant community. Join us in democratizing analytics today!';
export const defaultMeta: Metadata = {
title,
description,
openGraph: {
title,
images: [
{
url: 'https://openpanel.dev/ogimage.png',
width: 1200,
height: 630,
alt: title,
},
],
},
};

View File

@@ -79,3 +79,10 @@ export const zInviteUser = z.object({
organizationSlug: z.string(),
role: z.enum(['admin', 'org:member']),
});
export const zShareOverview = z.object({
organizationId: z.string(),
projectId: z.string(),
password: z.string().nullable(),
public: z.boolean(),
});