a lot
This commit is contained in:
6
apps/web/TOOODOO.md
Normal file
6
apps/web/TOOODOO.md
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/app/(public)/share/overview/[id]/page.tsx
Normal file
82
apps/web/src/app/(public)/share/overview/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
11
apps/web/src/components/overview/live-counter/index.tsx
Normal file
11
apps/web/src/components/overview/live-counter/index.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
76
apps/web/src/components/overview/overview-share.tsx
Normal file
76
apps/web/src/components/overview/overview-share.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
|
||||
65
apps/web/src/components/report/chart/SerieIcon.tsx
Normal file
65
apps/web/src/components/report/chart/SerieIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { IChartData, IChartSerieDataItem } from '@/app/_trpc/client';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
96
apps/web/src/modals/ShareOverviewModal.tsx
Normal file
96
apps/web/src/modals/ShareOverviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,9 @@ const modals = {
|
||||
EditReport: dynamic(() => import('./EditReport'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
};
|
||||
|
||||
const emitter = mitt<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
29
apps/web/src/server/api/routers/share.ts
Normal file
29
apps/web/src/server/api/routers/share.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
21
apps/web/src/utils/meta.ts
Normal file
21
apps/web/src/utils/meta.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user