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

File diff suppressed because it is too large Load Diff

View File

@@ -11,12 +11,12 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/websocket": "^8.3.1",
"@mixan/common": "workspace:*", "@mixan/common": "workspace:*",
"@mixan/db": "workspace:*", "@mixan/db": "workspace:*",
"@mixan/queue": "workspace:*", "@mixan/queue": "workspace:*",
"@mixan/redis": "workspace:*", "@mixan/redis": "workspace:*",
"fastify": "^4.25.2", "fastify": "^4.25.2",
"fastify-sse-v2": "^3.1.2",
"pino": "^8.17.2", "pino": "^8.17.2",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"ua-parser-js": "^1.0.37" "ua-parser-js": "^1.0.37"
@@ -28,6 +28,7 @@
"@mixan/types": "workspace:*", "@mixan/types": "workspace:*",
"@types/ramda": "^0.29.6", "@types/ramda": "^0.29.6",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@types/ws": "^8.5.10",
"eslint": "^8.48.0", "eslint": "^8.48.0",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"tsup": "^7.2.0", "tsup": "^7.2.0",

View File

@@ -1,96 +1,80 @@
import { combine } from '@/sse/combine';
import { redisMessageIterator } from '@/sse/redis-message-iterator';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import type * as WebSocket from 'ws';
import { getSafeJson } from '@mixan/common'; import { getSafeJson } from '@mixan/common';
import type { IServiceCreateEventPayload } from '@mixan/db'; import type { IServiceCreateEventPayload } from '@mixan/db';
import { chQuery, getEvents } from '@mixan/db'; import { getEvents, getLiveVisitors } from '@mixan/db';
import { redis, redisPub, redisSub } from '@mixan/redis'; import { redis, redisPub, redisSub } from '@mixan/redis';
async function getLiveCount(projectId: string) { export function getLiveEventInfo(key: string) {
const keys = await redis.keys(`live:event:${projectId}:*`);
return keys.length;
}
function getLiveEventInfo(key: string) {
return key.split(':').slice(2) as [string, string]; return key.split(':').slice(2) as [string, string];
} }
export async function test(request: FastifyRequest, reply: FastifyReply) { export async function test(
req: FastifyRequest<{
Params: {
projectId: string;
};
}>,
reply: FastifyReply
) {
const [event] = await getEvents( const [event] = await getEvents(
`SELECT * FROM events LIMIT 1 OFFSET ${Math.floor(Math.random() * 1000)}` `SELECT * FROM events WHERE project_id = '${req.params.projectId}' AND name = 'screen_view' LIMIT 1`
); );
if (!event) { if (!event) {
return reply.status(404).send('No event found'); return reply.status(404).send('No event found');
} }
redisPub.publish('event', JSON.stringify(event)); redisPub.publish('event', JSON.stringify(event));
redis.set(`live:event:${event.projectId}:${event.profileId}`, '', 'EX', 10); redis.set(
reply.status(202).send('OK'); `live:event:${event.projectId}:${Math.random() * 1000}`,
'',
'EX',
10
);
reply.status(202).send(event);
} }
export function events( export function wsVisitors(
request: FastifyRequest<{ connection: {
Params: { projectId: string }; socket: WebSocket;
}>, },
reply: FastifyReply req: FastifyRequest<{
Params: {
projectId: string;
};
}>
) { ) {
const reqProjectId = request.params.projectId; const { params } = req;
// Subscribe
redisSub.subscribe('event'); redisSub.subscribe('event');
redisSub.psubscribe('__key*:*'); redisSub.psubscribe('__key*:expired');
const listeners: ((...args: any[]) => void)[] = [];
const incomingEvents = redisMessageIterator({ const message = (channel: string, message: string) => {
listenOn: 'message', if (channel === 'event') {
async transformer(message) {
const event = getSafeJson<IServiceCreateEventPayload>(message); const event = getSafeJson<IServiceCreateEventPayload>(message);
if (event && event.projectId === reqProjectId) { if (event?.projectId === params.projectId) {
return { getLiveVisitors(params.projectId).then((count) => {
visitors: await getLiveCount(event.projectId), connection.socket.send(String(count));
event,
};
}
return null;
},
registerListener(fn) {
listeners.push(fn);
},
}); });
}
const expiredEvents = redisMessageIterator({ }
listenOn: 'pmessage', };
async transformer(message) { const pmessage = (pattern: string, channel: string, message: string) => {
// message = live:event:${projectId}:${profileId}
const [projectId] = getLiveEventInfo(message); const [projectId] = getLiveEventInfo(message);
if (projectId && projectId === reqProjectId) { if (projectId && projectId === params.projectId) {
return { getLiveVisitors(params.projectId).then((count) => {
visitors: await getLiveCount(projectId), connection.socket.send(String(count));
event: null as null | IServiceCreateEventPayload,
};
}
return null;
},
registerListener(fn) {
listeners.push(fn);
},
}); });
}
async function* consumeMessages() {
for await (const result of combine([incomingEvents, expiredEvents])) {
if (result.data) {
yield {
data: JSON.stringify(result.data),
}; };
}
}
}
reply.sse(consumeMessages()); redisSub.on('message', message);
redisSub.on('pmessage', pmessage);
reply.raw.on('close', () => { connection.socket.on('close', () => {
redisSub.unsubscribe('event'); redisSub.unsubscribe('event');
redisSub.punsubscribe('__key*:expired'); redisSub.punsubscribe('__key*:expired');
listeners.forEach((listener) => redisSub.off('message', listener)); redisSub.off('message', message);
redisSub.off('pmessage', pmessage);
}); });
} }

View File

@@ -1,6 +1,5 @@
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import Fastify from 'fastify'; import Fastify from 'fastify';
import { FastifySSEPlugin } from 'fastify-sse-v2';
import pino from 'pino'; import pino from 'pino';
import { redisPub } from '@mixan/redis'; import { redisPub } from '@mixan/redis';
@@ -29,7 +28,6 @@ const startServer = async () => {
origin: '*', origin: '*',
}); });
fastify.register(FastifySSEPlugin);
fastify.decorateRequest('projectId', ''); fastify.decorateRequest('projectId', '');
fastify.register(eventRouter, { prefix: '/event' }); fastify.register(eventRouter, { prefix: '/event' });
fastify.register(profileRouter, { prefix: '/profile' }); fastify.register(profileRouter, { prefix: '/profile' });

View File

@@ -1,18 +1,25 @@
import * as controller from '@/controllers/live.controller'; import * as controller from '@/controllers/live.controller';
import fastifyWS from '@fastify/websocket';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
const liveRouter: FastifyPluginCallback = (fastify, opts, done) => { const liveRouter: FastifyPluginCallback = (fastify, opts, done) => {
fastify.route({ fastify.route({
method: 'GET', method: 'GET',
url: '/events/test', url: '/events/test/:projectId',
handler: controller.test, handler: controller.test,
}); });
fastify.route({ fastify.register(fastifyWS);
method: 'GET',
url: '/events/:projectId', fastify.register((fastify, _, done) => {
handler: controller.events, fastify.get(
'/visitors/:projectId',
{ websocket: true },
controller.wsVisitors
);
done();
}); });
done(); done();
}; };

View File

@@ -1,33 +0,0 @@
// @ts-nocheck
export async function* combine<T>(iterable: AsyncGenerator<T>[]): T[] {
const asyncIterators = Array.from(iterable, (o) => o[Symbol.asyncIterator]());
const results = [];
let count = asyncIterators.length;
const never = new Promise(() => {});
function getNext(asyncIterator: AsyncGenerator, index: number) {
return asyncIterator.next().then((result) => ({
index,
result,
}));
}
const nextPromises = asyncIterators.map(getNext);
try {
while (count) {
const { index, result } = await Promise.race(nextPromises);
if (result.done) {
nextPromises[index] = never;
results[index] = result.value;
count--;
} else {
nextPromises[index] = getNext(asyncIterators[index], index);
yield result.value;
}
}
} finally {
for (const [index, iterator] of asyncIterators.entries())
if (nextPromises[index] != never && iterator.return != null)
iterator.return();
}
return results;
}

View File

@@ -1,49 +0,0 @@
import { redisSub } from '@mixan/redis';
export async function* redisMessageIterator<T>(opts: {
transformer: (payload: string) => Promise<T>;
listenOn: string;
registerListener: (listener: (...args: any[]) => void) => void;
}) {
// Subscribe to a channel
interface Payload {
data: T;
}
// Promise resolver to signal new messages
let messageNotifier: null | ((payload: Payload) => void) = null;
// Promise to wait for new messages
let waitForMessage: Promise<Payload> = new Promise<Payload>((resolve) => {
messageNotifier = resolve;
});
async function listener(pattern: string, channel: string, message: string) {
const data = await opts.transformer(
pattern && channel && message ? message : channel
);
// Resolve the waiting promise to notify the generator of new message arrival
if (typeof messageNotifier === 'function') {
messageNotifier({ data });
}
// Clear the notifier to avoid multiple resolutions for a single message
messageNotifier = null;
}
// Event listener for messages on the subscribed channel
redisSub.on(opts.listenOn, listener);
opts.registerListener(listener);
while (true) {
// Wait for a new message
const { data } = await waitForMessage;
// Reset the waiting promise for the next message
waitForMessage = new Promise((resolve) => {
messageNotifier = resolve;
});
// Yield the received message
yield { data };
}
}

View File

@@ -1,3 +1,5 @@
import { stripTrailingSlash } from '@mixan/common';
import referrers from '../referrers'; import referrers from '../referrers';
function getHostname(url: string | undefined) { function getHostname(url: string | undefined) {
@@ -18,7 +20,7 @@ export function parseReferrer(url: string | undefined) {
return { return {
name: match?.name ?? '', name: match?.name ?? '',
type: match?.type ?? 'unknown', type: match?.type ?? 'unknown',
url: url ?? '', url: stripTrailingSlash(url ?? ''),
}; };
} }

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

View File

@@ -1,45 +1,27 @@
'use client'; 'use client';
import { Suspense } from 'react'; 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 { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { MetricCardLoading } from '@/components/report/chart/MetricCard'; 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 { Widget, WidgetBody } from '@/components/Widget';
import type { IChartInput } from '@/types'; import type { IChartInput } from '@/types';
import { cn } from '@/utils/cn'; 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'; interface OverviewMetricsProps {
import { LiveCounter } from './live-counter'; projectId: string;
}
export default function OverviewMetrics() { export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { previous, range, setRange, interval, metric, setMetric, filters } = const { previous, range, interval, metric, setMetric, filters } =
useOverviewOptions(); useOverviewOptions();
const reports = [ const reports = [
{ {
id: 'Unique visitors', id: 'Unique visitors',
projectId: '', // TODO: Remove projectId,
events: [ events: [
{ {
segment: 'user', segment: 'user',
@@ -60,7 +42,7 @@ export default function OverviewMetrics() {
}, },
{ {
id: 'Total sessions', id: 'Total sessions',
projectId: '', // TODO: Remove projectId,
events: [ events: [
{ {
segment: 'event', segment: 'event',
@@ -81,7 +63,7 @@ export default function OverviewMetrics() {
}, },
{ {
id: 'Total pageviews', id: 'Total pageviews',
projectId: '', // TODO: Remove projectId,
events: [ events: [
{ {
segment: 'event', segment: 'event',
@@ -102,7 +84,7 @@ export default function OverviewMetrics() {
}, },
{ {
id: 'Views per session', id: 'Views per session',
projectId: '', // TODO: Remove projectId,
events: [ events: [
{ {
segment: 'user_average', segment: 'user_average',
@@ -123,7 +105,7 @@ export default function OverviewMetrics() {
}, },
{ {
id: 'Bounce rate', id: 'Bounce rate',
projectId: '', // TODO: Remove projectId,
events: [ events: [
{ {
segment: 'event', segment: 'event',
@@ -161,7 +143,7 @@ export default function OverviewMetrics() {
}, },
{ {
id: 'Visit duration', id: 'Visit duration',
projectId: '', // TODO: Remove projectId,
events: [ events: [
{ {
segment: 'property_average', segment: 'property_average',
@@ -196,48 +178,7 @@ export default function OverviewMetrics() {
const selectedMetric = reports[metric]!; const selectedMetric = reports[metric]!;
return ( 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) => ( {reports.map((report, index) => (
<button <button
key={index} key={index}
@@ -249,13 +190,13 @@ export default function OverviewMetrics() {
<Suspense fallback={<MetricCardLoading />}> <Suspense fallback={<MetricCardLoading />}>
<Chart hideID {...report} /> <Chart hideID {...report} />
</Suspense> </Suspense>
{/* add active border */}
<div <div
className={cn( 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', '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' metric === index ? 'opacity-100' : 'opacity-0'
)} )}
/> />
{/* add active border */}
</button> </button>
))} ))}
<Widget className="col-span-6"> <Widget className="col-span-6">
@@ -268,18 +209,6 @@ export default function OverviewMetrics() {
</Suspense> </Suspense>
</WidgetBody> </WidgetBody>
</Widget> </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>
); );
} }

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 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 { getExists } from '@/server/pageExists';
import { db } from '@mixan/db';
import { StickyBelowHeader } from './layout-sticky-below-header';
import OverviewMetrics from './overview-metrics'; import OverviewMetrics from './overview-metrics';
import {
OverviewFilterSheetTrigger,
OverviewReportRange,
} from './overview-sticky-header';
interface PageProps { interface PageProps {
params: { params: {
@@ -9,14 +26,49 @@ interface PageProps {
projectId: string; projectId: string;
}; };
} }
export default async function Page({ export default async function Page({
params: { organizationId, projectId }, params: { organizationId, projectId },
}: PageProps) { }: PageProps) {
await getExists(organizationId, projectId); const [share] = await Promise.all([
db.shareOverview.findUnique({
where: {
project_id: projectId,
},
}),
getExists(organizationId, projectId),
]);
return ( return (
<PageLayout title="Overview" organizationSlug={organizationId}> <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> </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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpLink } from '@trpc/client'; import { httpLink } from '@trpc/client';
import { Provider as ReduxProvider } from 'react-redux'; import { Provider as ReduxProvider } from 'react-redux';
import { Toaster } from 'sonner';
import superjson from 'superjson'; import superjson from 'superjson';
export default function Providers({ children }: { children: React.ReactNode }) { export default function Providers({ children }: { children: React.ReactNode }) {
@@ -49,6 +50,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
{children} {children}
<Toaster />
<ModalProvider /> <ModalProvider />
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </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'; 'use client';
import { useEffect, useState } from 'react'; import { useRef, useState } from 'react';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query'; 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 { toast } from 'sonner';
import { getSafeJson } from '@mixan/common'; export interface LiveCounterProps {
import type { IServiceCreateEventPayload } from '@mixan/db'; data: number;
projectId: string;
interface LiveCounterProps {
initialCount: number;
} }
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 client = useQueryClient();
const [counter, setCounter] = useState(initialCount); const [counter, setCounter] = useState(data);
const { projectId } = useAppParams(); const [socketUrl] = useState(`${ws}/live/visitors/${projectId}`);
const [es] = useState( const lastRefresh = useRef(Date.now());
typeof window != 'undefined' &&
new EventSource(`http://localhost:3333/live/events/${projectId}`)
);
useEffect(() => { useWebSocket(socketUrl, {
if (!es) { shouldReconnect: () => true,
return () => {}; onMessage(event) {
} const value = parseInt(event.data, 10);
if (!isNaN(value)) {
function handler(event: MessageEvent<string>) { setCounter(value);
const parsed = getSafeJson<{ if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
visitors: number; lastRefresh.current = Date.now();
event: IServiceCreateEventPayload | null; toast('Refreshed data');
}>(event.data);
if (parsed) {
setCounter(parsed.visitors);
if (parsed.event) {
client.refetchQueries({ client.refetchQueries({
type: 'active', type: 'active',
}); });
toast('New event', { }
description: `${parsed.event.name}`, }
duration: 2000, },
}); });
}
}
}
es.addEventListener('message', handler);
return () => es.removeEventListener('message', handler);
}, []);
return ( return (
<Tooltip> <Tooltip>
@@ -79,6 +73,9 @@ export function LiveCounter({ initialCount = 0 }: LiveCounterProps) {
transitions={(index) => ({ transitions={(index) => ({
type: 'spring', type: 'spring',
duration: index + 0.3, duration: index + 0.3,
damping: 10,
stiffness: 200,
})} })}
animateToNumber={counter} animateToNumber={counter}
locale="en" locale="en"

View File

@@ -20,6 +20,28 @@ export function OverviewFiltersButtons() {
<strong>{options.referrer}</strong> <strong>{options.referrer}</strong>
</Button> </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 && ( {options.device && (
<Button <Button
size="sm" size="sm"

View File

@@ -8,8 +8,10 @@ import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions'; import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFilters() { interface OverviewFiltersProps {
const { projectId } = useAppParams(); projectId: string;
}
export function OverviewFilters({ projectId }: OverviewFiltersProps) {
const options = useOverviewOptions(); const options = useOverviewOptions();
const { data: referrers } = api.chart.values.useQuery({ 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 { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget'; import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopDevices() { interface OverviewTopDevicesProps {
projectId: string;
}
export default function OverviewTopDevices({
projectId,
}: OverviewTopDevicesProps) {
const { const {
filters, filters,
interval, interval,
@@ -18,19 +23,15 @@ export default function OverviewTopDevices() {
previous, previous,
setBrowser, setBrowser,
setBrowserVersion, setBrowserVersion,
browser,
browserVersion,
setOS, setOS,
setOSVersion, setOSVersion,
os,
osVersion,
} = useOverviewOptions(); } = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('tech', { const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: { devices: {
title: 'Top devices', title: 'Top devices',
btn: 'Devices', btn: 'Devices',
chart: { chart: {
projectId: '', projectId,
events: [ events: [
{ {
segment: 'user', segment: 'user',
@@ -58,7 +59,7 @@ export default function OverviewTopDevices() {
title: 'Top browser', title: 'Top browser',
btn: 'Browser', btn: 'Browser',
chart: { chart: {
projectId: '', projectId,
events: [ events: [
{ {
segment: 'user', segment: 'user',
@@ -86,7 +87,7 @@ export default function OverviewTopDevices() {
title: 'Top Browser Version', title: 'Top Browser Version',
btn: 'Browser Version', btn: 'Browser Version',
chart: { chart: {
projectId: '', projectId,
events: [ events: [
{ {
segment: 'user', segment: 'user',
@@ -114,7 +115,7 @@ export default function OverviewTopDevices() {
title: 'Top OS', title: 'Top OS',
btn: 'OS', btn: 'OS',
chart: { chart: {
projectId: '', projectId,
events: [ events: [
{ {
segment: 'user', segment: 'user',
@@ -142,7 +143,7 @@ export default function OverviewTopDevices() {
title: 'Top OS version', title: 'Top OS version',
btn: 'OS Version', btn: 'OS Version',
chart: { chart: {
projectId: '', projectId,
events: [ events: [
{ {
segment: 'user', segment: 'user',

View File

@@ -10,14 +10,19 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions'; import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget'; 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 { filters, interval, range, previous } = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('ev', { const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: { all: {
title: 'Top events', title: 'Top events',
btn: 'All', btn: 'All',
chart: { chart: {
projectId: '', projectId,
events: [ events: [
{ {
segment: 'event', segment: 'event',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ export function ChartEmpty() {
return ( return (
<div <div
className={ 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 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 { IChartSerie } from '@/server/api/routers/chart';
import type { IChartInput } from '@/types'; import type { IChartInput } from '@/types';
import { ChartLoading } from './ChartLoading';
export interface ChartContextType extends IChartInput { export interface ChartContextType extends IChartInput {
editMode?: boolean; editMode?: boolean;
hideID?: boolean; hideID?: boolean;
@@ -53,6 +64,16 @@ export function withChartProivder<ComponentProps>(
WrappedComponent: React.FC<ComponentProps> WrappedComponent: React.FC<ComponentProps>
) { ) {
const WithChartProvider = (props: ComponentProps & ChartContextType) => { const WithChartProvider = (props: ComponentProps & ChartContextType) => {
const [mounted, setMounted] = useState(props.chartType === 'metric');
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <ChartLoading />;
}
return ( return (
<ChartProvider {...props}> <ChartProvider {...props}>
<WrappedComponent {...props} /> <WrappedComponent {...props} />

View File

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

View File

@@ -41,7 +41,7 @@ export function ReportAreaChart({
<> <>
<div <div
className={cn( 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' editMode && 'border border-border bg-white rounded-md p-4'
)} )}
> >

View File

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

View File

@@ -19,9 +19,16 @@ interface ReportHistogramChartProps {
interval: IInterval; 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; 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({ export function ReportHistogramChart({
@@ -38,7 +45,7 @@ export function ReportHistogramChart({
<> <>
<div <div
className={cn( 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' editMode && 'border border-border bg-white rounded-md p-4'
)} )}
> >

View File

@@ -1,3 +1,5 @@
'use client';
import React from 'react'; import React from 'react';
import type { IChartData } from '@/app/_trpc/client'; import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer'; import { AutoSizer } from '@/components/AutoSizer';
@@ -41,7 +43,7 @@ export function ReportLineChart({
<> <>
<div <div
className={cn( 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' 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 type { IChartData } from '@/app/_trpc/client';
import { useVisibleSeries } from '@/hooks/useVisibleSeries'; import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -226,10 +226,9 @@ export async function getChartData(payload: IGetChartDataInput) {
return Object.keys(series).map((key) => { return Object.keys(series).map((key) => {
// If we have breakdowns, we want to use the breakdown key as the legend // 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 // But only if it successfully broke it down, otherwise we use the getEventLabel
const serieName = const isBreakdown =
payload.breakdowns.length && !alphabetIds.includes(key as 'A') payload.breakdowns.length && !alphabetIds.includes(key as 'A');
? key const serieName = isBreakdown ? key : getEventLegend(payload.event);
: getEventLegend(payload.event);
const data = const data =
payload.chartType === 'area' || payload.chartType === 'area' ||
payload.chartType === 'linear' || 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 type { IChartEvent, IChartInput, IChartRange } from '@/types';
import { getDaysOldDate } from '@/utils/date'; import { getDaysOldDate } from '@/utils/date';
import { average, max, min, round, sum } from '@/utils/math'; import { average, max, min, round, sum } from '@/utils/math';
@@ -103,7 +107,8 @@ export const chartRouter = createTRPCRouter({
)(properties); )(properties);
}), }),
values: protectedProcedure // TODO: Make this private
values: publicProcedure
.input( .input(
z.object({ z.object({
event: z.string(), 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); const current = getDatesFromRange(input.range);
let diff = 0; let diff = 0;
@@ -313,6 +319,11 @@ export const chartRouter = createTRPCRouter({
} }
}); });
// await new Promise((res) => {
// setTimeout(() => {
// res();
// }, 100);
// });
return final; return final;
}), }),
}); });

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(), organizationSlug: z.string(),
role: z.enum(['admin', 'org:member']), role: z.enum(['admin', 'org:member']),
}); });
export const zShareOverview = z.object({
organizationId: z.string(),
projectId: z.string(),
password: z.string().nullable(),
public: z.boolean(),
});

View File

@@ -3,3 +3,4 @@ export * from './src/profileId';
export * from './src/date'; export * from './src/date';
export * from './src/object'; export * from './src/object';
export * from './src/names'; export * from './src/names';
export * from './src/string';

View File

@@ -0,0 +1,3 @@
export function stripTrailingSlash(url: string) {
return url.replace(/\/+$/, '');
}

View File

@@ -4,3 +4,4 @@ export * from './src/clickhouse-client';
export * from './src/sql-builder'; export * from './src/sql-builder';
export * from './src/services/salt'; export * from './src/services/salt';
export * from './src/services/event.service'; export * from './src/services/event.service';
export * from './src/services/share.service';

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "shares" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"organization_slug" TEXT NOT NULL,
"public" BOOLEAN NOT NULL DEFAULT false,
"password" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "shares_id_key" ON "shares"("id");
-- CreateIndex
CREATE UNIQUE INDEX "shares_project_id_key" ON "shares"("project_id");
-- AddForeignKey
ALTER TABLE "shares" ADD CONSTRAINT "shares_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "shares" ALTER COLUMN "password" DROP NOT NULL;

View File

@@ -23,6 +23,7 @@ model Project {
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
reports Report[] reports Report[]
dashboards Dashboard[] dashboards Dashboard[]
share ShareOverview?
@@map("projects") @@map("projects")
} }
@@ -151,3 +152,16 @@ model Waitlist {
@@map("waitlist") @@map("waitlist")
} }
model ShareOverview {
id String @unique
project_id String @unique
project Project @relation(fields: [project_id], references: [id])
organization_slug String
public Boolean @default(false)
password String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("shares")
}

View File

@@ -92,6 +92,11 @@ interface GetEventsOptions {
profile?: boolean | Prisma.ProfileSelect; profile?: boolean | Prisma.ProfileSelect;
} }
export async function getLiveVisitors(projectId: string) {
const keys = await redis.keys(`live:event:${projectId}:*`);
return keys.length;
}
export async function getEvents(sql: string, options: GetEventsOptions = {}) { export async function getEvents(sql: string, options: GetEventsOptions = {}) {
const events = await chQuery<IClickhouseEvent>(sql); const events = await chQuery<IClickhouseEvent>(sql);
if (options.profile) { if (options.profile) {
@@ -186,7 +191,12 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
}); });
redisPub.publish('event', JSON.stringify(transformEvent(event))); redisPub.publish('event', JSON.stringify(transformEvent(event)));
redis.set(`live:event:${event.project_id}:${event.profile_id}`, '', 'EX', 10); redis.set(
`live:event:${event.project_id}:${event.profile_id}`,
'',
'EX',
60 * 5
);
return { return {
...res, ...res,

View File

@@ -0,0 +1,12 @@
import { db } from '../prisma-client';
export function getShareOverviewById(id: string) {
return db.shareOverview.findFirst({
where: {
id,
},
include: {
project: true,
},
});
}

3671
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff