+
+
Last 30 minutes
+
+ {count}
+
+
+
+
+ NOW
+
+ {/*
*/}
+ {children}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-metrics.tsx b/apps/dashboard/src/components/overview/overview-metrics.tsx
new file mode 100644
index 00000000..a1b7b5b5
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-metrics.tsx
@@ -0,0 +1,226 @@
+'use client';
+
+import { WidgetHead } from '@/components/overview/overview-widget';
+import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
+import { ChartSwitch } from '@/components/report/chart';
+import { Widget, WidgetBody } from '@/components/Widget';
+import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
+import { cn } from '@/utils/cn';
+import type { IChartInput } from '@openpanel/validation';
+
+interface OverviewMetricsProps {
+ projectId: string;
+}
+
+export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
+ const { previous, range, interval, metric, setMetric, startDate, endDate } =
+ useOverviewOptions();
+ const [filters] = useEventQueryFilters();
+ const isPageFilter = filters.find((filter) => filter.name === 'path');
+ const reports = [
+ {
+ id: 'Visitors',
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'user',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ displayName: 'Visitors',
+ },
+ ],
+ breakdowns: [],
+ chartType: 'metric',
+ lineType: 'monotone',
+ interval,
+ name: 'Visitors',
+ range,
+ previous,
+ metric: 'sum',
+ },
+ {
+ id: 'Sessions',
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'session',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ displayName: 'Sessions',
+ },
+ ],
+ breakdowns: [],
+ chartType: 'metric',
+ lineType: 'monotone',
+ interval,
+ name: 'Sessions',
+ range,
+ previous,
+ metric: 'sum',
+ },
+ {
+ id: 'Pageviews',
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: 'screen_view',
+ displayName: 'Pageviews',
+ },
+ ],
+ breakdowns: [],
+ chartType: 'metric',
+ lineType: 'monotone',
+ interval,
+ name: 'Pageviews',
+ range,
+ previous,
+ metric: 'sum',
+ },
+ {
+ id: 'Views per session',
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'user_average',
+ filters,
+ id: 'A',
+ name: 'screen_view',
+ displayName: 'Views per session',
+ },
+ ],
+ breakdowns: [],
+ chartType: 'metric',
+ lineType: 'monotone',
+ interval,
+ name: 'Views per session',
+ range,
+ previous,
+ metric: 'average',
+ },
+ {
+ id: 'Bounce rate',
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters: [
+ {
+ id: '1',
+ name: 'properties.__bounce',
+ operator: 'is',
+ value: ['true'],
+ },
+ ...filters,
+ ],
+ id: 'A',
+ name: 'session_end',
+ displayName: 'Bounce rate',
+ },
+ {
+ segment: 'event',
+ filters: filters,
+ id: 'B',
+ name: 'session_end',
+ displayName: 'Bounce rate',
+ },
+ ],
+ breakdowns: [],
+ chartType: 'metric',
+ lineType: 'monotone',
+ interval,
+ name: 'Bounce rate',
+ range,
+ previous,
+ previousIndicatorInverted: true,
+ formula: 'A/B*100',
+ metric: 'average',
+ unit: '%',
+ },
+ {
+ id: 'Visit duration',
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'property_average',
+ filters: [
+ {
+ name: 'duration',
+ operator: 'isNot',
+ value: ['0'],
+ id: 'A',
+ },
+ ...filters,
+ ],
+ id: 'A',
+ property: 'duration',
+ name: isPageFilter ? 'screen_view' : 'session_end',
+ displayName: isPageFilter ? 'Time on page' : 'Visit duration',
+ },
+ ],
+ breakdowns: [],
+ chartType: 'metric',
+ lineType: 'monotone',
+ interval,
+ name: 'Visit duration',
+ range,
+ previous,
+ formula: 'A/1000',
+ metric: 'average',
+ unit: 'min',
+ },
+ ] satisfies (IChartInput & { id: string })[];
+
+ const selectedMetric = reports[metric]!;
+
+ return (
+ <>
+
+ {reports.map((report, index) => (
+ {
+ setMetric(index);
+ }}
+ >
+
+ {/* add active border */}
+
+ ))}
+
+
+
+ {selectedMetric.events[0]?.displayName}
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-share.tsx b/apps/dashboard/src/components/overview/overview-share.tsx
new file mode 100644
index 00000000..5d07e022
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-share.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { api } from '@/app/_trpc/client';
+import { pushModal } from '@/modals';
+import type { ShareOverview } from '@openpanel/db';
+import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+
+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 (
+
+
+
+ {data && data.public ? 'Public' : 'Private'}
+
+
+
+
+ {(!data || data.public === false) && (
+ pushModal('ShareOverviewModal')}>
+
+ Make public
+
+ )}
+ {data?.public && (
+
+
+
+ View
+
+
+ )}
+ {data?.public && (
+ {
+ mutation.mutate({
+ public: false,
+ projectId: data?.project_id,
+ organizationId: data?.organization_slug,
+ password: null,
+ });
+ }}
+ >
+
+ Make private
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-top-devices.tsx b/apps/dashboard/src/components/overview/overview-top-devices.tsx
new file mode 100644
index 00000000..04d2cbb1
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-top-devices.tsx
@@ -0,0 +1,221 @@
+'use client';
+
+import { ChartSwitch } from '@/components/report/chart';
+import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
+import { cn } from '@/utils/cn';
+
+import { Widget, WidgetBody } from '../Widget';
+import { WidgetButtons, WidgetHead } from './overview-widget';
+import { useOverviewOptions } from './useOverviewOptions';
+import { useOverviewWidget } from './useOverviewWidget';
+
+interface OverviewTopDevicesProps {
+ projectId: string;
+}
+export default function OverviewTopDevices({
+ projectId,
+}: OverviewTopDevicesProps) {
+ const { interval, range, previous, startDate, endDate } =
+ useOverviewOptions();
+ const [filters, setFilter] = useEventQueryFilters();
+ const isPageFilter = filters.find((filter) => filter.name === 'path');
+ const [widget, setWidget, widgets] = useOverviewWidget('tech', {
+ devices: {
+ title: 'Top devices',
+ btn: 'Devices',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'user',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'device',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ browser: {
+ title: 'Top browser',
+ btn: 'Browser',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'user',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'browser',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ browser_version: {
+ title: 'Top Browser Version',
+ btn: 'Browser Version',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'user',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'browser_version',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ os: {
+ title: 'Top OS',
+ btn: 'OS',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'user',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'os',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ os_version: {
+ title: 'Top OS version',
+ btn: 'OS Version',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'user',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'os_version',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ });
+
+ return (
+ <>
+
+
+ {widget.title}
+
+ {widgets.map((w) => (
+ setWidget(w.key)}
+ className={cn(w.key === widget.key && 'active')}
+ >
+ {w.btn}
+
+ ))}
+
+
+
+ {
+ switch (widget.key) {
+ case 'devices':
+ setFilter('device', item.name);
+ break;
+ case 'browser':
+ setFilter('browser', item.name);
+ break;
+ case 'browser_version':
+ setFilter('browser_version', item.name);
+ break;
+ case 'os':
+ setFilter('os', item.name);
+ break;
+ case 'os_version':
+ setFilter('os_version', item.name);
+ break;
+ }
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-top-events/index.tsx b/apps/dashboard/src/components/overview/overview-top-events/index.tsx
new file mode 100644
index 00000000..6c9601c1
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-top-events/index.tsx
@@ -0,0 +1,16 @@
+import { getConversionEventNames } from '@openpanel/db';
+
+import type { OverviewTopEventsProps } from './overview-top-events';
+import OverviewTopEvents from './overview-top-events';
+
+export default async function OverviewTopEventsServer({
+ projectId,
+}: Omit
) {
+ const eventNames = await getConversionEventNames(projectId);
+ return (
+ item.name)}
+ />
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx b/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx
new file mode 100644
index 00000000..7ec1cab3
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { ChartSwitch } from '@/components/report/chart';
+import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
+import { cn } from '@/utils/cn';
+
+import { Widget, WidgetBody } from '../../Widget';
+import { WidgetButtons, WidgetHead } from '../overview-widget';
+import { useOverviewOptions } from '../useOverviewOptions';
+import { useOverviewWidget } from '../useOverviewWidget';
+
+export interface OverviewTopEventsProps {
+ projectId: string;
+ conversions: string[];
+}
+export default function OverviewTopEvents({
+ projectId,
+ conversions,
+}: OverviewTopEventsProps) {
+ const { interval, range, previous, startDate, endDate } =
+ useOverviewOptions();
+ const [filters] = useEventQueryFilters();
+ const [widget, setWidget, widgets] = useOverviewWidget('ev', {
+ all: {
+ title: 'Top events',
+ btn: 'All',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters: [
+ ...filters,
+ {
+ id: 'ex_session',
+ name: 'name',
+ operator: 'isNot',
+ value: ['session_start', 'session_end'],
+ },
+ ],
+ id: 'A',
+ name: '*',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'name',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ conversions: {
+ title: 'Conversions',
+ btn: 'Conversions',
+ hide: conversions.length === 0,
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters: [
+ ...filters,
+ {
+ id: 'conversion',
+ name: 'name',
+ operator: 'is',
+ value: conversions,
+ },
+ ],
+ id: 'A',
+ name: '*',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'name',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ });
+
+ return (
+ <>
+
+
+ {widget.title}
+
+ {widgets
+ .filter((item) => item.hide !== true)
+ .map((w) => (
+ setWidget(w.key)}
+ className={cn(w.key === widget.key && 'active')}
+ >
+ {w.btn}
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-top-geo.tsx b/apps/dashboard/src/components/overview/overview-top-geo.tsx
new file mode 100644
index 00000000..91aa2d2c
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-top-geo.tsx
@@ -0,0 +1,191 @@
+'use client';
+
+import { ChartSwitch } from '@/components/report/chart';
+import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
+import { cn } from '@/utils/cn';
+
+import { Widget, WidgetBody } from '../Widget';
+import { WidgetButtons, WidgetHead } from './overview-widget';
+import { useOverviewOptions } from './useOverviewOptions';
+import { useOverviewWidget } from './useOverviewWidget';
+
+interface OverviewTopGeoProps {
+ projectId: string;
+}
+export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
+ const { interval, range, previous, startDate, endDate } =
+ useOverviewOptions();
+ const [filters, setFilter] = useEventQueryFilters();
+ const isPageFilter = filters.find((filter) => filter.name === 'path');
+ const [widget, setWidget, widgets] = useOverviewWidget('geo', {
+ countries: {
+ title: 'Top countries',
+ btn: 'Countries',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'country',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ regions: {
+ title: 'Top regions',
+ btn: 'Regions',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'region',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ cities: {
+ title: 'Top cities',
+ btn: 'Cities',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'city',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ });
+
+ return (
+ <>
+
+
+ {widget.title}
+
+ {widgets.map((w) => (
+ setWidget(w.key)}
+ className={cn(w.key === widget.key && 'active')}
+ >
+ {w.btn}
+
+ ))}
+
+
+
+ {
+ switch (widget.key) {
+ case 'countries':
+ setWidget('regions');
+ setFilter('country', item.name);
+ break;
+ case 'regions':
+ setWidget('cities');
+ setFilter('region', item.name);
+ break;
+ case 'cities':
+ setFilter('city', item.name);
+ break;
+ }
+ }}
+ />
+
+
+
+
+ Map
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-top-pages.tsx b/apps/dashboard/src/components/overview/overview-top-pages.tsx
new file mode 100644
index 00000000..140c3b76
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-top-pages.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+import { ChartSwitch } from '@/components/report/chart';
+import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
+import { cn } from '@/utils/cn';
+
+import { Widget, WidgetBody } from '../Widget';
+import { WidgetButtons, WidgetHead } from './overview-widget';
+import { useOverviewOptions } from './useOverviewOptions';
+import { useOverviewWidget } from './useOverviewWidget';
+
+interface OverviewTopPagesProps {
+ projectId: string;
+}
+export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
+ const { interval, range, previous, startDate, endDate } =
+ useOverviewOptions();
+ const [filters, setFilter] = useEventQueryFilters();
+ const [widget, setWidget, widgets] = useOverviewWidget('pages', {
+ top: {
+ title: 'Top pages',
+ btn: 'Top pages',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: 'screen_view',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'path',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval,
+ name: 'Top sources',
+ range,
+ previous,
+ metric: 'sum',
+ },
+ },
+ entries: {
+ title: 'Entry Pages',
+ btn: 'Entries',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'path',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval,
+ name: 'Top sources',
+ range,
+ previous,
+ metric: 'sum',
+ },
+ },
+ exits: {
+ title: 'Exit Pages',
+ btn: 'Exits',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: 'session_end',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'path',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval,
+ name: 'Top sources',
+ range,
+ previous,
+ metric: 'sum',
+ },
+ },
+ });
+
+ return (
+ <>
+
+
+ {widget.title}
+
+ {widgets.map((w) => (
+ setWidget(w.key)}
+ className={cn(w.key === widget.key && 'active')}
+ >
+ {w.btn}
+
+ ))}
+
+
+
+ {
+ setFilter('path', item.name);
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-top-sources.tsx b/apps/dashboard/src/components/overview/overview-top-sources.tsx
new file mode 100644
index 00000000..5a8e3329
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-top-sources.tsx
@@ -0,0 +1,322 @@
+'use client';
+
+import { ChartSwitch } from '@/components/report/chart';
+import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
+import { cn } from '@/utils/cn';
+
+import { Widget, WidgetBody } from '../Widget';
+import { WidgetButtons, WidgetHead } from './overview-widget';
+import { useOverviewOptions } from './useOverviewOptions';
+import { useOverviewWidget } from './useOverviewWidget';
+
+interface OverviewTopSourcesProps {
+ projectId: string;
+}
+export default function OverviewTopSources({
+ projectId,
+}: OverviewTopSourcesProps) {
+ const { interval, range, previous, startDate, endDate } =
+ useOverviewOptions();
+ const [filters, setFilter] = useEventQueryFilters();
+ const isPageFilter = filters.find((filter) => filter.name === 'path');
+ const [widget, setWidget, widgets] = useOverviewWidget('sources', {
+ all: {
+ title: 'Top sources',
+ btn: 'All',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters: filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : '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,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters: filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'referrer',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ type: {
+ title: 'Top types',
+ btn: 'Types',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters: filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : '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,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'properties.query.utm_source',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ utm_medium: {
+ title: 'UTM Medium',
+ btn: 'Medium',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'properties.query.utm_medium',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ utm_campaign: {
+ title: 'UTM Campaign',
+ btn: 'Campaign',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'properties.query.utm_campaign',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ utm_term: {
+ title: 'UTM Term',
+ btn: 'Term',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'properties.query.utm_term',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ utm_content: {
+ title: 'UTM Content',
+ btn: 'Content',
+ chart: {
+ projectId,
+ startDate,
+ endDate,
+ events: [
+ {
+ segment: 'event',
+ filters,
+ id: 'A',
+ name: isPageFilter ? 'screen_view' : 'session_start',
+ },
+ ],
+ breakdowns: [
+ {
+ id: 'A',
+ name: 'properties.query.utm_content',
+ },
+ ],
+ chartType: 'bar',
+ lineType: 'monotone',
+ interval: interval,
+ name: 'Top sources',
+ range: range,
+ previous: previous,
+ metric: 'sum',
+ },
+ },
+ });
+
+ return (
+ <>
+
+
+ {widget.title}
+
+ {widgets.map((w) => (
+ setWidget(w.key)}
+ className={cn(w.key === widget.key && 'active')}
+ >
+ {w.btn}
+
+ ))}
+
+
+
+ {
+ switch (widget.key) {
+ case 'all':
+ setFilter('referrer_name', item.name);
+ setWidget('domain');
+ break;
+ case 'domain':
+ setFilter('referrer', item.name);
+ break;
+ case 'type':
+ setFilter('referrer_type', item.name);
+ setWidget('domain');
+ break;
+ case 'utm_source':
+ setFilter('properties.query.utm_source', item.name);
+ break;
+ case 'utm_medium':
+ setFilter('properties.query.utm_medium', item.name);
+ break;
+ case 'utm_campaign':
+ setFilter('properties.query.utm_campaign', item.name);
+ break;
+ case 'utm_term':
+ setFilter('properties.query.utm_term', item.name);
+ break;
+ case 'utm_content':
+ setFilter('properties.query.utm_content', item.name);
+ break;
+ }
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/overview/overview-widget.tsx b/apps/dashboard/src/components/overview/overview-widget.tsx
new file mode 100644
index 00000000..9bead633
--- /dev/null
+++ b/apps/dashboard/src/components/overview/overview-widget.tsx
@@ -0,0 +1,122 @@
+'use client';
+
+import { Children, useEffect, useRef, useState } from 'react';
+import { useThrottle } from '@/hooks/useThrottle';
+import { cn } from '@/utils/cn';
+import { ChevronsUpDownIcon } from 'lucide-react';
+import { last } from 'ramda';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '../ui/dropdown-menu';
+import type { WidgetHeadProps } from '../Widget';
+import { WidgetHead as WidgetHeadBase } from '../Widget';
+
+export function WidgetHead({ className, ...props }: WidgetHeadProps) {
+ return (
+
+ );
+}
+
+export function WidgetButtons({
+ className,
+ children,
+ ...props
+}: WidgetHeadProps) {
+ const container = useRef(null);
+ const sizes = useRef([]);
+ const [slice, setSlice] = useState(3); // Show 3 buttons by default
+ const gap = 16;
+
+ const handleResize = useThrottle(() => {
+ if (container.current) {
+ if (sizes.current.length === 0) {
+ // Get buttons
+ const buttons: HTMLButtonElement[] = Array.from(
+ container.current.querySelectorAll(`button`)
+ );
+ // Get sizes and cache them
+ sizes.current = buttons.map(
+ (button) => Math.ceil(button.offsetWidth) + gap
+ );
+ }
+ const containerWidth = container.current.offsetWidth;
+ const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
+ const moreWidth = (last(sizes.current) ?? 0) + gap;
+
+ if (buttonsWidth > containerWidth) {
+ const res = sizes.current.reduce(
+ (acc, size, index) => {
+ if (acc.size + size + moreWidth > containerWidth) {
+ return { index: acc.index, size: acc.size + size };
+ }
+ return { index, size: acc.size + size };
+ },
+ { index: 0, size: 0 }
+ );
+
+ setSlice(res.index);
+ } else {
+ setSlice(sizes.current.length - 1);
+ }
+ }
+ }, 30);
+
+ useEffect(() => {
+ handleResize();
+
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, [handleResize, children]);
+
+ const hidden = '!opacity-0 absolute pointer-events-none';
+
+ return (
+
+ {Children.map(children, (child, index) => {
+ return (
+
+ {child}
+
+ );
+ })}
+
+
+
+ More
+
+
+
+
+ {Children.map(children, (child, index) => {
+ if (index <= slice) {
+ return null;
+ }
+ return {child} ;
+ })}
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/overview/useOverviewOptions.ts b/apps/dashboard/src/components/overview/useOverviewOptions.ts
new file mode 100644
index 00000000..7a4000ad
--- /dev/null
+++ b/apps/dashboard/src/components/overview/useOverviewOptions.ts
@@ -0,0 +1,71 @@
+import {
+ getDefaultIntervalByDates,
+ getDefaultIntervalByRange,
+ timeRanges,
+} from '@openpanel/constants';
+import { mapKeys } from '@openpanel/validation';
+import {
+ parseAsBoolean,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+ useQueryState,
+} from 'nuqs';
+
+const nuqsOptions = { history: 'push' } as const;
+
+export function useOverviewOptions() {
+ const [previous, setPrevious] = useQueryState(
+ 'compare',
+ parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
+ );
+ const [startDate, setStartDate] = useQueryState(
+ 'start',
+ parseAsString.withOptions(nuqsOptions)
+ );
+ const [endDate, setEndDate] = useQueryState(
+ 'end',
+ parseAsString.withOptions(nuqsOptions)
+ );
+ const [range, setRange] = useQueryState(
+ 'range',
+ parseAsStringEnum(mapKeys(timeRanges))
+ .withDefault('7d')
+ .withOptions(nuqsOptions)
+ );
+
+ const interval =
+ getDefaultIntervalByDates(startDate, endDate) ||
+ getDefaultIntervalByRange(range);
+
+ const [metric, setMetric] = useQueryState(
+ 'metric',
+ parseAsInteger.withDefault(0).withOptions(nuqsOptions)
+ );
+
+ // Toggles
+ const [liveHistogram, setLiveHistogram] = useQueryState(
+ 'live',
+ parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
+ );
+
+ return {
+ previous,
+ setPrevious,
+ range,
+ setRange,
+ metric,
+ setMetric,
+ startDate,
+ setStartDate,
+ endDate,
+ setEndDate,
+
+ // Computed
+ interval,
+
+ // Toggles
+ liveHistogram,
+ setLiveHistogram,
+ };
+}
diff --git a/apps/dashboard/src/components/overview/useOverviewWidget.tsx b/apps/dashboard/src/components/overview/useOverviewWidget.tsx
new file mode 100644
index 00000000..2eefa355
--- /dev/null
+++ b/apps/dashboard/src/components/overview/useOverviewWidget.tsx
@@ -0,0 +1,30 @@
+import { mapKeys } from '@openpanel/validation';
+import type { IChartInput } from '@openpanel/validation';
+import { parseAsStringEnum, useQueryState } from 'nuqs';
+
+export function useOverviewWidget(
+ key: string,
+ widgets: Record<
+ T,
+ { title: string; btn: string; chart: IChartInput; hide?: boolean }
+ >
+) {
+ const keys = Object.keys(widgets) as T[];
+ const [widget, setWidget] = useQueryState(
+ key,
+ parseAsStringEnum(keys)
+ .withDefault(keys[0]!)
+ .withOptions({ history: 'push' })
+ );
+ return [
+ {
+ ...widgets[widget],
+ key: widget,
+ },
+ setWidget,
+ mapKeys(widgets).map((key) => ({
+ ...widgets[key],
+ key,
+ })),
+ ] as const;
+}
diff --git a/apps/dashboard/src/components/profiles/ProfileAvatar.tsx b/apps/dashboard/src/components/profiles/ProfileAvatar.tsx
new file mode 100644
index 00000000..e40417f5
--- /dev/null
+++ b/apps/dashboard/src/components/profiles/ProfileAvatar.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { cn } from '@/utils/cn';
+import type { IServiceProfile } from '@openpanel/db';
+import { AvatarImage } from '@radix-ui/react-avatar';
+import type { VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
+
+import { Avatar, AvatarFallback } from '../ui/avatar';
+
+interface ProfileAvatarProps
+ extends VariantProps,
+ Partial> {
+ className?: string;
+}
+
+const variants = cva('', {
+ variants: {
+ size: {
+ default: 'h-12 w-12 rounded-full [&>span]:rounded-full',
+ sm: 'h-6 w-6 rounded [&>span]:rounded',
+ xs: 'h-4 w-4 rounded [&>span]:rounded',
+ },
+ },
+ defaultVariants: {
+ size: 'default',
+ },
+});
+
+export function ProfileAvatar({
+ avatar,
+ first_name,
+ className,
+ size,
+}: ProfileAvatarProps) {
+ return (
+
+ {avatar && }
+
+ {first_name?.at(0) ?? '🫣'}
+
+
+ );
+}
diff --git a/apps/web/src/components/projects/ProjectActions.tsx b/apps/dashboard/src/components/projects/ProjectActions.tsx
similarity index 79%
rename from apps/web/src/components/projects/ProjectActions.tsx
rename to apps/dashboard/src/components/projects/ProjectActions.tsx
index 99c78ced..6b58e124 100644
--- a/apps/web/src/components/projects/ProjectActions.tsx
+++ b/apps/dashboard/src/components/projects/ProjectActions.tsx
@@ -1,9 +1,12 @@
-import { useRefetchActive } from '@/hooks/useRefetchActive';
+'use client';
+
+import { api } from '@/app/_trpc/client';
import { pushModal, showConfirm } from '@/modals';
-import type { IProject } from '@/types';
-import { api } from '@/utils/api';
import { clipboard } from '@/utils/clipboard';
+import type { IServiceProject } from '@openpanel/db';
import { MoreHorizontal } from 'lucide-react';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
import { Button } from '../ui/button';
import {
@@ -14,17 +17,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
-import { toast } from '../ui/use-toast';
-export function ProjectActions({ id }: IProject) {
- const refetch = useRefetchActive();
+export function ProjectActions(project: Exclude) {
+ const { id } = project;
+ const router = useRouter();
const deletion = api.project.remove.useMutation({
onSuccess() {
- toast({
- title: 'Success',
+ toast('Success', {
description: 'Project deleted successfully.',
});
- refetch();
+ router.refresh();
},
});
@@ -43,7 +45,7 @@ export function ProjectActions({ id }: IProject) {
{
- pushModal('EditProject', { id });
+ pushModal('EditProject', project);
}}
>
Edit
diff --git a/apps/dashboard/src/components/projects/project-card.tsx b/apps/dashboard/src/components/projects/project-card.tsx
new file mode 100644
index 00000000..c01c0d41
--- /dev/null
+++ b/apps/dashboard/src/components/projects/project-card.tsx
@@ -0,0 +1,68 @@
+import { shortNumber } from '@/hooks/useNumerFormatter';
+import Link from 'next/link';
+
+import type { IServiceProject } from '@openpanel/db';
+import { chQuery } from '@openpanel/db';
+
+import { ChartSSR } from '../chart-ssr';
+
+export async function ProjectCard({
+ id,
+ name,
+ organizationSlug,
+}: IServiceProject) {
+ const [chart, [data]] = await Promise.all([
+ chQuery<{ value: number; date: string }>(
+ `SELECT countDistinct(profile_id) as value, toStartOfDay(created_at) as date FROM events WHERE project_id = '${id}' AND name = 'session_start' AND created_at >= now() - interval '1 month' GROUP BY date ORDER BY date ASC`
+ ),
+ chQuery<{ total: number; month: number; day: number }>(
+ `
+ SELECT
+ (
+ SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}'
+ ) as total,
+ (
+ SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}' AND created_at >= now() - interval '1 month'
+ ) as month,
+ (
+ SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}' AND created_at >= now() - interval '1 day'
+ ) as day
+ `
+ ),
+ ]);
+
+ return (
+
+ {name}
+
+ ({ ...d, date: new Date(d.date) }))} />
+
+
+
Visitors
+
+
+
Total
+
+ {shortNumber('en')(data?.total)}
+
+
+
+
Month
+
+ {shortNumber('en')(data?.month)}
+
+
+
+
24h
+
+ {shortNumber('en')(data?.day)}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/projects/table.tsx b/apps/dashboard/src/components/projects/table.tsx
similarity index 84%
rename from apps/web/src/components/projects/table.tsx
rename to apps/dashboard/src/components/projects/table.tsx
index 6247992b..df97965f 100644
--- a/apps/web/src/components/projects/table.tsx
+++ b/apps/dashboard/src/components/projects/table.tsx
@@ -1,11 +1,11 @@
import { formatDate } from '@/utils/date';
-import type { Project as IProject } from '@prisma/client';
+import { IServiceProject } from '@openpanel/db';
+import type { Project as IProject } from '@openpanel/db';
import type { ColumnDef } from '@tanstack/react-table';
import { ProjectActions } from './ProjectActions';
export type Project = IProject;
-
export const columns: ColumnDef[] = [
{
accessorKey: 'name',
diff --git a/apps/dashboard/src/components/references/table.tsx b/apps/dashboard/src/components/references/table.tsx
new file mode 100644
index 00000000..6fd734ea
--- /dev/null
+++ b/apps/dashboard/src/components/references/table.tsx
@@ -0,0 +1,26 @@
+import { formatDate, formatDateTime } from '@/utils/date';
+import type { IServiceReference } from '@openpanel/db';
+import type { ColumnDef } from '@tanstack/react-table';
+
+export const columns: ColumnDef[] = [
+ {
+ accessorKey: 'title',
+ header: 'Title',
+ },
+ {
+ accessorKey: 'date',
+ header: 'Date',
+ cell({ row }) {
+ const date = row.original.date;
+ return {formatDateTime(date)}
;
+ },
+ },
+ {
+ accessorKey: 'createdAt',
+ header: 'Created at',
+ cell({ row }) {
+ const date = row.original.createdAt;
+ return {formatDate(date)}
;
+ },
+ },
+];
diff --git a/apps/dashboard/src/components/report/PreviousDiffIndicator.tsx b/apps/dashboard/src/components/report/PreviousDiffIndicator.tsx
new file mode 100644
index 00000000..92993552
--- /dev/null
+++ b/apps/dashboard/src/components/report/PreviousDiffIndicator.tsx
@@ -0,0 +1,113 @@
+import { useNumber } from '@/hooks/useNumerFormatter';
+import { cn } from '@/utils/cn';
+import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
+
+import { Badge } from '../ui/badge';
+import { useChartContext } from './chart/ChartProvider';
+
+export function getDiffIndicator(
+ inverted: boolean | undefined,
+ state: string | undefined | null,
+ positive: A,
+ negative: B,
+ neutral: C
+): A | B | C {
+ if (state === 'neutral' || !state) {
+ return neutral;
+ }
+
+ if (inverted === true) {
+ return state === 'positive' ? negative : positive;
+ }
+ return state === 'positive' ? positive : negative;
+}
+
+// TODO: Fix this mess!
+
+interface PreviousDiffIndicatorProps {
+ diff?: number | null | undefined;
+ state?: string | null | undefined;
+ children?: React.ReactNode;
+ inverted?: boolean;
+}
+
+export function PreviousDiffIndicator({
+ diff,
+ state,
+ children,
+}: PreviousDiffIndicatorProps) {
+ const { previous, previousIndicatorInverted } = useChartContext();
+ const variant = getDiffIndicator(
+ previousIndicatorInverted,
+ state,
+ 'success',
+ 'destructive',
+ undefined
+ );
+ const number = useNumber();
+
+ if (diff === null || diff === undefined || previous === false) {
+ return children ?? null;
+ }
+
+ const renderIcon = () => {
+ if (state === 'positive') {
+ return ;
+ }
+ if (state === 'negative') {
+ return ;
+ }
+ return null;
+ };
+
+ return (
+ <>
+
+ {renderIcon()}
+ {number.format(diff)}%
+
+ {children}
+ >
+ );
+}
+
+export function PreviousDiffIndicatorText({
+ diff,
+ state,
+ className,
+}: PreviousDiffIndicatorProps & { className?: string }) {
+ const { previous, previousIndicatorInverted } = useChartContext();
+ const number = useNumber();
+ if (diff === null || diff === undefined || previous === false) {
+ return null;
+ }
+
+ const renderIcon = () => {
+ if (state === 'positive') {
+ return ;
+ }
+ if (state === 'negative') {
+ return ;
+ }
+ return null;
+ };
+
+ return (
+
+ {renderIcon()}
+ {number.short(diff)}%
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/ReportChartType.tsx b/apps/dashboard/src/components/report/ReportChartType.tsx
new file mode 100644
index 00000000..58f1243d
--- /dev/null
+++ b/apps/dashboard/src/components/report/ReportChartType.tsx
@@ -0,0 +1,31 @@
+import { useDispatch, useSelector } from '@/redux';
+import { chartTypes } from '@openpanel/constants';
+import { objectToZodEnums } from '@openpanel/validation';
+import { LineChartIcon } from 'lucide-react';
+
+import { Combobox } from '../ui/combobox';
+import { changeChartType } from './reportSlice';
+
+interface ReportChartTypeProps {
+ className?: string;
+}
+export function ReportChartType({ className }: ReportChartTypeProps) {
+ const dispatch = useDispatch();
+ const type = useSelector((state) => state.report.chartType);
+
+ return (
+ {
+ dispatch(changeChartType(value));
+ }}
+ value={type}
+ items={objectToZodEnums(chartTypes).map((key) => ({
+ label: chartTypes[key],
+ value: key,
+ }))}
+ />
+ );
+}
diff --git a/apps/web/src/components/report/ReportInterval.tsx b/apps/dashboard/src/components/report/ReportInterval.tsx
similarity index 63%
rename from apps/web/src/components/report/ReportInterval.tsx
rename to apps/dashboard/src/components/report/ReportInterval.tsx
index 60eadff4..9cc6e141 100644
--- a/apps/web/src/components/report/ReportInterval.tsx
+++ b/apps/dashboard/src/components/report/ReportInterval.tsx
@@ -1,24 +1,38 @@
import { useDispatch, useSelector } from '@/redux';
-import type { IInterval } from '@/types';
-import { isMinuteIntervalEnabledByRange } from '@/utils/constants';
+import {
+ isHourIntervalEnabledByRange,
+ isMinuteIntervalEnabledByRange,
+} from '@openpanel/constants';
+import type { IInterval } from '@openpanel/validation';
+import { ClockIcon } from 'lucide-react';
import { Combobox } from '../ui/combobox';
import { changeInterval } from './reportSlice';
-export function ReportInterval() {
+interface ReportIntervalProps {
+ className?: string;
+}
+export function ReportInterval({ className }: ReportIntervalProps) {
const dispatch = useDispatch();
const interval = useSelector((state) => state.report.interval);
const range = useSelector((state) => state.report.range);
const chartType = useSelector((state) => state.report.chartType);
- if (chartType !== 'linear') {
+ if (
+ chartType !== 'linear' &&
+ chartType !== 'histogram' &&
+ chartType !== 'area' &&
+ chartType !== 'metric'
+ ) {
return null;
}
return (
{
- dispatch(changeInterval(value as IInterval));
+ dispatch(changeInterval(value));
}}
value={interval}
items={[
@@ -30,6 +44,7 @@ export function ReportInterval() {
{
value: 'hour',
label: 'Hour',
+ disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',
diff --git a/apps/dashboard/src/components/report/ReportLineType.tsx b/apps/dashboard/src/components/report/ReportLineType.tsx
new file mode 100644
index 00000000..cada47fa
--- /dev/null
+++ b/apps/dashboard/src/components/report/ReportLineType.tsx
@@ -0,0 +1,36 @@
+import { useDispatch, useSelector } from '@/redux';
+import { lineTypes } from '@openpanel/constants';
+import { objectToZodEnums } from '@openpanel/validation';
+import { Tv2Icon } from 'lucide-react';
+
+import { Combobox } from '../ui/combobox';
+import { changeLineType } from './reportSlice';
+
+interface ReportLineTypeProps {
+ className?: string;
+}
+export function ReportLineType({ className }: ReportLineTypeProps) {
+ const dispatch = useDispatch();
+ const chartType = useSelector((state) => state.report.chartType);
+ const type = useSelector((state) => state.report.lineType);
+
+ if (chartType != 'linear' && chartType != 'area') {
+ return null;
+ }
+
+ return (
+ {
+ dispatch(changeLineType(value));
+ }}
+ value={type}
+ items={objectToZodEnums(lineTypes).map((key) => ({
+ label: lineTypes[key],
+ value: key,
+ }))}
+ />
+ );
+}
diff --git a/apps/dashboard/src/components/report/ReportRange.tsx b/apps/dashboard/src/components/report/ReportRange.tsx
new file mode 100644
index 00000000..d71f81c7
--- /dev/null
+++ b/apps/dashboard/src/components/report/ReportRange.tsx
@@ -0,0 +1,101 @@
+import * as React from 'react';
+import { Button } from '@/components/ui/button';
+import { Calendar } from '@/components/ui/calendar';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { useBreakpoint } from '@/hooks/useBreakpoint';
+import { useDispatch, useSelector } from '@/redux';
+import { cn } from '@/utils/cn';
+import { timeRanges } from '@openpanel/constants';
+import type { IChartRange } from '@openpanel/validation';
+import { endOfDay, format, startOfDay } from 'date-fns';
+import { CalendarIcon, ChevronsUpDownIcon } from 'lucide-react';
+import type { SelectRangeEventHandler } from 'react-day-picker';
+
+import type { ExtendedComboboxProps } from '../ui/combobox';
+import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
+import { changeDates, changeEndDate, changeStartDate } from './reportSlice';
+
+export function ReportRange({
+ range,
+ onRangeChange,
+ onDatesChange,
+ dates,
+ className,
+ ...props
+}: {
+ range: IChartRange;
+ onRangeChange: (range: IChartRange) => void;
+ onDatesChange: SelectRangeEventHandler;
+ dates: { startDate: string | null; endDate: string | null };
+} & Omit, 'value' | 'onChange'>) {
+ const { isBelowSm } = useBreakpoint('sm');
+
+ return (
+ <>
+
+
+
+
+ {dates.startDate ? (
+ dates.endDate ? (
+ <>
+ {format(dates.startDate, 'LLL dd')} -{' '}
+ {format(dates.endDate, 'LLL dd')}
+ >
+ ) : (
+ format(dates.startDate, 'LLL dd, y')
+ )
+ ) : (
+ {range}
+ )}
+
+
+
+
+
+
+ {
+ if (value) onRangeChange(value as IChartRange);
+ }}
+ type="single"
+ variant="outline"
+ className="flex-wrap max-sm:max-w-xs"
+ >
+ {Object.values(timeRanges).map((key) => (
+
+ {key}
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/components/report/ReportSaveButton.tsx b/apps/dashboard/src/components/report/ReportSaveButton.tsx
similarity index 68%
rename from apps/web/src/components/report/ReportSaveButton.tsx
rename to apps/dashboard/src/components/report/ReportSaveButton.tsx
index 03cdc067..9fb874b9 100644
--- a/apps/web/src/components/report/ReportSaveButton.tsx
+++ b/apps/dashboard/src/components/report/ReportSaveButton.tsx
@@ -1,21 +1,25 @@
+'use client';
+
+import { api, handleError } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
-import { toast } from '@/components/ui/use-toast';
+import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
-import { api, handleError } from '@/utils/api';
import { SaveIcon } from 'lucide-react';
+import { toast } from 'sonner';
-import { useReportId } from './hooks/useReportId';
import { resetDirty } from './reportSlice';
-export function ReportSaveButton() {
- const { reportId } = useReportId();
+interface ReportSaveButtonProps {
+ className?: string;
+}
+export function ReportSaveButton({ className }: ReportSaveButtonProps) {
+ const { reportId } = useAppParams<{ reportId: string | undefined }>();
const dispatch = useDispatch();
const update = api.report.update.useMutation({
onSuccess() {
dispatch(resetDirty());
- toast({
- title: 'Success',
+ toast('Success', {
description: 'Report updated.',
});
},
@@ -26,11 +30,12 @@ export function ReportSaveButton() {
if (reportId) {
return (
{
update.mutate({
- reportId,
+ reportId: reportId,
report,
});
}}
@@ -42,6 +47,7 @@ export function ReportSaveButton() {
} else {
return (
{
pushModal('SaveReport', {
diff --git a/apps/dashboard/src/components/report/chart/Chart.tsx b/apps/dashboard/src/components/report/chart/Chart.tsx
new file mode 100644
index 00000000..4b935fb3
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/Chart.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import { api } from '@/app/_trpc/client';
+import type { IChartInput } from '@openpanel/validation';
+
+import { ChartEmpty } from './ChartEmpty';
+import { ReportAreaChart } from './ReportAreaChart';
+import { ReportBarChart } from './ReportBarChart';
+import { ReportHistogramChart } from './ReportHistogramChart';
+import { ReportLineChart } from './ReportLineChart';
+import { ReportMapChart } from './ReportMapChart';
+import { ReportMetricChart } from './ReportMetricChart';
+import { ReportPieChart } from './ReportPieChart';
+
+export type ReportChartProps = IChartInput;
+
+export function Chart({
+ interval,
+ events,
+ breakdowns,
+ chartType,
+ name,
+ range,
+ lineType,
+ previous,
+ formula,
+ unit,
+ metric,
+ projectId,
+ startDate,
+ endDate,
+}: ReportChartProps) {
+ const [references] = api.reference.getChartReferences.useSuspenseQuery({
+ projectId,
+ startDate,
+ endDate,
+ range,
+ });
+
+ const [data] = api.chart.chart.useSuspenseQuery(
+ {
+ // dont send lineType since it does not need to be sent
+ lineType: 'monotone',
+ interval,
+ chartType,
+ events,
+ breakdowns,
+ name,
+ range,
+ startDate,
+ endDate,
+ projectId,
+ previous,
+ formula,
+ unit,
+ metric,
+ },
+ {
+ keepPreviousData: true,
+ }
+ );
+
+ if (data.series.length === 0) {
+ return ;
+ }
+
+ if (chartType === 'map') {
+ return ;
+ }
+
+ if (chartType === 'histogram') {
+ return ;
+ }
+
+ if (chartType === 'bar') {
+ return ;
+ }
+
+ if (chartType === 'metric') {
+ return ;
+ }
+
+ if (chartType === 'pie') {
+ return ;
+ }
+
+ if (chartType === 'linear') {
+ return (
+
+ );
+ }
+
+ if (chartType === 'area') {
+ return (
+
+ );
+ }
+
+ return Unknown chart type
;
+}
diff --git a/apps/web/src/components/report/chart/ChartAnimation.tsx b/apps/dashboard/src/components/report/chart/ChartAnimation.tsx
similarity index 87%
rename from apps/web/src/components/report/chart/ChartAnimation.tsx
rename to apps/dashboard/src/components/report/chart/ChartAnimation.tsx
index 7d30102f..d9d48c0b 100644
--- a/apps/web/src/components/report/chart/ChartAnimation.tsx
+++ b/apps/dashboard/src/components/report/chart/ChartAnimation.tsx
@@ -24,6 +24,9 @@ export const ChartAnimationContainer = (
) => (
);
diff --git a/apps/dashboard/src/components/report/chart/ChartEmpty.tsx b/apps/dashboard/src/components/report/chart/ChartEmpty.tsx
new file mode 100644
index 00000000..068447a7
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ChartEmpty.tsx
@@ -0,0 +1,31 @@
+import { FullPageEmptyState } from '@/components/FullPageEmptyState';
+import { cn } from '@/utils/cn';
+
+import { useChartContext } from './ChartProvider';
+import { MetricCardEmpty } from './MetricCard';
+
+export function ChartEmpty() {
+ const { editMode, chartType } = useChartContext();
+
+ if (editMode) {
+ return (
+
+ We could not find any data for selected events and filter.
+
+ );
+ }
+
+ if (chartType === 'metric') {
+ return ;
+ }
+
+ return (
+
+ No data
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ChartLoading.tsx b/apps/dashboard/src/components/report/chart/ChartLoading.tsx
new file mode 100644
index 00000000..e3dfe1b2
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ChartLoading.tsx
@@ -0,0 +1,15 @@
+import { cn } from '@/utils/cn';
+
+interface ChartLoadingProps {
+ className?: string;
+}
+export function ChartLoading({ className }: ChartLoadingProps) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ChartProvider.tsx b/apps/dashboard/src/components/report/chart/ChartProvider.tsx
new file mode 100644
index 00000000..d4554e77
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ChartProvider.tsx
@@ -0,0 +1,109 @@
+'use client';
+
+import {
+ createContext,
+ memo,
+ Suspense,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import type { IChartSerie } from '@/server/api/routers/chart';
+import type { IChartInput } from '@openpanel/validation';
+
+import { ChartLoading } from './ChartLoading';
+import { MetricCardLoading } from './MetricCard';
+
+export interface ChartContextType extends IChartInput {
+ editMode?: boolean;
+ hideID?: boolean;
+ onClick?: (item: IChartSerie) => void;
+}
+
+type ChartProviderProps = {
+ children: React.ReactNode;
+} & ChartContextType;
+
+const ChartContext = createContext({
+ events: [],
+ breakdowns: [],
+ chartType: 'linear',
+ lineType: 'monotone',
+ interval: 'day',
+ name: '',
+ range: '7d',
+ metric: 'sum',
+ previous: false,
+ projectId: '',
+});
+
+export function ChartProvider({
+ children,
+ editMode,
+ previous,
+ hideID,
+ ...props
+}: ChartProviderProps) {
+ return (
+ ({
+ ...props,
+ editMode: editMode ?? false,
+ previous: previous ?? false,
+ hideID: hideID ?? false,
+ }),
+ [editMode, previous, hideID, props]
+ )}
+ >
+ {children}
+
+ );
+}
+
+export function withChartProivder(
+ WrappedComponent: React.FC
+) {
+ const WithChartProvider = (props: ComponentProps & ChartContextType) => {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return props.chartType === 'metric' ? (
+
+ ) : (
+
+ );
+ }
+
+ return (
+
+ ) : (
+
+ )
+ }
+ >
+
+
+
+
+ );
+ };
+
+ WithChartProvider.displayName = `WithChartProvider(${
+ WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'
+ })`;
+
+ return memo(WithChartProvider);
+}
+
+export function useChartContext() {
+ return useContext(ChartContext)!;
+}
diff --git a/apps/web/src/components/report/chart/LazyChart.tsx b/apps/dashboard/src/components/report/chart/LazyChart.tsx
similarity index 78%
rename from apps/web/src/components/report/chart/LazyChart.tsx
rename to apps/dashboard/src/components/report/chart/LazyChart.tsx
index 10eb95dd..32dc42bc 100644
--- a/apps/web/src/components/report/chart/LazyChart.tsx
+++ b/apps/dashboard/src/components/report/chart/LazyChart.tsx
@@ -1,8 +1,11 @@
+'use client';
+
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import type { ReportChartProps } from '.';
-import { Chart } from '.';
+import { ChartSwitch } from '.';
+import { ChartLoading } from './ChartLoading';
import type { ChartContextType } from './ChartProvider';
export function LazyChart(props: ReportChartProps & ChartContextType) {
@@ -21,9 +24,9 @@ export function LazyChart(props: ReportChartProps & ChartContextType) {
return (
{once.current || inViewport ? (
-
+
) : (
-
+
)}
);
diff --git a/apps/dashboard/src/components/report/chart/MetricCard.tsx b/apps/dashboard/src/components/report/chart/MetricCard.tsx
new file mode 100644
index 00000000..e924aa67
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/MetricCard.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import type { IChartData } from '@/app/_trpc/client';
+import { ColorSquare } from '@/components/ColorSquare';
+import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
+import { theme } from '@/utils/theme';
+import type { IChartMetric } from '@openpanel/validation';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import { Area, AreaChart } from 'recharts';
+
+import {
+ getDiffIndicator,
+ PreviousDiffIndicatorText,
+} from '../PreviousDiffIndicator';
+import { useChartContext } from './ChartProvider';
+
+interface MetricCardProps {
+ serie: IChartData['series'][number];
+ color?: string;
+ metric: IChartMetric;
+ unit?: string;
+}
+
+export function MetricCard({
+ serie,
+ color: _color,
+ metric,
+ unit,
+}: MetricCardProps) {
+ const { previousIndicatorInverted } = useChartContext();
+ const number = useNumber();
+
+ const renderValue = (value: number, unitClassName?: string) => {
+ if (unit === 'min') {
+ return <>{fancyMinutes(value)}>;
+ }
+
+ return (
+ <>
+ {number.short(value)}
+ {unit && {unit} }
+ >
+ );
+ };
+
+ const previous = serie.metrics.previous[metric];
+
+ const graphColors = getDiffIndicator(
+ previousIndicatorInverted,
+ previous?.state,
+ 'green',
+ 'red',
+ 'blue'
+ );
+
+ return (
+
+
+
+ {({ width, height }) => (
+
+
+
+ )}
+
+
+
+
+
+ {serie.event.id}
+
+ {serie.name || serie.event.displayName || serie.event.name}
+
+
+ {/*
*/}
+
+
+
+ {renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
+
+
+
+
+
+ );
+}
+
+export function MetricCardEmpty() {
+ return (
+
+ );
+}
+
+export function MetricCardLoading() {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ReportAreaChart.tsx b/apps/dashboard/src/components/report/chart/ReportAreaChart.tsx
new file mode 100644
index 00000000..f3f4bf23
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportAreaChart.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import type { IChartData } from '@/app/_trpc/client';
+import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
+import { useNumber } from '@/hooks/useNumerFormatter';
+import { useRechartDataModel } from '@/hooks/useRechartDataModel';
+import { useVisibleSeries } from '@/hooks/useVisibleSeries';
+import { getChartColor } from '@/utils/theme';
+import type { IChartLineType, IInterval } from '@openpanel/validation';
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import { getYAxisWidth } from './chart-utils';
+import { useChartContext } from './ChartProvider';
+import { ReportChartTooltip } from './ReportChartTooltip';
+import { ReportTable } from './ReportTable';
+import { ResponsiveContainer } from './ResponsiveContainer';
+
+interface ReportAreaChartProps {
+ data: IChartData;
+ interval: IInterval;
+ lineType: IChartLineType;
+}
+
+export function ReportAreaChart({
+ lineType,
+ interval,
+ data,
+}: ReportAreaChartProps) {
+ const { editMode } = useChartContext();
+ const { series, setVisibleSeries } = useVisibleSeries(data);
+ const formatDate = useFormatDateInterval(interval);
+ const rechartData = useRechartDataModel(series);
+ const number = useNumber();
+
+ return (
+ <>
+
+ {({ width, height }) => (
+
+ } />
+ formatDate(m)}
+ tickLine={false}
+ allowDuplicatedCategory={false}
+ />
+
+
+ {series.map((serie) => {
+ const color = getChartColor(serie.index);
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {editMode && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ReportBarChart.tsx b/apps/dashboard/src/components/report/chart/ReportBarChart.tsx
new file mode 100644
index 00000000..0187a337
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportBarChart.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useMemo } from 'react';
+import type { IChartData } from '@/app/_trpc/client';
+import { Progress } from '@/components/ui/progress';
+import { useNumber } from '@/hooks/useNumerFormatter';
+import { cn } from '@/utils/cn';
+import { getChartColor } from '@/utils/theme';
+import { NOT_SET_VALUE } from '@openpanel/constants';
+
+import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
+import { useChartContext } from './ChartProvider';
+import { SerieIcon } from './SerieIcon';
+
+interface ReportBarChartProps {
+ data: IChartData;
+}
+
+export function ReportBarChart({ data }: ReportBarChartProps) {
+ const { editMode, metric, onClick } = useChartContext();
+ const number = useNumber();
+ const series = useMemo(
+ () => (editMode ? data.series : data.series.slice(0, 10)),
+ [data, editMode]
+ );
+ const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
+
+ return (
+
+ {series.map((serie, index) => {
+ const isClickable = serie.name !== NOT_SET_VALUE && onClick;
+ return (
+
onClick(serie) } : {})}
+ >
+
+
+ {serie.name}
+
+
+
+ {serie.metrics.previous[metric]?.value}
+
+ {number.format(serie.metrics.sum)}
+
+
+
+
+ );
+ })}
+
+ );
+
+ // return (
+ //
+ //
+ // {table.getHeaderGroups().map((headerGroup) => (
+ //
+ // {headerGroup.headers.map((header) => (
+ //
+ //
+ // {flexRender(
+ // header.column.columnDef.header,
+ // header.getContext()
+ // )}
+ // {{
+ // asc: ,
+ // desc: ,
+ // }[header.column.getIsSorted() as string] ?? null}
+ //
+ //
+ // ))}
+ //
+ // ))}
+ //
+ //
+ // {table.getRowModel().rows.map((row) => (
+ //
+ // {row.getVisibleCells().map((cell) => (
+ //
+ // {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ //
+ // ))}
+ //
+ // ))}
+ //
+ //
+ // );
+}
diff --git a/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx b/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx
new file mode 100644
index 00000000..a05dac98
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
+import { useMappings } from '@/hooks/useMappings';
+import { useNumber } from '@/hooks/useNumerFormatter';
+import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
+import type { IToolTipProps } from '@/types';
+
+import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
+import { useChartContext } from './ChartProvider';
+
+type ReportLineChartTooltipProps = IToolTipProps<{
+ value: number;
+ dataKey: string;
+ payload: Record;
+}>;
+
+export function ReportChartTooltip({
+ active,
+ payload,
+}: ReportLineChartTooltipProps) {
+ const { unit, interval } = useChartContext();
+ const getLabel = useMappings();
+ const formatDate = useFormatDateInterval(interval);
+ const number = useNumber();
+ if (!active || !payload) {
+ return null;
+ }
+
+ if (!payload.length) {
+ return null;
+ }
+
+ const limit = 3;
+ const sorted = payload
+ .slice(0)
+ .filter((item) => !item.dataKey.includes(':prev:count'))
+ .sort((a, b) => b.value - a.value);
+ const visible = sorted.slice(0, limit);
+ const hidden = sorted.slice(limit);
+
+ return (
+
+ {visible.map((item, index) => {
+ // If we have a
| component, payload can be nested
+ const payload = item.payload.payload ?? item.payload;
+ const data = (
+ item.dataKey.includes(':')
+ ? // @ts-expect-error
+ payload[`${item.dataKey.split(':')[0]}:payload`]
+ : payload
+ ) as IRechartPayloadItem;
+
+ return (
+
+ {index === 0 && data.date && (
+
+
{formatDate(new Date(data.date))}
+
+ )}
+
+
+
+
+ {getLabel(data.label)}
+
+
+
{number.formatWithUnit(data.count, unit)}
+
+
+
+ {!!data.previous &&
+ `(${number.formatWithUnit(data.previous.value, unit)})`}
+
+
+
+
+
+
+ );
+ })}
+ {hidden.length > 0 && (
+
and {hidden.length} more...
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ReportHistogramChart.tsx b/apps/dashboard/src/components/report/chart/ReportHistogramChart.tsx
new file mode 100644
index 00000000..f8eddaec
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportHistogramChart.tsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import type { IChartData } from '@/app/_trpc/client';
+import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
+import { useNumber } from '@/hooks/useNumerFormatter';
+import { useRechartDataModel } from '@/hooks/useRechartDataModel';
+import { useVisibleSeries } from '@/hooks/useVisibleSeries';
+import { getChartColor, theme } from '@/utils/theme';
+import type { IInterval } from '@openpanel/validation';
+import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
+
+import { getYAxisWidth } from './chart-utils';
+import { useChartContext } from './ChartProvider';
+import { ReportChartTooltip } from './ReportChartTooltip';
+import { ReportTable } from './ReportTable';
+import { ResponsiveContainer } from './ResponsiveContainer';
+
+interface ReportHistogramChartProps {
+ data: IChartData;
+ interval: IInterval;
+}
+
+function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
+ const bg = theme?.colors?.slate?.['200'] as string;
+ return (
+
+ );
+}
+
+export function ReportHistogramChart({
+ interval,
+ data,
+}: ReportHistogramChartProps) {
+ const { editMode, previous } = useChartContext();
+ const formatDate = useFormatDateInterval(interval);
+ const { series, setVisibleSeries } = useVisibleSeries(data);
+ const rechartData = useRechartDataModel(series);
+ const number = useNumber();
+
+ return (
+ <>
+
+ {({ width, height }) => (
+
+
+ } cursor={ } />
+
+
+ {series.map((serie) => {
+ return (
+
+ {previous && (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {editMode && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ReportLineChart.tsx b/apps/dashboard/src/components/report/chart/ReportLineChart.tsx
new file mode 100644
index 00000000..581945a0
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportLineChart.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import React from 'react';
+import type { IChartData } from '@/app/_trpc/client';
+import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
+import { useNumber } from '@/hooks/useNumerFormatter';
+import { useRechartDataModel } from '@/hooks/useRechartDataModel';
+import { useVisibleSeries } from '@/hooks/useVisibleSeries';
+import { getChartColor } from '@/utils/theme';
+import type { IServiceReference } from '@openpanel/db';
+import type { IChartLineType, IInterval } from '@openpanel/validation';
+import {
+ CartesianGrid,
+ Line,
+ LineChart,
+ ReferenceLine,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import { getYAxisWidth } from './chart-utils';
+import { useChartContext } from './ChartProvider';
+import { ReportChartTooltip } from './ReportChartTooltip';
+import { ReportTable } from './ReportTable';
+import { ResponsiveContainer } from './ResponsiveContainer';
+
+interface ReportLineChartProps {
+ data: IChartData;
+ references: IServiceReference[];
+ interval: IInterval;
+ lineType: IChartLineType;
+}
+
+export function ReportLineChart({
+ lineType,
+ interval,
+ data,
+ references,
+}: ReportLineChartProps) {
+ const { editMode, previous } = useChartContext();
+ const formatDate = useFormatDateInterval(interval);
+ const { series, setVisibleSeries } = useVisibleSeries(data);
+ const rechartData = useRechartDataModel(series);
+ const number = useNumber();
+ console.log(references.map((ref) => ref.createdAt.getTime()));
+
+ return (
+ <>
+
+ {({ width, height }) => (
+
+ {references.map((ref) => (
+
+ ))}
+
+
+ } />
+ formatDate(new Date(m))}
+ type="number"
+ tickLine={false}
+ />
+ {series.map((serie) => {
+ return (
+
+
+ {previous && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {editMode && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ReportMapChart.tsx b/apps/dashboard/src/components/report/chart/ReportMapChart.tsx
new file mode 100644
index 00000000..2b83b875
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportMapChart.tsx
@@ -0,0 +1,40 @@
+import { useMemo } from 'react';
+import type { IChartData } from '@/app/_trpc/client';
+import { useVisibleSeries } from '@/hooks/useVisibleSeries';
+import { theme } from '@/utils/theme';
+import WorldMap from 'react-svg-worldmap';
+import AutoSizer from 'react-virtualized-auto-sizer';
+
+import { useChartContext } from './ChartProvider';
+
+interface ReportMapChartProps {
+ data: IChartData;
+}
+
+export function ReportMapChart({ data }: ReportMapChartProps) {
+ const { metric, unit } = useChartContext();
+ const { series } = useVisibleSeries(data, 100);
+
+ const mapData = useMemo(
+ () =>
+ series.map((s) => ({
+ country: s.name.toLowerCase(),
+ value: s.metrics[metric],
+ })),
+ [series, metric]
+ );
+
+ return (
+
+ {({ width }) => (
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ReportMetricChart.tsx b/apps/dashboard/src/components/report/chart/ReportMetricChart.tsx
new file mode 100644
index 00000000..500bda10
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportMetricChart.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import type { IChartData } from '@/app/_trpc/client';
+import { useVisibleSeries } from '@/hooks/useVisibleSeries';
+import { cn } from '@/utils/cn';
+
+import { useChartContext } from './ChartProvider';
+import { MetricCard } from './MetricCard';
+
+interface ReportMetricChartProps {
+ data: IChartData;
+}
+
+export function ReportMetricChart({ data }: ReportMetricChartProps) {
+ const { editMode, metric, unit } = useChartContext();
+ const { series } = useVisibleSeries(data, editMode ? undefined : 2);
+ return (
+
+ {series.map((serie) => {
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ReportPieChart.tsx b/apps/dashboard/src/components/report/chart/ReportPieChart.tsx
new file mode 100644
index 00000000..dc4211e0
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportPieChart.tsx
@@ -0,0 +1,129 @@
+import type { IChartData } from '@/app/_trpc/client';
+import { AutoSizer } from '@/components/AutoSizer';
+import { useVisibleSeries } from '@/hooks/useVisibleSeries';
+import { cn } from '@/utils/cn';
+import { round } from '@/utils/math';
+import { getChartColor } from '@/utils/theme';
+import { truncate } from '@/utils/truncate';
+import { Cell, Pie, PieChart, Tooltip } from 'recharts';
+
+import { useChartContext } from './ChartProvider';
+import { ReportChartTooltip } from './ReportChartTooltip';
+import { ReportTable } from './ReportTable';
+
+interface ReportPieChartProps {
+ data: IChartData;
+}
+
+export function ReportPieChart({ data }: ReportPieChartProps) {
+ const { editMode } = useChartContext();
+ const { series, setVisibleSeries } = useVisibleSeries(data);
+
+ const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
+ const pieData = series.map((serie) => ({
+ id: serie.name,
+ color: getChartColor(serie.index),
+ index: serie.index,
+ label: serie.name,
+ count: serie.metrics.sum,
+ percent: serie.metrics.sum / sum,
+ }));
+
+ return (
+ <>
+
+
+ {({ width }) => {
+ const height = Math.min(Math.max(width * 0.5625, 250), 400);
+ return (
+
+ } />
+
+ {pieData.map((item) => {
+ return (
+ |
+ );
+ })}
+
+
+ );
+ }}
+
+
+ {editMode && (
+
+ )}
+ >
+ );
+}
+
+const renderLabel = ({
+ cx,
+ cy,
+ midAngle,
+ innerRadius,
+ outerRadius,
+ fill,
+ payload,
+}: {
+ cx: number;
+ cy: number;
+ midAngle: number;
+ innerRadius: number;
+ outerRadius: number;
+ fill: string;
+ payload: { label: string; percent: number };
+}) => {
+ const RADIAN = Math.PI / 180;
+ const radius = 25 + innerRadius + (outerRadius - innerRadius);
+ const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
+ const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
+ const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
+ const x = cx + radius * Math.cos(-midAngle * RADIAN);
+ const y = cy + radius * Math.sin(-midAngle * RADIAN);
+ const label = payload.label;
+ const percent = round(payload.percent * 100, 1);
+
+ return (
+ <>
+
+ {percent}%
+
+ cx ? 'start' : 'end'}
+ dominantBaseline="central"
+ fontSize={10}
+ >
+ {truncate(label, 20)}
+
+ >
+ );
+};
diff --git a/apps/dashboard/src/components/report/chart/ReportTable.tsx b/apps/dashboard/src/components/report/chart/ReportTable.tsx
new file mode 100644
index 00000000..9bc4030b
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ReportTable.tsx
@@ -0,0 +1,169 @@
+import * as React from 'react';
+import type { IChartData } from '@/app/_trpc/client';
+import { Pagination, usePagination } from '@/components/Pagination';
+import { Badge } from '@/components/ui/badge';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
+import { useMappings } from '@/hooks/useMappings';
+import { useNumber } from '@/hooks/useNumerFormatter';
+import { useSelector } from '@/redux';
+import { getChartColor } from '@/utils/theme';
+
+import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
+
+interface ReportTableProps {
+ data: IChartData;
+ visibleSeries: IChartData['series'];
+ setVisibleSeries: React.Dispatch>;
+}
+
+export function ReportTable({
+ data,
+ visibleSeries,
+ setVisibleSeries,
+}: ReportTableProps) {
+ const { setPage, paginate, page } = usePagination(50);
+ const number = useNumber();
+ const interval = useSelector((state) => state.report.interval);
+ const formatDate = useFormatDateInterval(interval);
+ const getLabel = useMappings();
+
+ function handleChange(name: string, checked: boolean) {
+ setVisibleSeries((prev) => {
+ if (checked) {
+ return [...prev, name];
+ } else {
+ return prev.filter((item) => item !== name);
+ }
+ });
+ }
+
+ return (
+ <>
+
+
+
+
+ Name
+
+
+
+ {paginate(data.series).map((serie, index) => {
+ const checked = !!visibleSeries.find(
+ (item) => item.name === serie.name
+ );
+
+ return (
+
+
+
+
+ handleChange(serie.name, !!checked)
+ }
+ style={
+ checked
+ ? {
+ background: getChartColor(index),
+ borderColor: getChartColor(index),
+ }
+ : undefined
+ }
+ checked={checked}
+ />
+
+
+
+ {getLabel(serie.name)}
+
+
+
+ {getLabel(serie.name)}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ Total
+ Average
+ {data.series[0]?.data.map((serie) => (
+
+ {formatDate(serie.date)}
+
+ ))}
+
+
+
+ {paginate(data.series).map((serie) => {
+ return (
+
+
+
+ {number.format(serie.metrics.sum)}
+
+
+
+
+
+ {number.format(serie.metrics.average)}
+
+
+
+
+ {serie.data.map((item) => {
+ return (
+
+
+ {number.format(item.count)}
+
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+
+
+ Total: {number.format(data.metrics.sum)}
+ Average: {number.format(data.metrics.average)}
+ Min: {number.format(data.metrics.min)}
+ Max: {number.format(data.metrics.max)}
+
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/ResponsiveContainer.tsx b/apps/dashboard/src/components/report/chart/ResponsiveContainer.tsx
new file mode 100644
index 00000000..55205f2a
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/ResponsiveContainer.tsx
@@ -0,0 +1,36 @@
+import { cn } from '@/utils/cn';
+import AutoSizer from 'react-virtualized-auto-sizer';
+
+import { useChartContext } from './ChartProvider';
+
+interface ResponsiveContainerProps {
+ children: (props: { width: number; height: number }) => React.ReactNode;
+}
+
+export function ResponsiveContainer({ children }: ResponsiveContainerProps) {
+ const { editMode } = useChartContext();
+ const maxHeight = 300;
+ const minHeight = 200;
+ return (
+
+
+ {({ width }) =>
+ children({
+ width,
+ height: Math.min(
+ Math.max(width * 0.5625, minHeight),
+ // we add p-4 (16px) padding in edit mode
+ editMode ? maxHeight - 16 : maxHeight
+ ),
+ })
+ }
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/chart/SerieIcon.tsx b/apps/dashboard/src/components/report/chart/SerieIcon.tsx
new file mode 100644
index 00000000..26049ae0
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/SerieIcon.tsx
@@ -0,0 +1,240 @@
+import { useMemo } from 'react';
+import { NOT_SET_VALUE } from '@openpanel/constants';
+import type { LucideIcon, LucideProps } from 'lucide-react';
+import {
+ ActivityIcon,
+ ExternalLinkIcon,
+ HelpCircleIcon,
+ MailIcon,
+ MonitorIcon,
+ MonitorPlayIcon,
+ PodcastIcon,
+ ScanIcon,
+ SearchIcon,
+ SmartphoneIcon,
+ TabletIcon,
+} from 'lucide-react';
+
+interface SerieIconProps extends LucideProps {
+ name: string;
+}
+
+function getProxyImage(url: string) {
+ return `${String(process.env.NEXT_PUBLIC_API_URL)}/misc/favicon?url=${encodeURIComponent(url)}`;
+}
+
+const createImageIcon = (url: string) => {
+ return function (props: LucideProps) {
+ return ;
+ } as LucideIcon;
+};
+
+const createFlagIcon = (url: string) => {
+ return function (props: LucideProps) {
+ return (
+
+ );
+ } as LucideIcon;
+};
+
+const mapper: Record = {
+ // Events
+ screen_view: MonitorPlayIcon,
+ session_start: ActivityIcon,
+ session_end: ActivityIcon,
+ link_out: ExternalLinkIcon,
+
+ // Websites
+ linkedin: createImageIcon(getProxyImage('https://linkedin.com')),
+ slack: createImageIcon(getProxyImage('https://slack.com')),
+ pinterest: createImageIcon(getProxyImage('https://www.pinterest.se')),
+ ecosia: createImageIcon(getProxyImage('https://ecosia.com')),
+ yandex: createImageIcon(getProxyImage('https://yandex.com')),
+ google: createImageIcon(getProxyImage('https://google.com')),
+ facebook: createImageIcon(getProxyImage('https://facebook.com')),
+ bing: createImageIcon(getProxyImage('https://bing.com')),
+ twitter: createImageIcon(getProxyImage('https://x.com')),
+ duckduckgo: createImageIcon(getProxyImage('https://duckduckgo.com')),
+ 'yahoo!': createImageIcon(getProxyImage('https://yahoo.com')),
+ instagram: createImageIcon(getProxyImage('https://instagram.com')),
+ gmail: createImageIcon(getProxyImage('https://mail.google.com/')),
+
+ 'mobile safari': createImageIcon(
+ getProxyImage(
+ 'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg'
+ )
+ ),
+ chrome: createImageIcon(
+ getProxyImage(
+ 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg'
+ )
+ ),
+ 'samsung internet': createImageIcon(
+ getProxyImage(
+ 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png'
+ )
+ ),
+ safari: createImageIcon(
+ getProxyImage(
+ 'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg'
+ )
+ ),
+ edge: createImageIcon(
+ getProxyImage(
+ 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png'
+ )
+ ),
+ firefox: createImageIcon(
+ getProxyImage(
+ 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png'
+ )
+ ),
+ snapchat: createImageIcon(getProxyImage('https://snapchat.com')),
+
+ // Misc
+ mobile: SmartphoneIcon,
+ desktop: MonitorIcon,
+ tablet: TabletIcon,
+ search: SearchIcon,
+ social: PodcastIcon,
+ email: MailIcon,
+ unknown: HelpCircleIcon,
+ [NOT_SET_VALUE]: ScanIcon,
+
+ // Flags
+ se: createFlagIcon('se'),
+ us: createFlagIcon('us'),
+ gb: createFlagIcon('gb'),
+ ua: createFlagIcon('ua'),
+ ru: createFlagIcon('ru'),
+ de: createFlagIcon('de'),
+ fr: createFlagIcon('fr'),
+ br: createFlagIcon('br'),
+ in: createFlagIcon('in'),
+ it: createFlagIcon('it'),
+ es: createFlagIcon('es'),
+ pl: createFlagIcon('pl'),
+ nl: createFlagIcon('nl'),
+ id: createFlagIcon('id'),
+ tr: createFlagIcon('tr'),
+ ph: createFlagIcon('ph'),
+ ca: createFlagIcon('ca'),
+ ar: createFlagIcon('ar'),
+ mx: createFlagIcon('mx'),
+ za: createFlagIcon('za'),
+ au: createFlagIcon('au'),
+ co: createFlagIcon('co'),
+ ch: createFlagIcon('ch'),
+ at: createFlagIcon('at'),
+ be: createFlagIcon('be'),
+ pt: createFlagIcon('pt'),
+ my: createFlagIcon('my'),
+ th: createFlagIcon('th'),
+ vn: createFlagIcon('vn'),
+ sg: createFlagIcon('sg'),
+ eg: createFlagIcon('eg'),
+ sa: createFlagIcon('sa'),
+ pk: createFlagIcon('pk'),
+ bd: createFlagIcon('bd'),
+ ro: createFlagIcon('ro'),
+ hu: createFlagIcon('hu'),
+ cz: createFlagIcon('cz'),
+ gr: createFlagIcon('gr'),
+ il: createFlagIcon('il'),
+ no: createFlagIcon('no'),
+ fi: createFlagIcon('fi'),
+ dk: createFlagIcon('dk'),
+ sk: createFlagIcon('sk'),
+ bg: createFlagIcon('bg'),
+ hr: createFlagIcon('hr'),
+ rs: createFlagIcon('rs'),
+ ba: createFlagIcon('ba'),
+ si: createFlagIcon('si'),
+ lv: createFlagIcon('lv'),
+ lt: createFlagIcon('lt'),
+ ee: createFlagIcon('ee'),
+ by: createFlagIcon('by'),
+ md: createFlagIcon('md'),
+ kz: createFlagIcon('kz'),
+ uz: createFlagIcon('uz'),
+ kg: createFlagIcon('kg'),
+ tj: createFlagIcon('tj'),
+ tm: createFlagIcon('tm'),
+ az: createFlagIcon('az'),
+ ge: createFlagIcon('ge'),
+ am: createFlagIcon('am'),
+ af: createFlagIcon('af'),
+ ir: createFlagIcon('ir'),
+ iq: createFlagIcon('iq'),
+ sy: createFlagIcon('sy'),
+ lb: createFlagIcon('lb'),
+ jo: createFlagIcon('jo'),
+ ps: createFlagIcon('ps'),
+ kw: createFlagIcon('kw'),
+ qa: createFlagIcon('qa'),
+ om: createFlagIcon('om'),
+ ye: createFlagIcon('ye'),
+ ae: createFlagIcon('ae'),
+ bh: createFlagIcon('bh'),
+ cy: createFlagIcon('cy'),
+ mt: createFlagIcon('mt'),
+ sm: createFlagIcon('sm'),
+ li: createFlagIcon('li'),
+ is: createFlagIcon('is'),
+ al: createFlagIcon('al'),
+ mk: createFlagIcon('mk'),
+ me: createFlagIcon('me'),
+ ad: createFlagIcon('ad'),
+ lu: createFlagIcon('lu'),
+ mc: createFlagIcon('mc'),
+ fo: createFlagIcon('fo'),
+ gg: createFlagIcon('gg'),
+ je: createFlagIcon('je'),
+ im: createFlagIcon('im'),
+ gi: createFlagIcon('gi'),
+ va: createFlagIcon('va'),
+ ax: createFlagIcon('ax'),
+ bl: createFlagIcon('bl'),
+ mf: createFlagIcon('mf'),
+ pm: createFlagIcon('pm'),
+ yt: createFlagIcon('yt'),
+ wf: createFlagIcon('wf'),
+ tf: createFlagIcon('tf'),
+ re: createFlagIcon('re'),
+ sc: createFlagIcon('sc'),
+ mu: createFlagIcon('mu'),
+ zw: createFlagIcon('zw'),
+ mz: createFlagIcon('mz'),
+ na: createFlagIcon('na'),
+ bw: createFlagIcon('bw'),
+ ls: createFlagIcon('ls'),
+ sz: createFlagIcon('sz'),
+ bi: createFlagIcon('bi'),
+ rw: createFlagIcon('rw'),
+ ug: createFlagIcon('ug'),
+ ke: createFlagIcon('ke'),
+ tz: createFlagIcon('tz'),
+ mg: createFlagIcon('mg'),
+};
+
+export function SerieIcon({ name, ...props }: SerieIconProps) {
+ const Icon = useMemo(() => {
+ const mapped = mapper[name.toLowerCase()] ?? null;
+
+ if (mapped) {
+ return mapped;
+ }
+
+ if (name.includes('http')) {
+ return createImageIcon(getProxyImage(name));
+ }
+
+ return null;
+ }, [name]);
+
+ return Icon ? (
+
+
+
+ ) : null;
+}
diff --git a/apps/dashboard/src/components/report/chart/chart-utils.ts b/apps/dashboard/src/components/report/chart/chart-utils.ts
new file mode 100644
index 00000000..e37d5e80
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/chart-utils.ts
@@ -0,0 +1,11 @@
+const formatter = new Intl.NumberFormat('en', {
+ notation: 'compact',
+});
+
+export function getYAxisWidth(value: number) {
+ if (!isFinite(value)) {
+ return 7.8 + 7.8;
+ }
+
+ return formatter.format(value).toString().length * 7.8 + 7.8;
+}
diff --git a/apps/dashboard/src/components/report/chart/index.tsx b/apps/dashboard/src/components/report/chart/index.tsx
new file mode 100644
index 00000000..a32f5f07
--- /dev/null
+++ b/apps/dashboard/src/components/report/chart/index.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import type { IChartInput } from '@openpanel/validation';
+
+import { Funnel } from '../funnel';
+import { Chart } from './Chart';
+import { withChartProivder } from './ChartProvider';
+
+export type ReportChartProps = IChartInput;
+
+export const ChartSwitch = withChartProivder(function ChartSwitch(
+ props: ReportChartProps
+) {
+ if (props.chartType === 'funnel') {
+ return ;
+ }
+
+ return ;
+});
+
+interface ChartSwitchShortcutProps {
+ projectId: ReportChartProps['projectId'];
+ range?: ReportChartProps['range'];
+ previous?: ReportChartProps['previous'];
+ chartType?: ReportChartProps['chartType'];
+ interval?: ReportChartProps['interval'];
+ events: ReportChartProps['events'];
+}
+
+export const ChartSwitchShortcut = ({
+ projectId,
+ range = '7d',
+ previous = false,
+ chartType = 'linear',
+ interval = 'day',
+ events,
+}: ChartSwitchShortcutProps) => {
+ return (
+
+ );
+};
diff --git a/apps/dashboard/src/components/report/funnel/Funnel.tsx b/apps/dashboard/src/components/report/funnel/Funnel.tsx
new file mode 100644
index 00000000..43c97e85
--- /dev/null
+++ b/apps/dashboard/src/components/report/funnel/Funnel.tsx
@@ -0,0 +1,175 @@
+'use client';
+
+import type { RouterOutputs } from '@/app/_trpc/client';
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from '@/components/ui/carousel';
+import { cn } from '@/utils/cn';
+import { round } from '@/utils/math';
+import { ArrowRight, ArrowRightIcon } from 'lucide-react';
+
+import { useChartContext } from '../chart/ChartProvider';
+
+function FunnelChart({ from, to }: { from: number; to: number }) {
+ const fromY = 100 - from;
+ const toY = 100 - to;
+ const steps = [
+ `M0,${fromY}`,
+ 'L0,100',
+ 'L100,100',
+ `L100,${toY}`,
+ `L0,${fromY}`,
+ ];
+ return (
+
+
+
+ {/* bottom */}
+
+ {/* top */}
+
+
+
+ {/* bottom */}
+
+ {/* top */}
+
+
+
+
+
+
+ );
+}
+
+function getDropoffColor(value: number) {
+ if (value > 80) {
+ return 'text-red-600';
+ }
+ if (value > 50) {
+ return 'text-orange-600';
+ }
+ if (value > 30) {
+ return 'text-yellow-600';
+ }
+ return 'text-green-600';
+}
+
+export function FunnelSteps({
+ steps,
+ totalSessions,
+}: RouterOutputs['chart']['funnel']) {
+ const { editMode } = useChartContext();
+ return (
+
+
+
+ {steps.map((step, index, list) => {
+ const finalStep = index === list.length - 1;
+ return (
+
+
+
+
Step {index + 1}
+
+ {step.event.displayName || step.event.name}
+
+
+
+
+
+
+ Sessions
+
+
+
+ {step.before}
+
+
+
{step.current}
+
+ {index !== 0 && (
+ <>
+
+ {step.current} of {totalSessions} (
+ {round(step.percent, 1)}%)
+
+ >
+ )}
+
+
+ {finalStep ? (
+
+
+ Conversion
+
+
+ {round(step.percent, 1)}%
+
+
+ Converted {step.current} of {totalSessions} sessions
+
+
+ ) : (
+
+
Dropoff
+
+ {round(step.dropoff.percent, 1)}%
+
+
+ Lost {step.dropoff.count} sessions
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/funnel/index.tsx b/apps/dashboard/src/components/report/funnel/index.tsx
new file mode 100644
index 00000000..f0ab77a1
--- /dev/null
+++ b/apps/dashboard/src/components/report/funnel/index.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import type { RouterOutputs } from '@/app/_trpc/client';
+import { api } from '@/app/_trpc/client';
+import type { IChartInput } from '@openpanel/validation';
+
+import { ChartEmpty } from '../chart/ChartEmpty';
+import { withChartProivder } from '../chart/ChartProvider';
+import { FunnelSteps } from './Funnel';
+
+export type ReportChartProps = IChartInput & {
+ initialData?: RouterOutputs['chart']['funnel'];
+};
+
+export const Funnel = withChartProivder(function Chart({
+ events,
+ name,
+ range,
+ projectId,
+}: ReportChartProps) {
+ const [data] = api.chart.funnel.useSuspenseQuery(
+ {
+ events,
+ name,
+ range,
+ projectId,
+ lineType: 'monotone',
+ interval: 'day',
+ chartType: 'funnel',
+ breakdowns: [],
+ startDate: null,
+ endDate: null,
+ previous: false,
+ formula: undefined,
+ unit: undefined,
+ metric: 'sum',
+ },
+ {
+ keepPreviousData: true,
+ }
+ );
+
+ if (data.steps.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+});
diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/dashboard/src/components/report/reportSlice.ts
similarity index 64%
rename from apps/web/src/components/report/reportSlice.ts
rename to apps/dashboard/src/components/report/reportSlice.ts
index cd883e73..b31f228c 100644
--- a/apps/web/src/components/report/reportSlice.ts
+++ b/apps/dashboard/src/components/report/reportSlice.ts
@@ -1,32 +1,50 @@
+import { start } from 'repl';
+import {
+ alphabetIds,
+ getDefaultIntervalByDates,
+ getDefaultIntervalByRange,
+ isHourIntervalEnabledByRange,
+ isMinuteIntervalEnabledByRange,
+} from '@openpanel/constants';
import type {
IChartBreakdown,
IChartEvent,
IChartInput,
+ IChartLineType,
IChartRange,
IChartType,
IInterval,
-} from '@/types';
-import { alphabetIds, isMinuteIntervalEnabledByRange } from '@/utils/constants';
+} from '@openpanel/validation';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
+import { isSameDay, isSameMonth } from 'date-fns';
type InitialState = IChartInput & {
dirty: boolean;
+ ready: boolean;
startDate: string | null;
endDate: string | null;
};
// First approach: define the initial state using that type
const initialState: InitialState = {
+ ready: false,
dirty: false,
+ // TODO: remove this
+ projectId: '',
name: 'Untitled',
chartType: 'linear',
+ lineType: 'monotone',
interval: 'day',
breakdowns: [],
events: [],
range: '1m',
startDate: null,
endDate: null,
+ previous: false,
+ formula: undefined,
+ unit: undefined,
+ metric: 'sum',
};
export const reportSlice = createSlice({
@@ -42,12 +60,20 @@ export const reportSlice = createSlice({
reset() {
return initialState;
},
+ ready() {
+ return {
+ ...initialState,
+ ready: true,
+ };
+ },
setReport(state, action: PayloadAction) {
return {
+ ...state,
...action.payload,
startDate: null,
endDate: null,
dirty: false,
+ ready: true,
};
},
setName(state, action: PayloadAction) {
@@ -83,6 +109,12 @@ export const reportSlice = createSlice({
});
},
+ // Previous
+ changePrevious: (state, action: PayloadAction) => {
+ state.dirty = true;
+ state.previous = action.payload;
+ },
+
// Breakdowns
addBreakdown: (
state,
@@ -132,32 +164,83 @@ export const reportSlice = createSlice({
) {
state.interval = 'hour';
}
+
+ if (
+ !isHourIntervalEnabledByRange(state.range) &&
+ state.interval === 'hour'
+ ) {
+ state.interval = 'day';
+ }
+ },
+
+ // Line type
+ changeLineType: (state, action: PayloadAction) => {
+ state.dirty = true;
+ state.lineType = action.payload;
+ },
+
+ // Custom start and end date
+ changeDates: (
+ state,
+ action: PayloadAction<{
+ startDate: string;
+ endDate: string;
+ }>
+ ) => {
+ state.dirty = true;
+ state.startDate = action.payload.startDate;
+ state.endDate = action.payload.endDate;
+
+ if (isSameDay(state.startDate, state.endDate)) {
+ state.interval = 'hour';
+ } else if (isSameMonth(state.startDate, state.endDate)) {
+ state.interval = 'day';
+ } else {
+ state.interval = 'month';
+ }
},
// Date range
changeStartDate: (state, action: PayloadAction) => {
state.dirty = true;
state.startDate = action.payload;
+
+ const interval = getDefaultIntervalByDates(
+ state.startDate,
+ state.endDate
+ );
+ if (interval) {
+ state.interval = interval;
+ }
},
// Date range
changeEndDate: (state, action: PayloadAction) => {
state.dirty = true;
state.endDate = action.payload;
+
+ const interval = getDefaultIntervalByDates(
+ state.startDate,
+ state.endDate
+ );
+ if (interval) {
+ state.interval = interval;
+ }
},
changeDateRanges: (state, action: PayloadAction) => {
state.dirty = true;
state.range = action.payload;
- if (action.payload === '30min' || action.payload === '1h') {
- state.interval = 'minute';
- } else if (action.payload === 'today' || action.payload === '24h') {
- state.interval = 'hour';
- } else if (action.payload === '7d' || action.payload === '14d') {
- state.interval = 'day';
- } else {
- state.interval = 'month';
- }
+ state.startDate = null;
+ state.endDate = null;
+
+ state.interval = getDefaultIntervalByRange(action.payload);
+ },
+
+ // Formula
+ changeFormula: (state, action: PayloadAction) => {
+ state.dirty = true;
+ state.formula = action.payload;
},
},
});
@@ -165,6 +248,7 @@ export const reportSlice = createSlice({
// Action creators are generated for each case reducer function
export const {
reset,
+ ready,
setReport,
setName,
addEvent,
@@ -174,9 +258,15 @@ export const {
removeBreakdown,
changeBreakdown,
changeInterval,
+ changeDates,
+ changeStartDate,
+ changeEndDate,
changeDateRanges,
changeChartType,
+ changeLineType,
resetDirty,
+ changeFormula,
+ changePrevious,
} = reportSlice.actions;
export default reportSlice.reducer;
diff --git a/apps/dashboard/src/components/report/sidebar/EventPropertiesCombobox.tsx b/apps/dashboard/src/components/report/sidebar/EventPropertiesCombobox.tsx
new file mode 100644
index 00000000..6e18e8bf
--- /dev/null
+++ b/apps/dashboard/src/components/report/sidebar/EventPropertiesCombobox.tsx
@@ -0,0 +1,62 @@
+import { api } from '@/app/_trpc/client';
+import { Combobox } from '@/components/ui/combobox';
+import { useAppParams } from '@/hooks/useAppParams';
+import { useDispatch } from '@/redux';
+import { cn } from '@/utils/cn';
+import type { IChartEvent } from '@openpanel/validation';
+import { DatabaseIcon } from 'lucide-react';
+
+import { changeEvent } from '../reportSlice';
+
+interface EventPropertiesComboboxProps {
+ event: IChartEvent;
+}
+
+export function EventPropertiesCombobox({
+ event,
+}: EventPropertiesComboboxProps) {
+ const dispatch = useDispatch();
+ const { projectId } = useAppParams();
+
+ const query = api.chart.properties.useQuery(
+ {
+ event: event.name,
+ projectId,
+ },
+ {
+ enabled: !!event.name,
+ }
+ );
+
+ const properties = (query.data ?? []).map((item) => ({
+ label: item,
+ value: item,
+ }));
+
+ return (
+ {
+ dispatch(
+ changeEvent({
+ ...event,
+ property: value,
+ })
+ );
+ }}
+ >
+
+ {' '}
+ {event.property ? `Property: ${event.property}` : 'Select property'}
+
+
+ );
+}
diff --git a/apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx b/apps/dashboard/src/components/report/sidebar/ReportBreakdownMore.tsx
similarity index 100%
rename from apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx
rename to apps/dashboard/src/components/report/sidebar/ReportBreakdownMore.tsx
diff --git a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx b/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx
similarity index 82%
rename from apps/web/src/components/report/sidebar/ReportBreakdowns.tsx
rename to apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx
index 9c3275f0..0f8b422a 100644
--- a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx
+++ b/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx
@@ -1,20 +1,23 @@
+'use client';
+
+import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Combobox } from '@/components/ui/combobox';
-import { useOrganizationParams } from '@/hooks/useOrganizationParams';
+import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
-import type { IChartBreakdown } from '@/types';
-import { api } from '@/utils/api';
+import type { IChartBreakdown } from '@openpanel/validation';
+import { SplitIcon } from 'lucide-react';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
- const params = useOrganizationParams();
+ const { projectId } = useAppParams();
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery({
- projectSlug: params.project,
+ projectId,
});
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
value: item,
@@ -39,10 +42,13 @@ export function ReportBreakdowns() {
{selectedBreakdowns.map((item, index) => {
return (
-
+
{index}
{
dispatch(
@@ -63,6 +69,8 @@ export function ReportBreakdowns() {
{selectedBreakdowns.length === 0 && (
{
dispatch(
diff --git a/apps/web/src/components/report/sidebar/ReportEventMore.tsx b/apps/dashboard/src/components/report/sidebar/ReportEventMore.tsx
similarity index 87%
rename from apps/web/src/components/report/sidebar/ReportEventMore.tsx
rename to apps/dashboard/src/components/report/sidebar/ReportEventMore.tsx
index 987a5f34..25168b87 100644
--- a/apps/web/src/components/report/sidebar/ReportEventMore.tsx
+++ b/apps/dashboard/src/components/report/sidebar/ReportEventMore.tsx
@@ -34,7 +34,7 @@ const labels = [
];
export interface ReportEventMoreProps {
- onClick: (action: 'createFilter' | 'remove') => void;
+ onClick: (action: 'remove') => void;
}
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
@@ -49,10 +49,6 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) {
- onClick('createFilter')}>
-
- Add filter
-
state.report.previous);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
- const params = useOrganizationParams();
- const eventsQuery = api.chart.events.useQuery({
- projectSlug: params.project,
- });
- const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
- value: item.name,
- label: item.name,
- }));
+ const { projectId } = useAppParams();
+ const eventNames = useEventNames(projectId);
+
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
dispatch(changeEvent(event));
});
@@ -34,9 +38,6 @@ export function ReportEvents() {
const handleMore = (event: IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
- case 'createFilter': {
- return setIsCreating(true);
- }
case 'remove': {
return dispatch(removeEvent(event));
}
@@ -52,10 +53,13 @@ export function ReportEvents() {
{selectedEvents.map((event) => {
return (
-
+
{event.id}
{
dispatch(
@@ -66,7 +70,10 @@ export function ReportEvents() {
})
);
}}
- items={eventsCombobox}
+ items={eventNames.map((item) => ({
+ label: item.name,
+ value: item.name,
+ }))}
placeholder="Select event"
/>
-
+
{event.segment === 'user' ? (
<>
Unique users
>
+ ) : event.segment === 'session' ? (
+ <>
+ Unique sessions
+ >
) : event.segment === 'user_average' ? (
<>
- Unique users (average)
+ Average event per user
>
) : event.segment === 'one_event_per_user' ? (
<>
One event per user
>
+ ) : event.segment === 'property_sum' ? (
+ <>
+ Sum of property
+ >
+ ) : event.segment === 'property_average' ? (
+ <>
+ Average of property
+ >
) : (
<>
All events
@@ -135,24 +166,25 @@ export function ReportEvents() {
)}
- {
- handleMore(event)('createFilter');
- }}
- className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs"
- >
- Filter
-
+ {/* */}
+
+
+ {(event.segment === 'property_average' ||
+ event.segment === 'property_sum') && (
+
+ )}
{/* Filters */}
-
+
);
})}
{
dispatch(
addEvent({
@@ -162,10 +194,24 @@ export function ReportEvents() {
})
);
}}
- items={eventsCombobox}
+ items={eventNames.map((item) => ({
+ label: item.name,
+ value: item.name,
+ }))}
placeholder="Select event"
/>
+
+ dispatch(changePrevious(!!val))}
+ />
+ Show previous / Compare
+
);
}
diff --git a/apps/dashboard/src/components/report/sidebar/ReportForumula.tsx b/apps/dashboard/src/components/report/sidebar/ReportForumula.tsx
new file mode 100644
index 00000000..1908bbe7
--- /dev/null
+++ b/apps/dashboard/src/components/report/sidebar/ReportForumula.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { Input } from '@/components/ui/input';
+import { useDispatch, useSelector } from '@/redux';
+
+import { changeFormula } from '../reportSlice';
+
+export function ReportForumula() {
+ const forumula = useSelector((state) => state.report.formula);
+ const dispatch = useDispatch();
+
+ return (
+
+
Forumula
+
+ {
+ dispatch(changeFormula(event.target.value));
+ }}
+ />
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx b/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx
new file mode 100644
index 00000000..2a4b8011
--- /dev/null
+++ b/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx
@@ -0,0 +1,27 @@
+import { Button } from '@/components/ui/button';
+import { SheetClose, SheetFooter } from '@/components/ui/sheet';
+import { useSelector } from '@/redux';
+
+import { ReportBreakdowns } from './ReportBreakdowns';
+import { ReportEvents } from './ReportEvents';
+import { ReportForumula } from './ReportForumula';
+
+export function ReportSidebar() {
+ const { chartType } = useSelector((state) => state.report);
+ const showForumula = chartType !== 'funnel';
+ const showBreakdown = chartType !== 'funnel';
+ return (
+ <>
+
+
+ {showForumula && }
+ {showBreakdown && }
+
+
+
+ Done
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx
new file mode 100644
index 00000000..a5294c4e
--- /dev/null
+++ b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx
@@ -0,0 +1,132 @@
+import { api } from '@/app/_trpc/client';
+import { ColorSquare } from '@/components/ColorSquare';
+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 { operators } from '@openpanel/constants';
+import type {
+ IChartEvent,
+ IChartEventFilterOperator,
+ IChartEventFilterValue,
+} from '@openpanel/validation';
+import { mapKeys } from '@openpanel/validation';
+import { SlidersHorizontal, Trash } from 'lucide-react';
+
+import { changeEvent } from '../../reportSlice';
+
+interface FilterProps {
+ event: IChartEvent;
+ filter: IChartEvent['filters'][number];
+}
+
+export function FilterItem({ filter, event }: FilterProps) {
+ const { projectId } = useAppParams();
+ const getLabel = useMappings();
+ const dispatch = useDispatch();
+ const potentialValues = api.chart.values.useQuery({
+ event: event.name,
+ property: filter.name,
+ projectId,
+ });
+
+ const valuesCombobox =
+ potentialValues.data?.values?.map((item) => ({
+ value: item,
+ label: getLabel(item),
+ })) ?? [];
+
+ const removeFilter = () => {
+ dispatch(
+ changeEvent({
+ ...event,
+ filters: event.filters.filter((item) => item.id !== filter.id),
+ })
+ );
+ };
+
+ const changeFilterValue = (
+ value: IChartEventFilterValue | IChartEventFilterValue[]
+ ) => {
+ dispatch(
+ changeEvent({
+ ...event,
+ filters: event.filters.map((item) => {
+ if (item.id === filter.id) {
+ return {
+ ...item,
+ value: Array.isArray(value) ? value : [value],
+ };
+ }
+
+ return item;
+ }),
+ })
+ );
+ };
+
+ const changeFilterOperator = (operator: IChartEventFilterOperator) => {
+ dispatch(
+ changeEvent({
+ ...event,
+ filters: event.filters.map((item) => {
+ if (item.id === filter.id) {
+ return {
+ ...item,
+ operator,
+ };
+ }
+
+ return item;
+ }),
+ })
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ {filter.name}
+
+
+
+
+
+
+ ({
+ value: key,
+ label: operators[key],
+ }))}
+ label="Operator"
+ >
+
+ {operators[filter.operator]}
+
+
+ {
+ changeFilterValue(
+ typeof setFn === 'function' ? setFn(filter.value) : setFn
+ );
+ }}
+ placeholder="Select..."
+ />
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx b/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx
new file mode 100644
index 00000000..d29c381a
--- /dev/null
+++ b/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx
@@ -0,0 +1,61 @@
+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 '@openpanel/validation';
+import { FilterIcon } from 'lucide-react';
+
+import { changeEvent } from '../../reportSlice';
+
+interface FiltersComboboxProps {
+ event: IChartEvent;
+}
+
+export function FiltersCombobox({ event }: FiltersComboboxProps) {
+ const dispatch = useDispatch();
+ const { projectId } = useAppParams();
+
+ const query = api.chart.properties.useQuery(
+ {
+ event: event.name,
+ projectId,
+ },
+ {
+ enabled: !!event.name,
+ }
+ );
+
+ const properties = (query.data ?? []).map((item) => ({
+ label: item,
+ value: item,
+ }));
+
+ return (
+ {
+ dispatch(
+ changeEvent({
+ ...event,
+ filters: [
+ ...event.filters,
+ {
+ id: (event.filters.length + 1).toString(),
+ name: value,
+ operator: 'is',
+ value: [],
+ },
+ ],
+ })
+ );
+ }}
+ >
+
+ Add filter
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/report/sidebar/filters/FiltersList.tsx b/apps/dashboard/src/components/report/sidebar/filters/FiltersList.tsx
new file mode 100644
index 00000000..58b73591
--- /dev/null
+++ b/apps/dashboard/src/components/report/sidebar/filters/FiltersList.tsx
@@ -0,0 +1,19 @@
+import type { IChartEvent } from '@openpanel/validation';
+
+import { FilterItem } from './FilterItem';
+
+interface ReportEventFiltersProps {
+ event: IChartEvent;
+}
+
+export function FiltersList({ event }: ReportEventFiltersProps) {
+ return (
+
+
+ {event.filters.map((filter) => {
+ return ;
+ })}
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/RenderDots.tsx b/apps/dashboard/src/components/ui/RenderDots.tsx
similarity index 99%
rename from apps/web/src/components/ui/RenderDots.tsx
rename to apps/dashboard/src/components/ui/RenderDots.tsx
index 8542aec9..267c42e7 100644
--- a/apps/web/src/components/ui/RenderDots.tsx
+++ b/apps/dashboard/src/components/ui/RenderDots.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import { cn } from '@/utils/cn';
import { Asterisk, ChevronRight } from 'lucide-react';
diff --git a/apps/dashboard/src/components/ui/accordion.tsx b/apps/dashboard/src/components/ui/accordion.tsx
new file mode 100644
index 00000000..71398b45
--- /dev/null
+++ b/apps/dashboard/src/components/ui/accordion.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/utils/cn"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/dashboard/src/components/ui/alert-dialog.tsx
similarity index 99%
rename from apps/web/src/components/ui/alert-dialog.tsx
rename to apps/dashboard/src/components/ui/alert-dialog.tsx
index bc4751da..463dc1e2 100644
--- a/apps/web/src/components/ui/alert-dialog.tsx
+++ b/apps/dashboard/src/components/ui/alert-dialog.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import * as React from 'react';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
diff --git a/apps/dashboard/src/components/ui/alert.tsx b/apps/dashboard/src/components/ui/alert.tsx
new file mode 100644
index 00000000..27093d2a
--- /dev/null
+++ b/apps/dashboard/src/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import { cva } from 'class-variance-authority';
+import type { VariantProps } from 'class-variance-authority';
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
+ {
+ variants: {
+ variant: {
+ default: 'bg-background text-foreground',
+ destructive:
+ 'border-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ }
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = 'Alert';
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertTitle.displayName = 'AlertTitle';
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertDescription.displayName = 'AlertDescription';
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/apps/web/src/components/ui/aspect-ratio.tsx b/apps/dashboard/src/components/ui/aspect-ratio.tsx
similarity index 90%
rename from apps/web/src/components/ui/aspect-ratio.tsx
rename to apps/dashboard/src/components/ui/aspect-ratio.tsx
index 5dfdf1e6..aaabffbc 100644
--- a/apps/web/src/components/ui/aspect-ratio.tsx
+++ b/apps/dashboard/src/components/ui/aspect-ratio.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root;
diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/dashboard/src/components/ui/avatar.tsx
similarity index 98%
rename from apps/web/src/components/ui/avatar.tsx
rename to apps/dashboard/src/components/ui/avatar.tsx
index 24042045..2817f1c6 100644
--- a/apps/web/src/components/ui/avatar.tsx
+++ b/apps/dashboard/src/components/ui/avatar.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import * as React from 'react';
import { cn } from '@/utils/cn';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
diff --git a/apps/web/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx
similarity index 77%
rename from apps/web/src/components/ui/badge.tsx
rename to apps/dashboard/src/components/ui/badge.tsx
index 79b75eef..60b45ea9 100644
--- a/apps/web/src/components/ui/badge.tsx
+++ b/apps/dashboard/src/components/ui/badge.tsx
@@ -1,10 +1,12 @@
+'use client';
+
import * as React from 'react';
import { cn } from '@/utils/cn';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
const badgeVariants = cva(
- 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
+ 'inline-flex items-center rounded-full border px-1.5 h-[20px] text-[10px] font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
@@ -14,6 +16,8 @@ const badgeVariants = cva(
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
+ success:
+ 'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80',
outline: 'text-foreground',
},
},
diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx
new file mode 100644
index 00000000..ca40c6f1
--- /dev/null
+++ b/apps/dashboard/src/components/ui/button.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import { Slot } from '@radix-ui/react-slot';
+import { cva } from 'class-variance-authority';
+import type { VariantProps } from 'class-variance-authority';
+import type { LucideIcon } from 'lucide-react';
+import { Loader2 } from 'lucide-react';
+
+const buttonVariants = cva(
+ 'flex-shrink-0 inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ cta: 'bg-blue-600 text-primary-foreground hover:bg-blue-500',
+ destructive:
+ 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+ outline:
+ 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-10 px-4 py-2',
+ sm: 'h-8 rounded-md px-2',
+ lg: 'h-11 rounded-md px-8',
+ icon: 'h-8 w-8',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'sm',
+ },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+ loading?: boolean;
+ icon?: LucideIcon;
+ responsive?: boolean;
+}
+
+const Button = React.forwardRef(
+ (
+ {
+ className,
+ variant,
+ size,
+ asChild = false,
+ children,
+ loading,
+ disabled,
+ icon,
+ responsive,
+ ...props
+ },
+ ref
+ ) => {
+ const Comp = asChild ? Slot : 'button';
+ const Icon = loading ? Loader2 : icon ?? null;
+ return (
+
+ {Icon && (
+
+ )}
+ {responsive ? (
+ {children}
+ ) : (
+ children
+ )}
+
+ );
+ }
+);
+Button.displayName = 'Button';
+Button.defaultProps = {
+ type: 'button',
+};
+
+export { Button, buttonVariants };
diff --git a/apps/dashboard/src/components/ui/calendar.tsx b/apps/dashboard/src/components/ui/calendar.tsx
new file mode 100644
index 00000000..9fd16ef5
--- /dev/null
+++ b/apps/dashboard/src/components/ui/calendar.tsx
@@ -0,0 +1,64 @@
+import * as React from "react"
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/utils/cn"
+import { buttonVariants } from "@/components/ui/button"
+
+export type CalendarProps = React.ComponentProps
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ ,
+ IconRight: ({ ...props }) => ,
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/apps/dashboard/src/components/ui/carousel.tsx b/apps/dashboard/src/components/ui/carousel.tsx
new file mode 100644
index 00000000..cc9bb130
--- /dev/null
+++ b/apps/dashboard/src/components/ui/carousel.tsx
@@ -0,0 +1,258 @@
+import * as React from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/utils/cn';
+import useEmblaCarousel from 'embla-carousel-react';
+import type { UseEmblaCarouselType } from 'embla-carousel-react';
+import { ArrowLeft, ArrowRight } from 'lucide-react';
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+interface CarouselProps {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: 'horizontal' | 'vertical';
+ setApi?: (api: CarouselApi) => void;
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error('useCarousel must be used within a ');
+ }
+
+ return context;
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & CarouselProps
+>(
+ (
+ {
+ orientation = 'horizontal',
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === 'horizontal' ? 'x' : 'y',
+ },
+ plugins
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) {
+ return;
+ }
+
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext]
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) {
+ return;
+ }
+
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) {
+ return;
+ }
+
+ onSelect(api);
+ api.on('reInit', onSelect);
+ api.on('select', onSelect);
+
+ return () => {
+ api?.off('select', onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+Carousel.displayName = 'Carousel';
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselContent.displayName = 'CarouselContent';
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselItem.displayName = 'CarouselItem';
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+
+ Previous slide
+
+ );
+});
+CarouselPrevious.displayName = 'CarouselPrevious';
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+
+ Next slide
+
+ );
+});
+CarouselNext.displayName = 'CarouselNext';
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+};
diff --git a/apps/dashboard/src/components/ui/checkbox.tsx b/apps/dashboard/src/components/ui/checkbox.tsx
new file mode 100644
index 00000000..0e02bc1a
--- /dev/null
+++ b/apps/dashboard/src/components/ui/checkbox.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
+import { Check } from 'lucide-react';
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+
+const CheckboxInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+
+ {props.children}
+
+));
+CheckboxInput.displayName = 'CheckboxInput';
+
+export { Checkbox, CheckboxInput };
diff --git a/apps/dashboard/src/components/ui/combobox-advanced.tsx b/apps/dashboard/src/components/ui/combobox-advanced.tsx
new file mode 100644
index 00000000..91251087
--- /dev/null
+++ b/apps/dashboard/src/components/ui/combobox-advanced.tsx
@@ -0,0 +1,121 @@
+import * as React from 'react';
+import { Badge } from '@/components/ui/badge';
+import {
+ Command,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { ChevronsUpDownIcon } from 'lucide-react';
+import { useOnClickOutside } from 'usehooks-ts';
+
+import { Button } from './button';
+import { Checkbox } from './checkbox';
+import { Popover, PopoverContent, PopoverTrigger } from './popover';
+
+type IValue = any;
+type IItem = Record<'value' | 'label', IValue>;
+
+interface ComboboxAdvancedProps {
+ value: IValue[];
+ onChange: React.Dispatch>;
+ items: IItem[];
+ placeholder: string;
+ className?: string;
+}
+
+export function ComboboxAdvanced({
+ items,
+ value,
+ onChange,
+ placeholder,
+ className,
+}: ComboboxAdvancedProps) {
+ const [open, setOpen] = React.useState(false);
+ const [inputValue, setInputValue] = React.useState('');
+ const ref = React.useRef(null);
+ useOnClickOutside(ref, () => setOpen(false));
+
+ const selectables = items
+ .filter((item) => !value.find((s) => s === item.value))
+ .filter(
+ (item) =>
+ (typeof item.label === 'string' &&
+ item.label.toLowerCase().includes(inputValue.toLowerCase())) ||
+ (typeof item.value === 'string' &&
+ item.value.toLowerCase().includes(inputValue.toLowerCase()))
+ );
+
+ const renderItem = (item: IItem) => {
+ const checked = !!value.find((s) => s === item.value);
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onSelect={() => {
+ setInputValue('');
+ onChange((prev) => {
+ if (prev.includes(item.value)) {
+ return prev.filter((s) => s !== item.value);
+ }
+ return [...prev, item.value];
+ });
+ }}
+ className={'cursor-pointer flex items-center gap-2'}
+ >
+
+ {item?.label ?? item?.value}
+
+ );
+ };
+
+ const renderUnknownItem = (value: IValue) => {
+ const item = items.find((item) => item.value === value);
+ return item ? renderItem(item) : renderItem({ value, label: value });
+ };
+
+ return (
+
+
+ setOpen((prev) => !prev)}
+ className={className}
+ >
+
+ {value.length === 0 && placeholder}
+ {value.map((value) => {
+ const item = items.find((item) => item.value === value) ?? {
+ value,
+ label: value,
+ };
+ return {item.label} ;
+ })}
+
+
+
+
+
+
+
+
+ {inputValue !== '' &&
+ renderItem({
+ value: inputValue,
+ label: `Pick '${inputValue}'`,
+ })}
+ {value.map(renderUnknownItem)}
+ {selectables.map(renderItem)}
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/ui/combobox.tsx b/apps/dashboard/src/components/ui/combobox.tsx
new file mode 100644
index 00000000..1e88e49a
--- /dev/null
+++ b/apps/dashboard/src/components/ui/combobox.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+import * as React from 'react';
+import type { ButtonProps } from '@/components/ui/button';
+import { Button } from '@/components/ui/button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@/components/ui/command';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { cn } from '@/utils/cn';
+import type { LucideIcon } from 'lucide-react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+
+export interface ComboboxProps {
+ placeholder: string;
+ items: {
+ value: T;
+ label: string;
+ disabled?: boolean;
+ }[];
+ value: T | null | undefined;
+ onChange: (value: T) => void;
+ children?: React.ReactNode;
+ onCreate?: (value: T) => void;
+ className?: string;
+ searchable?: boolean;
+ icon?: LucideIcon;
+ size?: ButtonProps['size'];
+ label?: string;
+ align?: 'start' | 'end' | 'center';
+ portal?: boolean;
+}
+
+export type ExtendedComboboxProps = Omit<
+ ComboboxProps,
+ 'items' | 'placeholder'
+> & {
+ placeholder?: string;
+};
+
+export function Combobox({
+ placeholder,
+ items,
+ value,
+ onChange,
+ children,
+ onCreate,
+ className,
+ searchable,
+ icon: Icon,
+ size,
+ align = 'start',
+ portal,
+}: ComboboxProps) {
+ const [open, setOpen] = React.useState(false);
+ const [search, setSearch] = React.useState('');
+ function find(value: string) {
+ return items.find(
+ (item) => item.value.toLowerCase() === value.toLowerCase()
+ );
+ }
+
+ return (
+
+
+ {children ?? (
+
+
+ {Icon ? : null}
+
+ {value ? find(value)?.label ?? 'No match' : placeholder}
+
+
+
+
+ )}
+
+
+
+ {searchable === true && (
+
+ )}
+ {typeof onCreate === 'function' && search ? (
+
+ {
+ onCreate(search as T);
+ setSearch('');
+ setOpen(false);
+ }}
+ >
+ Create "{search}"
+
+
+ ) : (
+ Nothing selected
+ )}
+
+
+ {items.map((item) => (
+ {
+ const value = find(currentValue)?.value ?? currentValue;
+ onChange(value as T);
+ setOpen(false);
+ }}
+ {...(item.disabled && { disabled: true })}
+ >
+
+ {item.label}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/command.tsx b/apps/dashboard/src/components/ui/command.tsx
similarity index 99%
rename from apps/web/src/components/ui/command.tsx
rename to apps/dashboard/src/components/ui/command.tsx
index c143a853..91b9f2af 100644
--- a/apps/web/src/components/ui/command.tsx
+++ b/apps/dashboard/src/components/ui/command.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import * as React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { cn } from '@/utils/cn';
diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..0465e161
--- /dev/null
+++ b/apps/dashboard/src/components/ui/dialog.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { X } from 'lucide-react';
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ onClose?: () => void;
+ }
+>(({ className, children, onClose, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = 'DialogHeader';
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = 'DialogFooter';
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx
similarity index 99%
rename from apps/web/src/components/ui/dropdown-menu.tsx
rename to apps/dashboard/src/components/ui/dropdown-menu.tsx
index a2cf187a..842e461c 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import * as React from 'react';
import { cn } from '@/utils/cn';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
diff --git a/apps/dashboard/src/components/ui/gradient-background.tsx b/apps/dashboard/src/components/ui/gradient-background.tsx
new file mode 100644
index 00000000..f88c3e40
--- /dev/null
+++ b/apps/dashboard/src/components/ui/gradient-background.tsx
@@ -0,0 +1,24 @@
+import { cn } from '@/utils/cn';
+
+interface GradientBackgroundProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function GradientBackground({
+ children,
+ className,
+ ...props
+}: GradientBackgroundProps) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/ui/input.tsx b/apps/dashboard/src/components/ui/input.tsx
new file mode 100644
index 00000000..772c9e0e
--- /dev/null
+++ b/apps/dashboard/src/components/ui/input.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import type { VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
+
+const inputVariant = cva(
+ 'flex w-full rounded-md border border-input bg-background ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
+ {
+ variants: {
+ size: {
+ default: 'h-8 px-3 py-2 text-sm',
+ large: 'h-12 px-4 py-3 text-lg',
+ },
+ },
+ defaultVariants: {
+ size: 'default',
+ },
+ }
+);
+
+export type InputProps = VariantProps &
+ Omit, 'size'> & {
+ error?: string | undefined;
+ };
+
+const Input = React.forwardRef(
+ ({ className, error, type, size, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = 'Input';
+
+export { Input };
diff --git a/apps/dashboard/src/components/ui/key-value.tsx b/apps/dashboard/src/components/ui/key-value.tsx
new file mode 100644
index 00000000..be2a718f
--- /dev/null
+++ b/apps/dashboard/src/components/ui/key-value.tsx
@@ -0,0 +1,56 @@
+import { isValidElement } from 'react';
+import { cn } from '@/utils/cn';
+import Link from 'next/link';
+
+interface KeyValueProps {
+ name: string;
+ value: unknown;
+ onClick?: () => void;
+ href?: string;
+}
+
+export function KeyValue({ href, onClick, name, value }: KeyValueProps) {
+ const clickable = href || onClick;
+ const Component = href ? (Link as any) : onClick ? 'button' : 'div';
+
+ return (
+
+ {name}
+
+ {value}
+
+
+ );
+}
+
+export function KeyValueSubtle({ href, onClick, name, value }: KeyValueProps) {
+ const clickable = href || onClick;
+ const Component = href ? (Link as any) : onClick ? 'button' : 'div';
+ return (
+
+ {name}
+
+ {value}
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/label.tsx b/apps/dashboard/src/components/ui/label.tsx
similarity index 98%
rename from apps/web/src/components/ui/label.tsx
rename to apps/dashboard/src/components/ui/label.tsx
index eada103c..19859916 100644
--- a/apps/web/src/components/ui/label.tsx
+++ b/apps/dashboard/src/components/ui/label.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import * as React from 'react';
import { cn } from '@/utils/cn';
import * as LabelPrimitive from '@radix-ui/react-label';
diff --git a/apps/dashboard/src/components/ui/popover.tsx b/apps/dashboard/src/components/ui/popover.tsx
new file mode 100644
index 00000000..68538cf5
--- /dev/null
+++ b/apps/dashboard/src/components/ui/popover.tsx
@@ -0,0 +1,39 @@
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import * as PopoverPrimitive from '@radix-ui/react-popover';
+
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ portal?: boolean;
+ }
+>(({ className, align = 'center', sideOffset = 4, portal, ...props }, ref) => {
+ const node = (
+
+ );
+
+ if (portal) {
+ return {node} ;
+ }
+
+ return node;
+});
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent };
diff --git a/apps/dashboard/src/components/ui/progress.tsx b/apps/dashboard/src/components/ui/progress.tsx
new file mode 100644
index 00000000..77bd3f92
--- /dev/null
+++ b/apps/dashboard/src/components/ui/progress.tsx
@@ -0,0 +1,30 @@
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import * as ProgressPrimitive from '@radix-ui/react-progress';
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ color: string;
+ }
+>(({ className, value, color, ...props }, ref) => (
+
+
+
+));
+Progress.displayName = ProgressPrimitive.Root.displayName;
+
+export { Progress };
diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/dashboard/src/components/ui/radio-group.tsx
similarity index 98%
rename from apps/web/src/components/ui/radio-group.tsx
rename to apps/dashboard/src/components/ui/radio-group.tsx
index a2975858..fb251a99 100644
--- a/apps/web/src/components/ui/radio-group.tsx
+++ b/apps/dashboard/src/components/ui/radio-group.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import * as React from 'react';
import { cn } from '@/utils/cn';
diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/dashboard/src/components/ui/scroll-area.tsx
similarity index 57%
rename from apps/web/src/components/ui/scroll-area.tsx
rename to apps/dashboard/src/components/ui/scroll-area.tsx
index f25a5f8c..b2c7836c 100644
--- a/apps/web/src/components/ui/scroll-area.tsx
+++ b/apps/dashboard/src/components/ui/scroll-area.tsx
@@ -1,7 +1,8 @@
-import * as React from "react"
-import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+'use client';
-import { cn } from "@/utils/cn"
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
const ScrollArea = React.forwardRef<
React.ElementRef,
@@ -9,7 +10,7 @@ const ScrollArea = React.forwardRef<
>(({ className, children, ...props }, ref) => (
@@ -18,34 +19,34 @@ const ScrollArea = React.forwardRef<
-))
-ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, orientation = "vertical", ...props }, ref) => (
+>(({ className, orientation = 'vertical', ...props }, ref) => (
-))
-ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
-export { ScrollArea, ScrollBar }
+export { ScrollArea, ScrollBar };
diff --git a/apps/dashboard/src/components/ui/sheet.tsx b/apps/dashboard/src/components/ui/sheet.tsx
new file mode 100644
index 00000000..87fe3a5e
--- /dev/null
+++ b/apps/dashboard/src/components/ui/sheet.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/utils/cn';
+import * as SheetPrimitive from '@radix-ui/react-dialog';
+import { cva } from 'class-variance-authority';
+import type { VariantProps } from 'class-variance-authority';
+import { XIcon } from 'lucide-react';
+
+const Sheet = SheetPrimitive.Root;
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = SheetPrimitive.Portal;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ 'flex flex-col max-sm:w-[calc(100%-theme(spacing.8))] overflow-y-auto fixed z-50 gap-4 bg-background p-6 rounded-lg shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
+ {
+ variants: {
+ side: {
+ top: 'inset-x-4 top-4 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
+ bottom:
+ 'inset-x-4 bottom-4 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
+ left: 'top-4 bottom-4 left-4 w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
+ right:
+ 'top-4 bottom-4 right-4 w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
+ },
+ },
+ defaultVariants: {
+ side: 'right',
+ },
+ }
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps & {
+ onClose?: () => void;
+ }
+>(({ side = 'right', className, children, onClose, ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+ Close
+
+
+
+));
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetHeader.displayName = 'SheetHeader';
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetFooter.displayName = 'SheetFooter';
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/apps/dashboard/src/components/ui/sonner.tsx b/apps/dashboard/src/components/ui/sonner.tsx
new file mode 100644
index 00000000..537e52a5
--- /dev/null
+++ b/apps/dashboard/src/components/ui/sonner.tsx
@@ -0,0 +1,29 @@
+import { useTheme } from 'next-themes';
+import { Toaster as Sonner } from 'sonner';
+
+type ToasterProps = React.ComponentProps;
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = 'system' } = useTheme();
+
+ return (
+
+ );
+};
+
+export { Toaster };
diff --git a/apps/web/src/components/ui/table.tsx b/apps/dashboard/src/components/ui/table.tsx
similarity index 88%
rename from apps/web/src/components/ui/table.tsx
rename to apps/dashboard/src/components/ui/table.tsx
index 4e67313f..e99dfa41 100644
--- a/apps/web/src/components/ui/table.tsx
+++ b/apps/dashboard/src/components/ui/table.tsx
@@ -1,12 +1,17 @@
+'use client';
+
import * as React from 'react';
import { cn } from '@/utils/cn';
const Table = React.forwardRef<
HTMLTableElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-
+ React.HTMLAttributes
& {
+ wrapper?: boolean;
+ overflow?: boolean;
+ }
+>(({ className, wrapper, overflow = true, ...props }, ref) => (
+
+
;
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = 'system' } = useTheme();
+
+ return (
+
+ );
+};
+
+export { Toaster };
diff --git a/apps/dashboard/src/components/ui/toggle-group.tsx b/apps/dashboard/src/components/ui/toggle-group.tsx
new file mode 100644
index 00000000..87cb456b
--- /dev/null
+++ b/apps/dashboard/src/components/ui/toggle-group.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
+import { VariantProps } from "class-variance-authority"
+
+import { cn } from "@/utils/cn"
+import { toggleVariants } from "@/components/ui/toggle"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps
+>({
+ size: "default",
+ variant: "default",
+})
+
+const ToggleGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, size, children, ...props }, ref) => (
+
+
+ {children}
+
+
+))
+
+ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
+
+const ToggleGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, children, variant, size, ...props }, ref) => {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+})
+
+ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/apps/dashboard/src/components/ui/toggle.tsx b/apps/dashboard/src/components/ui/toggle.tsx
new file mode 100644
index 00000000..0569a7bc
--- /dev/null
+++ b/apps/dashboard/src/components/ui/toggle.tsx
@@ -0,0 +1,43 @@
+import * as React from "react"
+import * as TogglePrimitive from "@radix-ui/react-toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/utils/cn"
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-10 px-3",
+ sm: "h-9 px-2.5",
+ lg: "h-11 px-5",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+const Toggle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, size, ...props }, ref) => (
+
+))
+
+Toggle.displayName = TogglePrimitive.Root.displayName
+
+export { Toggle, toggleVariants }
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/dashboard/src/components/ui/tooltip.tsx
similarity index 65%
rename from apps/web/src/components/ui/tooltip.tsx
rename to apps/dashboard/src/components/ui/tooltip.tsx
index f7ceae52..1849163f 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/dashboard/src/components/ui/tooltip.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import * as React from 'react';
import { cn } from '@/utils/cn';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
@@ -16,7 +18,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
- 'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
+ 'z-50 overflow-hidden rounded-md border bg-black px-3 py-1.5 text-sm text-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
diff --git a/apps/web/src/env.mjs b/apps/dashboard/src/env.mjs
similarity index 71%
rename from apps/web/src/env.mjs
rename to apps/dashboard/src/env.mjs
index 32ac1455..df287104 100644
--- a/apps/web/src/env.mjs
+++ b/apps/dashboard/src/env.mjs
@@ -17,17 +17,6 @@ export const env = createEnv({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
- NEXTAUTH_SECRET:
- process.env.NODE_ENV === 'production'
- ? z.string()
- : z.string().optional(),
- NEXTAUTH_URL: z.preprocess(
- // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
- // Since NextAuth.js automatically uses the VERCEL_URL if present.
- (str) => process.env.VERCEL_URL ?? str,
- // VERCEL_URL doesn't include `https` so it cant be validated as a URL
- process.env.VERCEL ? z.string() : z.string().url()
- ),
},
/**
@@ -46,8 +35,6 @@ export const env = createEnv({
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
- NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
- NEXTAUTH_URL: process.env.NEXTAUTH_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
diff --git a/apps/dashboard/src/hooks/useAppParams.ts b/apps/dashboard/src/hooks/useAppParams.ts
new file mode 100644
index 00000000..805ac9d6
--- /dev/null
+++ b/apps/dashboard/src/hooks/useAppParams.ts
@@ -0,0 +1,16 @@
+import { useParams } from 'next/navigation';
+
+// eslint-disable-next-line
+type AppParams = {
+ organizationId: string;
+ projectId: string;
+};
+
+export function useAppParams() {
+ const params = useParams();
+ return {
+ ...(params ?? {}),
+ organizationId: params?.organizationId,
+ projectId: params?.projectId,
+ } as T & AppParams;
+}
diff --git a/apps/web/src/hooks/useBreakpoint.ts b/apps/dashboard/src/hooks/useBreakpoint.ts
similarity index 100%
rename from apps/web/src/hooks/useBreakpoint.ts
rename to apps/dashboard/src/hooks/useBreakpoint.ts
diff --git a/apps/dashboard/src/hooks/useCursor.ts b/apps/dashboard/src/hooks/useCursor.ts
new file mode 100644
index 00000000..458c4710
--- /dev/null
+++ b/apps/dashboard/src/hooks/useCursor.ts
@@ -0,0 +1,14 @@
+import { parseAsInteger, useQueryState } from 'nuqs';
+
+export function useCursor() {
+ const [cursor, setCursor] = useQueryState(
+ 'cursor',
+ parseAsInteger
+ .withOptions({ shallow: false, history: 'push' })
+ .withDefault(0)
+ );
+ return {
+ cursor,
+ setCursor,
+ };
+}
diff --git a/apps/web/src/hooks/useDebounceFn.ts b/apps/dashboard/src/hooks/useDebounceFn.ts
similarity index 100%
rename from apps/web/src/hooks/useDebounceFn.ts
rename to apps/dashboard/src/hooks/useDebounceFn.ts
diff --git a/apps/dashboard/src/hooks/useEventNames.ts b/apps/dashboard/src/hooks/useEventNames.ts
new file mode 100644
index 00000000..41dcb051
--- /dev/null
+++ b/apps/dashboard/src/hooks/useEventNames.ts
@@ -0,0 +1,9 @@
+import { api } from '@/app/_trpc/client';
+
+export function useEventNames(projectId: string) {
+ const query = api.chart.events.useQuery({
+ projectId: projectId,
+ });
+
+ return query.data ?? [];
+}
diff --git a/apps/dashboard/src/hooks/useEventProperties.ts b/apps/dashboard/src/hooks/useEventProperties.ts
new file mode 100644
index 00000000..2bd5f1ff
--- /dev/null
+++ b/apps/dashboard/src/hooks/useEventProperties.ts
@@ -0,0 +1,10 @@
+import { api } from '@/app/_trpc/client';
+
+export function useEventProperties(projectId: string, event?: string) {
+ const query = api.chart.properties.useQuery({
+ projectId: projectId,
+ event,
+ });
+
+ return query.data ?? [];
+}
diff --git a/apps/dashboard/src/hooks/useEventQueryFilters.ts b/apps/dashboard/src/hooks/useEventQueryFilters.ts
new file mode 100644
index 00000000..34597131
--- /dev/null
+++ b/apps/dashboard/src/hooks/useEventQueryFilters.ts
@@ -0,0 +1,102 @@
+import { useCallback } from 'react';
+
+// prettier-ignore
+import type { Options as NuqsOptions } from 'nuqs';
+
+import {
+ createParser,
+ parseAsArrayOf,
+ parseAsString,
+ useQueryState,
+} from 'nuqs';
+
+const nuqsOptions = { history: 'push' } as const;
+
+type Operator = 'is' | 'isNot' | 'contains' | 'doesNotContain';
+
+export const eventQueryFiltersParser = createParser({
+ parse: (query: string) => {
+ if (query === '') return [];
+ const filters = query.split(';');
+
+ return (
+ filters.map((filter) => {
+ const [key, operator, value] = filter.split(',');
+ return {
+ id: key!,
+ name: key!,
+ operator: (operator ?? 'is') as Operator,
+ value: [decodeURIComponent(value!)],
+ };
+ }) ?? []
+ );
+ },
+ serialize: (value) => {
+ return value
+ .map(
+ (filter) =>
+ `${filter.id},${filter.operator},${encodeURIComponent(filter.value[0] ?? '')}`
+ )
+ .join(';');
+ },
+});
+
+export function useEventQueryFilters(options: NuqsOptions = {}) {
+ const [filters, setFilters] = useQueryState(
+ 'f',
+ eventQueryFiltersParser.withDefault([]).withOptions({
+ ...nuqsOptions,
+ ...options,
+ })
+ );
+
+ const setFilter = useCallback(
+ (
+ name: string,
+ value: string | number | boolean | undefined | null,
+ operator: Operator = 'is'
+ ) => {
+ setFilters((prev) => {
+ const exists = prev.find((filter) => filter.name === name);
+ if (exists) {
+ // If same value is already set, remove the filter
+ if (exists.value[0] === value) {
+ return prev.filter((filter) => filter.name !== name);
+ }
+
+ return prev.map((filter) => {
+ if (filter.name === name) {
+ return {
+ ...filter,
+ operator,
+ value: [String(value)],
+ };
+ }
+ return filter;
+ });
+ }
+
+ return [
+ ...prev,
+ {
+ id: name,
+ name,
+ operator,
+ value: [String(value)],
+ },
+ ];
+ });
+ },
+ [setFilters]
+ );
+
+ return [filters, setFilter, setFilters] as const;
+}
+
+export const eventQueryNamesFilter = parseAsArrayOf(parseAsString).withDefault(
+ []
+);
+
+export function useEventQueryNamesFilter(options: NuqsOptions = {}) {
+ return useQueryState('events', eventQueryNamesFilter.withOptions(options));
+}
diff --git a/apps/dashboard/src/hooks/useEventValues.ts b/apps/dashboard/src/hooks/useEventValues.ts
new file mode 100644
index 00000000..22f8cd19
--- /dev/null
+++ b/apps/dashboard/src/hooks/useEventValues.ts
@@ -0,0 +1,15 @@
+import { api } from '@/app/_trpc/client';
+
+export function useEventValues(
+ projectId: string,
+ event: string,
+ property: string
+) {
+ const query = api.chart.values.useQuery({
+ projectId: projectId,
+ event,
+ property,
+ });
+
+ return query.data?.values ?? [];
+}
diff --git a/apps/web/src/hooks/useFormatDateInterval.ts b/apps/dashboard/src/hooks/useFormatDateInterval.ts
similarity index 93%
rename from apps/web/src/hooks/useFormatDateInterval.ts
rename to apps/dashboard/src/hooks/useFormatDateInterval.ts
index 8ab03c68..d00f9fec 100644
--- a/apps/web/src/hooks/useFormatDateInterval.ts
+++ b/apps/dashboard/src/hooks/useFormatDateInterval.ts
@@ -1,4 +1,4 @@
-import type { IInterval } from '@/types';
+import type { IInterval } from '@openpanel/validation';
export function formatDateInterval(interval: IInterval, date: Date): string {
if (interval === 'hour' || interval === 'minute') {
diff --git a/apps/web/src/hooks/useMappings.ts b/apps/dashboard/src/hooks/useMappings.ts
similarity index 80%
rename from apps/web/src/hooks/useMappings.ts
rename to apps/dashboard/src/hooks/useMappings.ts
index 18ecfad9..35130c0c 100644
--- a/apps/web/src/hooks/useMappings.ts
+++ b/apps/dashboard/src/hooks/useMappings.ts
@@ -1,7 +1,7 @@
import mappings from '@/mappings.json';
export function useMappings() {
- return (val: string) => {
+ return (val: string | null) => {
return mappings.find((item) => item.id === val)?.name ?? val;
};
}
diff --git a/apps/dashboard/src/hooks/useNumerFormatter.ts b/apps/dashboard/src/hooks/useNumerFormatter.ts
new file mode 100644
index 00000000..8e400746
--- /dev/null
+++ b/apps/dashboard/src/hooks/useNumerFormatter.ts
@@ -0,0 +1,60 @@
+import { round } from '@/utils/math';
+import { isNil } from 'ramda';
+
+export function fancyMinutes(time: number) {
+ const minutes = Math.floor(time / 60);
+ const seconds = round(time - minutes * 60, 0);
+ if (minutes === 0) return `${seconds}s`;
+ return `${minutes}m ${seconds}s`;
+}
+
+export const formatNumber =
+ (locale: string) => (value: number | null | undefined) => {
+ if (isNil(value)) {
+ return 'N/A';
+ }
+ return new Intl.NumberFormat(locale, {
+ maximumSignificantDigits: 20,
+ }).format(value);
+ };
+
+export const shortNumber =
+ (locale: string) => (value: number | null | undefined) => {
+ if (isNil(value)) {
+ return 'N/A';
+ }
+ return new Intl.NumberFormat(locale, {
+ notation: 'compact',
+ }).format(value);
+ };
+
+export function useNumber() {
+ const locale = 'en-gb';
+ const format = formatNumber(locale);
+ const short = shortNumber(locale);
+ return {
+ format,
+ short,
+ shortWithUnit: (value: number | null | undefined, unit?: string | null) => {
+ if (isNil(value)) {
+ return 'N/A';
+ }
+ if (unit === 'min') {
+ return fancyMinutes(value);
+ }
+ return `${short(value)}${unit ? ` ${unit}` : ''}`;
+ },
+ formatWithUnit: (
+ value: number | null | undefined,
+ unit?: string | null
+ ) => {
+ if (isNil(value)) {
+ return 'N/A';
+ }
+ if (unit === 'min') {
+ return fancyMinutes(value);
+ }
+ return `${format(value)}${unit ? ` ${unit}` : ''}`;
+ },
+ };
+}
diff --git a/apps/dashboard/src/hooks/useProfileProperties.ts b/apps/dashboard/src/hooks/useProfileProperties.ts
new file mode 100644
index 00000000..3cd44a50
--- /dev/null
+++ b/apps/dashboard/src/hooks/useProfileProperties.ts
@@ -0,0 +1,10 @@
+import { api } from '@/app/_trpc/client';
+
+export function useProfileProperties(projectId: string, event?: string) {
+ const query = api.profile.properties.useQuery({
+ projectId: projectId,
+ event,
+ });
+
+ return query.data ?? [];
+}
diff --git a/apps/dashboard/src/hooks/useProfileValues.ts b/apps/dashboard/src/hooks/useProfileValues.ts
new file mode 100644
index 00000000..09cdf924
--- /dev/null
+++ b/apps/dashboard/src/hooks/useProfileValues.ts
@@ -0,0 +1,10 @@
+import { api } from '@/app/_trpc/client';
+
+export function useProfileValues(projectId: string, property: string) {
+ const query = api.profile.values.useQuery({
+ projectId: projectId,
+ property,
+ });
+
+ return query.data?.values ?? [];
+}
diff --git a/apps/web/src/hooks/useQueryParams.ts b/apps/dashboard/src/hooks/useQueryParams.ts
similarity index 100%
rename from apps/web/src/hooks/useQueryParams.ts
rename to apps/dashboard/src/hooks/useQueryParams.ts
diff --git a/apps/dashboard/src/hooks/useRechartDataModel.ts b/apps/dashboard/src/hooks/useRechartDataModel.ts
new file mode 100644
index 00000000..dd443955
--- /dev/null
+++ b/apps/dashboard/src/hooks/useRechartDataModel.ts
@@ -0,0 +1,41 @@
+'use client';
+
+import { useMemo } from 'react';
+import type { IChartData, IChartSerieDataItem } from '@/app/_trpc/client';
+import { getChartColor } from '@/utils/theme';
+
+export type IRechartPayloadItem = IChartSerieDataItem & { color: string };
+
+export function useRechartDataModel(series: IChartData['series']) {
+ return useMemo(() => {
+ return (
+ series[0]?.data.map(({ date }) => {
+ return {
+ date,
+ timestamp: new Date(date).getTime(),
+ ...series.reduce((acc, serie, idx) => {
+ return {
+ ...acc,
+ ...serie.data.reduce(
+ (acc2, item) => {
+ if (item.date === date) {
+ if (item.previous) {
+ acc2[`${idx}:prev:count`] = item.previous.value;
+ }
+ acc2[`${idx}:count`] = item.count;
+ acc2[`${idx}:payload`] = {
+ ...item,
+ color: getChartColor(idx),
+ } satisfies IRechartPayloadItem;
+ }
+ return acc2;
+ },
+ {} as Record
+ ),
+ };
+ }, {}),
+ };
+ }) ?? []
+ );
+ }, [series]);
+}
diff --git a/apps/web/src/hooks/useRefetchActive.ts b/apps/dashboard/src/hooks/useRefetchActive.ts
similarity index 100%
rename from apps/web/src/hooks/useRefetchActive.ts
rename to apps/dashboard/src/hooks/useRefetchActive.ts
diff --git a/apps/web/src/hooks/useRouterBeforeLeave.ts b/apps/dashboard/src/hooks/useRouterBeforeLeave.ts
similarity index 100%
rename from apps/web/src/hooks/useRouterBeforeLeave.ts
rename to apps/dashboard/src/hooks/useRouterBeforeLeave.ts
diff --git a/apps/dashboard/src/hooks/useSetCookie.ts b/apps/dashboard/src/hooks/useSetCookie.ts
new file mode 100644
index 00000000..58097ecd
--- /dev/null
+++ b/apps/dashboard/src/hooks/useSetCookie.ts
@@ -0,0 +1,16 @@
+import { usePathname, useRouter } from 'next/navigation';
+
+export function useSetCookie() {
+ const router = useRouter();
+ const pathname = usePathname();
+ return (key: string, value: string, path?: string) => {
+ fetch(`/api/cookie?${key}=${value}`).then(() => {
+ if (path && path !== pathname) {
+ router.refresh();
+ router.replace(path);
+ } else {
+ router.refresh();
+ }
+ });
+ };
+}
diff --git a/apps/dashboard/src/hooks/useThrottle.ts b/apps/dashboard/src/hooks/useThrottle.ts
new file mode 100644
index 00000000..0ad7dd34
--- /dev/null
+++ b/apps/dashboard/src/hooks/useThrottle.ts
@@ -0,0 +1,15 @@
+import { useCallback, useEffect, useRef } from 'react';
+import throttle from 'lodash.throttle';
+
+export function useThrottle(cb: () => void, delay: number) {
+ const options = { leading: true, trailing: false }; // add custom lodash options
+ const cbRef = useRef(cb);
+ // use mutable ref to make useCallback/throttle not depend on `cb` dep
+ useEffect(() => {
+ cbRef.current = cb;
+ });
+ return useCallback(
+ throttle(() => cbRef.current(), delay, options),
+ [delay]
+ );
+}
diff --git a/apps/dashboard/src/hooks/useVisibleSeries.ts b/apps/dashboard/src/hooks/useVisibleSeries.ts
new file mode 100644
index 00000000..0fb0b209
--- /dev/null
+++ b/apps/dashboard/src/hooks/useVisibleSeries.ts
@@ -0,0 +1,30 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import type { IChartData } from '@/app/_trpc/client';
+
+export type IVisibleSeries = ReturnType['series'];
+export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
+ const max = limit ?? 5;
+ const [visibleSeries, setVisibleSeries] = useState(
+ data?.series?.slice(0, max).map((serie) => serie.name) ?? []
+ );
+
+ useEffect(() => {
+ setVisibleSeries(
+ data?.series?.slice(0, max).map((serie) => serie.name) ?? []
+ );
+ }, [data, max]);
+
+ return useMemo(() => {
+ return {
+ series: data.series
+ .map((serie, index) => ({
+ ...serie,
+ index,
+ }))
+ .filter((serie) => visibleSeries.includes(serie.name)),
+ setVisibleSeries,
+ } as const;
+ }, [visibleSeries, data.series]);
+}
diff --git a/apps/web/src/lottie/airplane.json b/apps/dashboard/src/lottie/airplane.json
similarity index 100%
rename from apps/web/src/lottie/airplane.json
rename to apps/dashboard/src/lottie/airplane.json
diff --git a/apps/web/src/lottie/ballon.json b/apps/dashboard/src/lottie/ballon.json
similarity index 100%
rename from apps/web/src/lottie/ballon.json
rename to apps/dashboard/src/lottie/ballon.json
diff --git a/apps/web/src/lottie/no-data.json b/apps/dashboard/src/lottie/no-data.json
similarity index 100%
rename from apps/web/src/lottie/no-data.json
rename to apps/dashboard/src/lottie/no-data.json
diff --git a/apps/web/src/mappings.json b/apps/dashboard/src/mappings.json
similarity index 100%
rename from apps/web/src/mappings.json
rename to apps/dashboard/src/mappings.json
diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts
new file mode 100644
index 00000000..a9af39af
--- /dev/null
+++ b/apps/dashboard/src/middleware.ts
@@ -0,0 +1,18 @@
+import { authMiddleware } from '@clerk/nextjs';
+
+// This example protects all routes including api/trpc routes
+// 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: ['/share/overview/:id', '/api/trpc(.*)'],
+});
+
+export const config = {
+ matcher: [
+ '/((?!.+\\.[\\w]+$|_next).*)',
+ '/',
+ '/(api)(.*)',
+ '/(api|trpc)(.*)',
+ '/api/trpc(.*)',
+ ],
+};
diff --git a/apps/dashboard/src/modals/AddClient.tsx b/apps/dashboard/src/modals/AddClient.tsx
new file mode 100644
index 00000000..d700145e
--- /dev/null
+++ b/apps/dashboard/src/modals/AddClient.tsx
@@ -0,0 +1,260 @@
+'use client';
+
+import { useEffect } from 'react';
+import { api, handleError } from '@/app/_trpc/client';
+import { Button } from '@/components/ui/button';
+import { CheckboxInput } from '@/components/ui/checkbox';
+import { Combobox } from '@/components/ui/combobox';
+import {
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useAppParams } from '@/hooks/useAppParams';
+import { clipboard } from '@/utils/clipboard';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Copy, SaveIcon } from 'lucide-react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import type { SubmitHandler } from 'react-hook-form';
+import { Controller, useForm, useWatch } from 'react-hook-form';
+import { toast } from 'sonner';
+import { z } from 'zod';
+
+import { popModal } from '.';
+import { ModalContent, ModalHeader } from './Modal/Container';
+
+const validation = z.object({
+ name: z.string().min(1),
+ domain: z.string().optional(),
+ withSecret: z.boolean().optional(),
+ projectId: z.string(),
+});
+
+type IForm = z.infer;
+
+export default function AddClient() {
+ const { organizationId, projectId } = useAppParams();
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(validation),
+ defaultValues: {
+ withSecret: false,
+ name: '',
+ domain: '',
+ projectId,
+ },
+ });
+ const mutation = api.client.create2.useMutation({
+ onError: handleError,
+ onSuccess() {
+ toast.success('Client created');
+ router.refresh();
+ },
+ });
+ const query = api.project.list.useQuery({
+ organizationId,
+ });
+ const onSubmit: SubmitHandler = (values) => {
+ mutation.mutate({
+ name: values.name,
+ domain: values.withSecret ? undefined : values.domain,
+ projectId: values.projectId,
+ organizationId,
+ });
+ };
+
+ const watch = useWatch({
+ control: form.control,
+ name: 'withSecret',
+ });
+
+ return (
+
+ {mutation.isSuccess ? (
+ <>
+
+ {mutation.data.clientSecret
+ ? 'Use your client id and secret with our SDK to send events to us. '
+ : 'Use your client id with our SDK to send events to us. '}
+ See our{' '}
+
+ documentation
+
+ >
+ }
+ />
+
+
clipboard(mutation.data.clientId)}
+ >
+ Client ID
+
+ {mutation.data.clientId}
+
+
+
+ {mutation.data.clientSecret ? (
+
clipboard(mutation.data.clientId)}
+ >
+ Secret
+
+ {mutation.data.clientSecret}
+
+
+
+ ) : (
+
+
Cors settings
+
+ {mutation.data.cors}
+
+
+ You can update cors settings{' '}
+
+ here
+
+
+
+ )}
+
+
+ popModal()}
+ >
+ Close
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+{
+ /*
+
+ Select your framework and we'll generate a client for you.
+
+
+
+
+
+
+
+
+
+
*/
+}
diff --git a/apps/web/src/modals/AddDashboard.tsx b/apps/dashboard/src/modals/AddDashboard.tsx
similarity index 74%
rename from apps/web/src/modals/AddDashboard.tsx
rename to apps/dashboard/src/modals/AddDashboard.tsx
index f2cd7b2e..fde7d54a 100644
--- a/apps/web/src/modals/AddDashboard.tsx
+++ b/apps/dashboard/src/modals/AddDashboard.tsx
@@ -1,32 +1,28 @@
+'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 { toast } from '@/components/ui/use-toast';
-import { useRefetchActive } from '@/hooks/useRefetchActive';
-import { api, handleError } from '@/utils/api';
+import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';
+import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
-interface AddDashboardProps {
- organizationSlug: string;
- projectSlug: string;
-}
-
const validator = z.object({
name: z.string().min(1, 'Required'),
});
type IForm = z.infer;
-export default function AddDashboard({
- // organizationSlug,
- projectSlug,
-}: AddDashboardProps) {
- const refetch = useRefetchActive();
+export default function AddDashboard() {
+ const { projectId, organizationId: organizationSlug } = useAppParams();
+ const router = useRouter();
const { register, handleSubmit, formState } = useForm({
resolver: zodResolver(validator),
@@ -38,9 +34,8 @@ export default function AddDashboard({
const mutation = api.dashboard.create.useMutation({
onError: handleError,
onSuccess() {
- refetch();
- toast({
- title: 'Success',
+ router.refresh();
+ toast('Success', {
description: 'Dashboard created.',
});
popModal();
@@ -49,13 +44,14 @@ export default function AddDashboard({
return (
-
+