fix(dashboard): share overview (all widgets didnt work)
This commit is contained in:
@@ -10,11 +10,12 @@ import { AnimatedNumber } from '../animated-number';
|
||||
|
||||
export interface LiveCounterProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
const FIFTEEN_SECONDS = 1000 * 30;
|
||||
|
||||
export function LiveCounter({ projectId }: LiveCounterProps) {
|
||||
export function LiveCounter({ projectId, shareId }: LiveCounterProps) {
|
||||
const trpc = useTRPC();
|
||||
const client = useQueryClient();
|
||||
const counter = useDebounceState(0, 1000);
|
||||
@@ -22,6 +23,7 @@ export function LiveCounter({ projectId }: LiveCounterProps) {
|
||||
const query = useQuery(
|
||||
trpc.overview.liveVisitors.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,16 +18,18 @@ import {
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
interface OverviewLiveHistogramProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
export function OverviewLiveHistogram({
|
||||
projectId,
|
||||
shareId,
|
||||
}: OverviewLiveHistogramProps) {
|
||||
const trpc = useTRPC();
|
||||
|
||||
// Use the new liveData endpoint instead of chart props
|
||||
const { data: liveData, isLoading } = useQuery(
|
||||
trpc.overview.liveData.queryOptions({ projectId }),
|
||||
trpc.overview.liveData.queryOptions({ projectId, shareId }),
|
||||
);
|
||||
|
||||
const totalSessions = liveData?.totalSessions ?? 0;
|
||||
|
||||
90
apps/start/src/components/overview/overview-map.tsx
Normal file
90
apps/start/src/components/overview/overview-map.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import WorldMap from 'react-svg-worldmap';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
interface OverviewMapProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
export function OverviewMap({ projectId, shareId }: OverviewMapProps) {
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const query = useQuery(
|
||||
trpc.overview.map.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
);
|
||||
|
||||
const mapData = useMemo(() => {
|
||||
if (!query.data) return [];
|
||||
|
||||
// Aggregate by country (sum counts for same country)
|
||||
const countryMap = new Map<string, number>();
|
||||
query.data.forEach((item) => {
|
||||
const country = item.country.toLowerCase();
|
||||
const current = countryMap.get(country) ?? 0;
|
||||
countryMap.set(country, current + item.count);
|
||||
});
|
||||
|
||||
return Array.from(countryMap.entries()).map(([country, value]) => ({
|
||||
country,
|
||||
value,
|
||||
}));
|
||||
}, [query.data]);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading map...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="text-muted-foreground">Error loading map</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!query.data || mapData.length === 0) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="text-muted-foreground">No data available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full" style={{ minHeight: 300 }}>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<WorldMap
|
||||
onClickFunction={(event) => {
|
||||
if (event.countryCode) {
|
||||
setFilter('country', event.countryCode);
|
||||
}
|
||||
}}
|
||||
size={width}
|
||||
data={mapData}
|
||||
color={'var(--chart-0)'}
|
||||
borderColor={'var(--foreground)'}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { OverviewMetricCard } from './overview-metric-card';
|
||||
|
||||
interface OverviewMetricsProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
const TITLES = [
|
||||
@@ -83,7 +84,10 @@ const TITLES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
export default function OverviewMetrics({
|
||||
projectId,
|
||||
shareId,
|
||||
}: OverviewMetricsProps) {
|
||||
const { range, interval, metric, setMetric, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
const [filters] = useEventQueryFilters();
|
||||
@@ -93,6 +97,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const overviewQuery = useQuery(
|
||||
trpc.overview.stats.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
interval,
|
||||
filters,
|
||||
@@ -138,7 +143,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-1',
|
||||
)}
|
||||
>
|
||||
<OverviewLiveHistogram projectId={projectId} />
|
||||
<OverviewLiveHistogram projectId={projectId} shareId={shareId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,7 +349,7 @@ function Chart({
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} />
|
||||
<YAxis {...yAxisProps} width={25} />
|
||||
<XAxis {...xAxisProps} />
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -471,7 +476,12 @@ function Chart({
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
domain={[
|
||||
0,
|
||||
activeMetric.key === 'bounce_rate'
|
||||
? 100
|
||||
: (dataMax: number) => Math.max(dataMax, 1),
|
||||
]}
|
||||
width={25}
|
||||
/>
|
||||
<YAxis
|
||||
@@ -480,14 +490,18 @@ function Chart({
|
||||
orientation="right"
|
||||
domain={[
|
||||
0,
|
||||
data.reduce(
|
||||
(max, item) => Math.max(max, item.total_revenue ?? 0),
|
||||
0,
|
||||
) * 2,
|
||||
Math.max(
|
||||
data.reduce(
|
||||
(max, item) => Math.max(max, item.total_revenue ?? 0),
|
||||
0,
|
||||
) * 1.2,
|
||||
1,
|
||||
),
|
||||
]}
|
||||
width={30}
|
||||
allowDataOverflow={false}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
<XAxis {...xAxisProps} padding={{ left: 10, right: 10 }} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -523,19 +537,11 @@ function Chart({
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'transparent',
|
||||
fill: 'var(--def-100)',
|
||||
fillOpacity: 1,
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
@@ -581,7 +587,8 @@ function Chart({
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'transparent',
|
||||
fill: 'var(--def-100)',
|
||||
fillOpacity: 1,
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
interface OverviewTopDevicesProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
export default function OverviewTopDevices({
|
||||
projectId,
|
||||
shareId,
|
||||
}: OverviewTopDevicesProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
@@ -325,6 +327,7 @@ export default function OverviewTopDevices({
|
||||
const query = useQuery(
|
||||
trpc.overview.topGeneric.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
@@ -337,6 +340,7 @@ export default function OverviewTopDevices({
|
||||
trpc.overview.topGenericSeries.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { IReportInput } from '@openpanel/validation';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
@@ -17,17 +15,18 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
|
||||
export interface OverviewTopEventsProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
export default function OverviewTopEvents({
|
||||
projectId,
|
||||
shareId,
|
||||
}: OverviewTopEventsProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const trpc = useTRPC();
|
||||
const { data: conversions } = useQuery(
|
||||
trpc.event.conversionNames.queryOptions({ projectId }),
|
||||
trpc.overview.topConversions.queryOptions({ projectId, shareId }),
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
@@ -36,15 +35,7 @@ export default function OverviewTopEvents({
|
||||
title: 'Events',
|
||||
btn: 'Events',
|
||||
meta: {
|
||||
filters: [
|
||||
{
|
||||
id: 'ex_session',
|
||||
name: 'name',
|
||||
operator: 'isNot',
|
||||
value: ['session_start', 'session_end', 'screen_view'],
|
||||
},
|
||||
],
|
||||
eventName: '*',
|
||||
type: 'events' as const,
|
||||
},
|
||||
},
|
||||
conversions: {
|
||||
@@ -52,69 +43,84 @@ export default function OverviewTopEvents({
|
||||
btn: 'Conversions',
|
||||
hide: !conversions || conversions.length === 0,
|
||||
meta: {
|
||||
filters: [
|
||||
{
|
||||
id: 'conversion',
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: conversions?.map((c) => c.name) ?? [],
|
||||
},
|
||||
],
|
||||
eventName: '*',
|
||||
type: 'conversions' as const,
|
||||
},
|
||||
},
|
||||
link_out: {
|
||||
title: 'Link out',
|
||||
btn: 'Link out',
|
||||
meta: {
|
||||
filters: [],
|
||||
eventName: 'link_out',
|
||||
breakdownProperty: 'properties.href',
|
||||
type: 'linkOut' as const,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report: IReportInput = useMemo(
|
||||
() => ({
|
||||
limit: 1000,
|
||||
// Use different endpoints based on widget type
|
||||
const eventsQuery = useQuery(
|
||||
trpc.overview.topEvents.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
series: [
|
||||
{
|
||||
type: 'event' as const,
|
||||
segment: 'event' as const,
|
||||
filters: [...filters, ...(widget.meta?.filters ?? [])],
|
||||
id: 'A',
|
||||
name: widget.meta?.eventName ?? '*',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: widget.meta?.breakdownProperty ?? 'name',
|
||||
},
|
||||
],
|
||||
chartType: 'bar' as const,
|
||||
interval,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum' as const,
|
||||
filters,
|
||||
excludeEvents:
|
||||
widget.meta?.type === 'events'
|
||||
? ['session_start', 'session_end', 'screen_view']
|
||||
: undefined,
|
||||
}),
|
||||
[projectId, startDate, endDate, filters, widget, interval, range, previous],
|
||||
);
|
||||
|
||||
const query = useQuery(trpc.chart.aggregate.queryOptions(report));
|
||||
const linkOutQuery = useQuery(
|
||||
trpc.overview.topLinkOut.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
filters,
|
||||
}),
|
||||
);
|
||||
|
||||
const tableData: EventTableItem[] = useMemo(() => {
|
||||
if (!query.data?.series) return [];
|
||||
// For link out, use href as name
|
||||
if (widget.meta?.type === 'linkOut') {
|
||||
if (!linkOutQuery.data) return [];
|
||||
return linkOutQuery.data.map((item) => ({
|
||||
id: item.href,
|
||||
name: item.href,
|
||||
count: item.count,
|
||||
}));
|
||||
}
|
||||
|
||||
return query.data.series.map((serie) => ({
|
||||
id: serie.id,
|
||||
name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '',
|
||||
count: serie.metrics.sum,
|
||||
// For events and conversions
|
||||
if (!eventsQuery.data) return [];
|
||||
|
||||
// For conversions, filter events by conversion names (client-side filtering)
|
||||
if (widget.meta?.type === 'conversions' && conversions) {
|
||||
const conversionNames = new Set(conversions.map((c) => c.name));
|
||||
return eventsQuery.data
|
||||
.filter((item) => conversionNames.has(item.name))
|
||||
.map((item) => ({
|
||||
id: item.name,
|
||||
name: item.name,
|
||||
count: item.count,
|
||||
}));
|
||||
}
|
||||
|
||||
// For regular events
|
||||
return eventsQuery.data.map((item) => ({
|
||||
id: item.name,
|
||||
name: item.name,
|
||||
count: item.count,
|
||||
}));
|
||||
}, [query.data]);
|
||||
}, [eventsQuery.data, linkOutQuery.data, widget.meta?.type, conversions]);
|
||||
|
||||
// Determine which query's loading state to use
|
||||
const isLoading =
|
||||
widget.meta?.type === 'linkOut'
|
||||
? linkOutQuery.isLoading
|
||||
: eventsQuery.isLoading;
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
@@ -150,14 +156,14 @@ export default function OverviewTopEvents({
|
||||
className="border-b-0 pb-2"
|
||||
/>
|
||||
<WidgetBody className="p-0">
|
||||
{query.isLoading ? (
|
||||
{isLoading ? (
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<OverviewWidgetTableEvents
|
||||
data={filteredData}
|
||||
onItemClick={(name) => {
|
||||
if (widget.meta?.breakdownProperty) {
|
||||
setFilter(widget.meta.breakdownProperty, name);
|
||||
if (widget.meta?.type === 'linkOut') {
|
||||
setFilter('properties.href', name);
|
||||
} else {
|
||||
setFilter('name', name);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import { countries } from '@/translations/countries';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { ReportChartShortcut } from '../report-chart/shortcut';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
OverviewLineChart,
|
||||
OverviewLineChartLoading,
|
||||
} from './overview-line-chart';
|
||||
import { OverviewMap } from './overview-map';
|
||||
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
|
||||
import {
|
||||
WidgetFooter,
|
||||
@@ -34,8 +33,12 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
|
||||
interface OverviewTopGeoProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
export default function OverviewTopGeo({
|
||||
projectId,
|
||||
shareId,
|
||||
}: OverviewTopGeoProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||
@@ -63,6 +66,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
const query = useQuery(
|
||||
trpc.overview.topGeneric.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
@@ -75,6 +79,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
trpc.overview.topGenericSeries.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
@@ -211,32 +216,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
<div className="title">Map</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ReportChartShortcut
|
||||
{...{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'country',
|
||||
},
|
||||
],
|
||||
chartType: 'map',
|
||||
interval: interval,
|
||||
range: range,
|
||||
previous: previous,
|
||||
}}
|
||||
/>
|
||||
<OverviewMap projectId={projectId} shareId={shareId} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -20,9 +20,13 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
|
||||
interface OverviewTopPagesProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
export default function OverviewTopPages({
|
||||
projectId,
|
||||
shareId,
|
||||
}: OverviewTopPagesProps) {
|
||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
||||
@@ -56,6 +60,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
const query = useQuery(
|
||||
trpc.overview.topPages.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
|
||||
@@ -24,9 +24,11 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
|
||||
interface OverviewTopSourcesProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
export default function OverviewTopSources({
|
||||
projectId,
|
||||
shareId,
|
||||
}: OverviewTopSourcesProps) {
|
||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
@@ -71,6 +73,7 @@ export default function OverviewTopSources({
|
||||
const query = useQuery(
|
||||
trpc.overview.topGeneric.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
@@ -83,6 +86,7 @@ export default function OverviewTopSources({
|
||||
trpc.overview.topGenericSeries.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
shareId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
interface OverviewUserJourneyProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
|
||||
@@ -159,6 +160,7 @@ function SankeyPortalTooltip({
|
||||
|
||||
export default function OverviewUserJourney({
|
||||
projectId,
|
||||
shareId,
|
||||
}: OverviewUserJourneyProps) {
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters] = useEventQueryFilters();
|
||||
@@ -177,6 +179,7 @@ export default function OverviewUserJourney({
|
||||
endDate,
|
||||
range,
|
||||
steps: steps ?? 5,
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function OverviewFilters({
|
||||
const { projectId } = useAppParams();
|
||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
|
||||
const eventNames = useEventNames({ projectId });
|
||||
const eventNames = useEventNames({ projectId, anyEvents: false });
|
||||
const selectedFilters = filters.filter((filter) => filter.value[0] !== null);
|
||||
return (
|
||||
<SheetContent className="[&>button.absolute]:hidden">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { LazyComponent } from '@/components/lazy-component';
|
||||
import { LoginNavbar } from '@/components/login-navbar';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { LiveCounter } from '@/components/overview/live-counter';
|
||||
@@ -11,6 +12,7 @@ import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import OverviewUserJourney from '@/components/overview/overview-user-journey';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||
@@ -110,19 +112,22 @@ function RouteComponent() {
|
||||
<OverviewRange />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<LiveCounter projectId={projectId} />
|
||||
<LiveCounter projectId={projectId} shareId={shareId} />
|
||||
</div>
|
||||
</div>
|
||||
<OverviewFiltersButtons />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-6 gap-4 p-4">
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
<OverviewTopEvents projectId={projectId} />
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
<OverviewMetrics projectId={projectId} shareId={shareId} />
|
||||
<OverviewTopSources projectId={projectId} shareId={shareId} />
|
||||
<OverviewTopPages projectId={projectId} shareId={shareId} />
|
||||
<OverviewTopDevices projectId={projectId} shareId={shareId} />
|
||||
<OverviewTopEvents projectId={projectId} shareId={shareId} />
|
||||
<OverviewTopGeo projectId={projectId} shareId={shareId} />
|
||||
<LazyComponent className="col-span-6">
|
||||
<OverviewUserJourney projectId={projectId} shareId={shareId} />
|
||||
</LazyComponent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,11 @@ import { chartColors } from '@openpanel/constants';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { z } from 'zod';
|
||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||
import { clix } from '../clickhouse/query-builder';
|
||||
import { getEventFiltersWhereClause } from './chart.service';
|
||||
import { getEventFiltersWhereClause, getSelectPropertyKey } from './chart.service';
|
||||
|
||||
// Constants
|
||||
const ROLLUP_DATE_PREFIX = '1970-01-01';
|
||||
@@ -127,12 +128,53 @@ export type IGetUserJourneyInput = z.infer<typeof zGetUserJourneyInput> & {
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
export const zGetTopEventsInput = z.object({
|
||||
projectId: z.string(),
|
||||
filters: z.array(z.any()),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
excludeEvents: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type IGetTopEventsInput = z.infer<typeof zGetTopEventsInput> & {
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
export const zGetTopLinkOutInput = z.object({
|
||||
projectId: z.string(),
|
||||
filters: z.array(z.any()),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
});
|
||||
|
||||
export type IGetTopLinkOutInput = z.infer<typeof zGetTopLinkOutInput> & {
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
export const zGetMapDataInput = z.object({
|
||||
projectId: z.string(),
|
||||
filters: z.array(z.any()),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
});
|
||||
|
||||
export type IGetMapDataInput = z.infer<typeof zGetMapDataInput> & {
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
export class OverviewService {
|
||||
constructor(private client: typeof ch) {}
|
||||
|
||||
// Helper methods
|
||||
private isRollupRow(date: string): boolean {
|
||||
return date.startsWith(ROLLUP_DATE_PREFIX);
|
||||
// The rollup row has date 1970-01-01 00:00:00 (epoch) from ClickHouse.
|
||||
// After transform with `new Date().toISOString()`, this becomes an ISO string.
|
||||
// Due to timezone handling in JavaScript's Date constructor (which interprets
|
||||
// the input as local time), the UTC date might become:
|
||||
// - 1969-12-31T... for positive UTC offsets (e.g., UTC+8)
|
||||
// - 1970-01-01T... for UTC or negative offsets
|
||||
// We check for both year prefixes to handle all server timezones.
|
||||
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31');
|
||||
}
|
||||
|
||||
private getFillConfig(interval: string, startDate: string, endDate: string) {
|
||||
@@ -1223,6 +1265,150 @@ export class OverviewService {
|
||||
links: filteredLinks,
|
||||
};
|
||||
}
|
||||
|
||||
async getTopEvents({
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
excludeEvents = ['session_start', 'session_end', 'screen_view'],
|
||||
}: {
|
||||
projectId: string;
|
||||
filters: IChartEventFilter[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timezone: string;
|
||||
excludeEvents?: string[];
|
||||
}): Promise<Array<{ name: string; count: number }>> {
|
||||
const where = this.getRawWhereClause('events', filters);
|
||||
const excludeWhere =
|
||||
excludeEvents.length > 0
|
||||
? `name NOT IN (${excludeEvents.map((e) => sqlstring.escape(e)).join(',')})`
|
||||
: '';
|
||||
|
||||
const query = clix(this.client, timezone)
|
||||
.select<{ name: string; count: number }>([
|
||||
'name',
|
||||
'count() as count',
|
||||
])
|
||||
.from(TABLE_NAMES.events, false)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(where)
|
||||
.rawWhere(excludeWhere)
|
||||
.groupBy(['name'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(MAX_RECORDS_LIMIT);
|
||||
|
||||
return query.execute();
|
||||
}
|
||||
|
||||
async getTopLinkOut({
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
}: {
|
||||
projectId: string;
|
||||
filters: IChartEventFilter[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timezone: string;
|
||||
}): Promise<Array<{ href: string; count: number }>> {
|
||||
const where = this.getRawWhereClause('events', filters);
|
||||
const hrefKey = getSelectPropertyKey('properties.href');
|
||||
|
||||
const query = clix(this.client, timezone)
|
||||
.select<{ href: string; count: number }>([
|
||||
`${hrefKey} as href`,
|
||||
'count() as count',
|
||||
])
|
||||
.from(TABLE_NAMES.events, false)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', 'link_out')
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(where)
|
||||
.rawWhere(`${hrefKey} IS NOT NULL AND ${hrefKey} != ''`)
|
||||
.groupBy(['href'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(MAX_RECORDS_LIMIT);
|
||||
|
||||
return query.execute();
|
||||
}
|
||||
|
||||
async getMapData({
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
}: {
|
||||
projectId: string;
|
||||
filters: IChartEventFilter[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timezone: string;
|
||||
}): Promise<
|
||||
Array<{
|
||||
country: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
count: number;
|
||||
}>
|
||||
> {
|
||||
const where = this.getRawWhereClause('events', filters);
|
||||
|
||||
// Note: ClickHouse doesn't have built-in lat/lng for countries/regions
|
||||
// This would typically require a lookup table or external service
|
||||
// For now, we'll return the data structure but lat/lng would need to be
|
||||
// resolved on the frontend or via a separate lookup
|
||||
const query = clix(this.client, timezone)
|
||||
.select<{
|
||||
country: string;
|
||||
region: string | null;
|
||||
city: string | null;
|
||||
count: number;
|
||||
}>([
|
||||
'nullIf(country, \'\') as country',
|
||||
'nullIf(region, \'\') as region',
|
||||
'nullIf(city, \'\') as city',
|
||||
'uniq(session_id) as count',
|
||||
])
|
||||
.from(TABLE_NAMES.events, false)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(where)
|
||||
.rawWhere('country IS NOT NULL AND country != \'\'')
|
||||
.groupBy(['country', 'region', 'city'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(MAX_RECORDS_LIMIT);
|
||||
|
||||
const results = await query.execute();
|
||||
|
||||
// Return with placeholder lat/lng - these should be resolved via geocoding
|
||||
// or a lookup table on the frontend/backend
|
||||
return results.map((row) => ({
|
||||
country: row.country,
|
||||
region: row.region ?? undefined,
|
||||
city: row.city ?? undefined,
|
||||
lat: 0, // Placeholder - needs geocoding
|
||||
lng: 0, // Placeholder - needs geocoding
|
||||
count: row.count,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const overviewService = new OverviewService(ch);
|
||||
|
||||
@@ -213,3 +213,67 @@ export async function validateShareAccess(
|
||||
|
||||
throw new Error('Share not found');
|
||||
}
|
||||
|
||||
// Validation for overview share access
|
||||
export async function validateOverviewShareAccess(
|
||||
shareId: string | undefined,
|
||||
projectId: string,
|
||||
ctx: {
|
||||
cookies: Record<string, string | undefined>;
|
||||
session?: { userId?: string | null };
|
||||
},
|
||||
): Promise<{ isValid: boolean }> {
|
||||
// If shareId is provided, validate share access
|
||||
if (shareId) {
|
||||
const share = await db.shareOverview.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
if (!share || !share.public) {
|
||||
throw new Error('Share not found or not public');
|
||||
}
|
||||
|
||||
// Verify the share is for the correct project
|
||||
if (share.projectId !== projectId) {
|
||||
throw new Error('Project ID mismatch');
|
||||
}
|
||||
|
||||
// If no password is set, share is public and accessible
|
||||
if (!share.password) {
|
||||
return {
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
// If password is set, require cookie OR member access
|
||||
const hasCookie = !!ctx.cookies[`shared-overview-${shareId}`];
|
||||
const hasMemberAccess =
|
||||
ctx.session?.userId &&
|
||||
(await getProjectAccess({
|
||||
userId: ctx.session.userId,
|
||||
projectId,
|
||||
}));
|
||||
|
||||
return {
|
||||
isValid: hasCookie || !!hasMemberAccess,
|
||||
};
|
||||
}
|
||||
|
||||
// If no shareId, require authenticated user with project access
|
||||
if (!ctx.session?.userId) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const access = await getProjectAccess({
|
||||
userId: ctx.session.userId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
throw new Error('You do not have access to this project');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,18 +5,25 @@ import {
|
||||
eventBuffer,
|
||||
getChartPrevStartEndDate,
|
||||
getChartStartEndDate,
|
||||
getConversionEventNames,
|
||||
getOrganizationSubscriptionChartEndDate,
|
||||
getSettingsForProject,
|
||||
overviewService,
|
||||
validateOverviewShareAccess,
|
||||
zGetMapDataInput,
|
||||
zGetMetricsInput,
|
||||
zGetTopEventsInput,
|
||||
zGetTopGenericInput,
|
||||
zGetTopGenericSeriesInput,
|
||||
zGetTopLinkOutInput,
|
||||
zGetTopPagesInput,
|
||||
zGetUserJourneyInput,
|
||||
} from '@openpanel/db';
|
||||
import { type IChartRange, zRange } from '@openpanel/validation';
|
||||
import { format } from 'date-fns';
|
||||
import { z } from 'zod';
|
||||
import { getProjectAccess } from '../access';
|
||||
import { TRPCAccessError } from '../errors';
|
||||
import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
const cacher = cacheMiddleware((input, opts) => {
|
||||
@@ -87,15 +94,58 @@ function getCurrentAndPrevious<
|
||||
|
||||
export const overviewRouter = createTRPCRouter({
|
||||
liveVisitors: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
.input(z.object({ projectId: z.string(), shareId: z.string().optional() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
return eventBuffer.getActiveVisitorCount(input.projectId);
|
||||
}),
|
||||
|
||||
liveData: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.input(z.object({ projectId: z.string(), shareId: z.string().optional() }))
|
||||
.use(cacher)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
|
||||
// Get total unique sessions in the last 30 minutes
|
||||
@@ -212,10 +262,32 @@ export const overviewRouter = createTRPCRouter({
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.use(cacher)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { current, previous } = await getCurrentAndPrevious(
|
||||
{ ...input, timezone },
|
||||
@@ -258,10 +330,32 @@ export const overviewRouter = createTRPCRouter({
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
mode: z.enum(['page', 'entry', 'exit', 'bot']),
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.use(cacher)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { current } = await getCurrentAndPrevious(
|
||||
{ ...input },
|
||||
@@ -292,10 +386,34 @@ export const overviewRouter = createTRPCRouter({
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.use(cacher)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
console.log('input', input);
|
||||
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { current } = await getCurrentAndPrevious(
|
||||
{ ...input, timezone },
|
||||
@@ -308,14 +426,38 @@ export const overviewRouter = createTRPCRouter({
|
||||
|
||||
topGenericSeries: publicProcedure
|
||||
.input(
|
||||
zGetTopGenericSeriesInput.omit({ startDate: true, endDate: true }).extend({
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
}),
|
||||
zGetTopGenericSeriesInput
|
||||
.omit({ startDate: true, endDate: true })
|
||||
.extend({
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.use(cacher)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { current } = await getCurrentAndPrevious(
|
||||
{ ...input, timezone },
|
||||
@@ -333,10 +475,32 @@ export const overviewRouter = createTRPCRouter({
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
steps: z.number().min(2).max(10).default(5).optional(),
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.use(cacher)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { current } = await getCurrentAndPrevious(
|
||||
{ ...input, timezone },
|
||||
@@ -350,6 +514,168 @@ export const overviewRouter = createTRPCRouter({
|
||||
});
|
||||
});
|
||||
|
||||
return current;
|
||||
}),
|
||||
|
||||
topEvents: publicProcedure
|
||||
.input(
|
||||
zGetTopEventsInput.omit({ startDate: true, endDate: true }).extend({
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.use(cacher)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { current } = await getCurrentAndPrevious(
|
||||
{ ...input, timezone },
|
||||
false,
|
||||
timezone,
|
||||
)(overviewService.getTopEvents.bind(overviewService));
|
||||
|
||||
return current;
|
||||
}),
|
||||
|
||||
topConversions: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
return getConversionEventNames(input.projectId);
|
||||
}),
|
||||
|
||||
topLinkOut: publicProcedure
|
||||
.input(
|
||||
zGetTopLinkOutInput.omit({ startDate: true, endDate: true }).extend({
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.use(cacher)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { current } = await getCurrentAndPrevious(
|
||||
{ ...input, timezone },
|
||||
false,
|
||||
timezone,
|
||||
)(overviewService.getTopLinkOut.bind(overviewService));
|
||||
|
||||
return current;
|
||||
}),
|
||||
|
||||
map: publicProcedure
|
||||
.input(
|
||||
zGetMapDataInput.omit({ startDate: true, endDate: true }).extend({
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange,
|
||||
shareId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.use(cacher)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Validate share access if shareId provided
|
||||
if (input.shareId) {
|
||||
await validateOverviewShareAccess(input.shareId, input.projectId, {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { current } = await getCurrentAndPrevious(
|
||||
{ ...input, timezone },
|
||||
false,
|
||||
timezone,
|
||||
)(overviewService.getMapData.bind(overviewService));
|
||||
|
||||
return current;
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user