feature(dashboard): refactor overview
fix(lint)
This commit is contained in:
committed by
Carl-Gerhard Lindesvärd
parent
b035c0d586
commit
a1eb4a296f
@@ -9,11 +9,12 @@ type Props = {
|
||||
};
|
||||
|
||||
const Conversions = ({ projectId }: Props) => {
|
||||
const query = api.event.conversions.useQuery(
|
||||
const query = api.event.conversions.useInfiniteQuery(
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,13 +5,13 @@ import EventListener from '@/components/events/event-listener';
|
||||
import { EventsTable } from '@/components/events/table';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { pushModal } from '@/modals';
|
||||
import { api } from '@/trpc/client';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, Loader2Icon } from 'lucide-react';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -20,21 +20,22 @@ type Props = {
|
||||
|
||||
const Events = ({ projectId, profileId }: Props) => {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [eventNames] = useEventQueryNamesFilter();
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(0),
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
'startDate',
|
||||
parseAsIsoDateTime,
|
||||
);
|
||||
const query = api.event.events.useQuery(
|
||||
|
||||
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||
const query = api.event.events.useInfiniteQuery(
|
||||
{
|
||||
cursor,
|
||||
projectId,
|
||||
take: 50,
|
||||
events: eventNames,
|
||||
filters,
|
||||
profileId,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
@@ -43,6 +44,25 @@ const Events = ({ projectId, profileId }: Props) => {
|
||||
<div>
|
||||
<TableButtons>
|
||||
<EventListener onRefresh={() => query.refetch()} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={CalendarIcon}
|
||||
onClick={() => {
|
||||
pushModal('DateRangerPicker', {
|
||||
onChange: ({ startDate, endDate }) => {
|
||||
setStartDate(startDate);
|
||||
setEndDate(endDate);
|
||||
},
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{startDate && endDate
|
||||
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
|
||||
: 'Date range'}
|
||||
</Button>
|
||||
<OverviewFiltersDrawer
|
||||
mode="events"
|
||||
projectId={projectId}
|
||||
@@ -58,7 +78,7 @@ const Events = ({ projectId, profileId }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</TableButtons>
|
||||
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
|
||||
<EventsTable query={query} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import ServerLiveCounter from '@/components/overview/live-counter';
|
||||
import OverviewMetrics from '@/components/overview/overview-metrics';
|
||||
import OverviewShareServer from '@/components/overview/overview-share';
|
||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
@@ -10,6 +9,7 @@ import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import OverviewMetricsV2 from '@/components/overview/overview-metrics-v2';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
|
||||
interface PageProps {
|
||||
@@ -36,7 +36,8 @@ export default function Page({ params: { projectId } }: PageProps) {
|
||||
<OverviewFiltersButtons />
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
{/* <OverviewMetrics projectId={projectId} /> */}
|
||||
<OverviewMetricsV2 projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
|
||||
@@ -4,15 +4,9 @@ import { TableButtons } from '@/components/data-table';
|
||||
import { EventsTable } from '@/components/events/table';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { api } from '@/trpc/client';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
import { GetEventListOptions } from '@openpanel/db';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -21,21 +15,14 @@ type Props = {
|
||||
|
||||
const Events = ({ projectId, profileId }: Props) => {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [eventNames] = useEventQueryNamesFilter();
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(0),
|
||||
);
|
||||
const query = api.event.events.useQuery(
|
||||
const query = api.event.events.useInfiniteQuery(
|
||||
{
|
||||
cursor,
|
||||
projectId,
|
||||
take: 50,
|
||||
events: eventNames,
|
||||
filters,
|
||||
profileId,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
@@ -58,7 +45,7 @@ const Events = ({ projectId, profileId }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</TableButtons>
|
||||
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
|
||||
<EventsTable query={query} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,24 +49,13 @@ const Map = ({ markers }: Props) => {
|
||||
const boundingBox = getBoundingBox(hull);
|
||||
const [zoom] = useAnimatedState(
|
||||
markers.length === 1
|
||||
? 20
|
||||
? 1
|
||||
: determineZoom(boundingBox, size ? size?.height / size?.width : 1),
|
||||
);
|
||||
|
||||
const [long] = useAnimatedState(center.long);
|
||||
const [lat] = useAnimatedState(center.lat);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (ref.current) {
|
||||
setSize({
|
||||
width: ref.current.clientWidth,
|
||||
height: ref.current.clientHeight,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
return bind(window, {
|
||||
type: 'resize',
|
||||
@@ -95,20 +84,12 @@ const Map = ({ markers }: Props) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 right-0 top-0',
|
||||
!isFullscreen && 'lg:left-72',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={cn('absolute bottom-0 left-0 right-0 top-0')} ref={ref}>
|
||||
{size === null ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<ComposableMap
|
||||
width={size?.width}
|
||||
height={size?.height}
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
rotate: [0, 0, 0],
|
||||
|
||||
59
apps/dashboard/src/components/charts/chart-tooltip.tsx
Normal file
59
apps/dashboard/src/components/charts/chart-tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createContext, useContext as useBaseContext } from 'react';
|
||||
|
||||
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
|
||||
|
||||
export function createChartTooltip<
|
||||
PropsFromTooltip extends Record<string, unknown>,
|
||||
PropsFromContext extends Record<string, unknown>,
|
||||
>(
|
||||
Tooltip: React.ComponentType<
|
||||
{
|
||||
context: PropsFromContext;
|
||||
data: PropsFromTooltip;
|
||||
} & TooltipProps<number, string>
|
||||
>,
|
||||
) {
|
||||
const context = createContext<PropsFromContext | null>(null);
|
||||
const useContext = () => {
|
||||
const value = useBaseContext(context);
|
||||
if (!value) {
|
||||
throw new Error('ChartTooltip context not found');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const InnerTooltip = (tooltip: TooltipProps<number, string>) => {
|
||||
const context = useContext();
|
||||
const data = tooltip.payload?.[0]?.payload;
|
||||
|
||||
if (!data || !tooltip.active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
|
||||
<Tooltip data={data} context={context} {...tooltip} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
TooltipProvider: ({
|
||||
children,
|
||||
...value
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
} & PropsFromContext) => {
|
||||
return (
|
||||
<context.Provider value={value as unknown as PropsFromContext}>
|
||||
{children}
|
||||
</context.Provider>
|
||||
);
|
||||
},
|
||||
Tooltip: (props: TooltipProps<number, string>) => {
|
||||
return (
|
||||
<RechartsTooltip {...props} content={<InnerTooltip {...props} />} />
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -56,6 +56,8 @@ export function EventListItem(props: EventListItemProps) {
|
||||
if (!isMinimal) {
|
||||
pushModal('EventDetails', {
|
||||
id: props.id,
|
||||
projectId,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function useColumns() {
|
||||
const number = useNumber();
|
||||
const columns: ColumnDef<IServiceEvent>[] = [
|
||||
{
|
||||
size: 300,
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell({ row }) {
|
||||
@@ -50,29 +51,29 @@ export function useColumns() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipComplete content="Click to edit" side="left">
|
||||
<button
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
onClick={() => {
|
||||
pushModal('EditEvent', {
|
||||
id: row.original.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<EventIcon
|
||||
size="sm"
|
||||
name={row.original.name}
|
||||
meta={row.original.meta}
|
||||
/>
|
||||
</button>
|
||||
</TooltipComplete>
|
||||
<button
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
onClick={() => {
|
||||
pushModal('EditEvent', {
|
||||
id: row.original.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<EventIcon
|
||||
size="sm"
|
||||
name={row.original.name}
|
||||
meta={row.original.meta}
|
||||
/>
|
||||
</button>
|
||||
<span className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
pushModal('EventDetails', {
|
||||
id: row.original.id,
|
||||
createdAt: row.original.createdAt,
|
||||
projectId: row.original.projectId,
|
||||
});
|
||||
}}
|
||||
className="font-medium"
|
||||
@@ -86,41 +87,13 @@ export function useColumns() {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: 'Country',
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created at',
|
||||
size: 170,
|
||||
cell({ row }) {
|
||||
const { country, city } = row.original;
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div className="inline-flex min-w-full flex-none items-center gap-2">
|
||||
<SerieIcon name={country} />
|
||||
<span>{city}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'os',
|
||||
header: 'OS',
|
||||
cell({ row }) {
|
||||
const { os } = row.original;
|
||||
return (
|
||||
<div className="flex min-w-full items-center gap-2">
|
||||
<SerieIcon name={os} />
|
||||
<span>{os}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'browser',
|
||||
header: 'Browser',
|
||||
cell({ row }) {
|
||||
const { browser } = row.original;
|
||||
return (
|
||||
<div className="inline-flex min-w-full flex-none items-center gap-2">
|
||||
<SerieIcon name={browser} />
|
||||
<span>{browser}</span>
|
||||
</div>
|
||||
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -134,8 +107,8 @@ export function useColumns() {
|
||||
}
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${profile?.id}`}
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${profile.id}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</ProjectLink>
|
||||
@@ -143,12 +116,44 @@ export function useColumns() {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created at',
|
||||
accessorKey: 'country',
|
||||
header: 'Country',
|
||||
size: 150,
|
||||
cell({ row }) {
|
||||
const date = row.original.createdAt;
|
||||
const { country, city } = row.original;
|
||||
return (
|
||||
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<SerieIcon name={country} />
|
||||
<span className="truncate">{city}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'os',
|
||||
header: 'OS',
|
||||
size: 130,
|
||||
cell({ row }) {
|
||||
const { os } = row.original;
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<SerieIcon name={os} />
|
||||
<span className="truncate">{os}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'browser',
|
||||
header: 'Browser',
|
||||
size: 110,
|
||||
cell({ row }) {
|
||||
const { browser } = row.original;
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<SerieIcon name={browser} />
|
||||
<span className="truncate">{browser}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
152
apps/dashboard/src/components/events/table/events-data-table.tsx
Normal file
152
apps/dashboard/src/components/events/table/events-data-table.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { GridCell } from '@/components/grid-table';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import throttle from 'lodash.throttle';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DataTableProps<TData> {
|
||||
columns: ColumnDef<TData, any>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function EventsDataTable<TData>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: rows.length,
|
||||
estimateSize: () => 48,
|
||||
scrollMargin,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateScrollMargin = throttle(() => {
|
||||
if (parentRef.current) {
|
||||
setScrollMargin(
|
||||
parentRef.current.getBoundingClientRect().top + window.scrollY,
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Initial calculation
|
||||
updateScrollMargin();
|
||||
|
||||
// Listen for scroll and resize events
|
||||
window.addEventListener('scroll', updateScrollMargin);
|
||||
window.addEventListener('resize', updateScrollMargin);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateScrollMargin);
|
||||
window.removeEventListener('resize', updateScrollMargin);
|
||||
};
|
||||
}, []); // Empty dependency array since we're setting up listeners
|
||||
|
||||
const visibleRows = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="relative w-full overflow-auto rounded-md">
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
width: 'max-content',
|
||||
minWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<div className="thead row h-12 sticky top-0" key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<GridCell
|
||||
key={header.id}
|
||||
isHeader
|
||||
style={{
|
||||
minWidth: header.column.getSize(),
|
||||
flexShrink: 1,
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</GridCell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div ref={parentRef} className="w-full">
|
||||
<div
|
||||
className="tbody [&>*:last-child]:border-0"
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{visibleRows.map((virtualRow, index) => {
|
||||
const row = rows[virtualRow.index]!;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn('absolute top-0 left-0 w-full h-12 row')}
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
virtualRow.start - virtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<GridCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
minWidth: cell.column.getSize(),
|
||||
flexShrink: 1,
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</GridCell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +1,82 @@
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableSkeleton } from '@/components/ui/table';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
import { column } from 'mathjs';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import type {
|
||||
UseInfiniteQueryResult,
|
||||
UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { GanttChartIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
import { useColumns } from './columns';
|
||||
import { EventsDataTable } from './events-data-table';
|
||||
|
||||
type Props =
|
||||
| {
|
||||
query: UseQueryResult<IServiceEvent[]>;
|
||||
query: UseInfiniteQueryResult<RouterOutputs['event']['events']>;
|
||||
}
|
||||
| {
|
||||
query: UseQueryResult<IServiceEvent[]>;
|
||||
cursor: number;
|
||||
setCursor: Dispatch<SetStateAction<number>>;
|
||||
query: UseQueryResult<RouterOutputs['event']['events']>;
|
||||
};
|
||||
|
||||
export const EventsTable = ({ query, ...props }: Props) => {
|
||||
const columns = useColumns();
|
||||
const { data, isFetching, isLoading } = query;
|
||||
const { isLoading } = query;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { inViewport, enterCount } = useInViewport(ref, undefined, {
|
||||
disconnectOnLeave: true,
|
||||
});
|
||||
const isInfiniteQuery = 'fetchNextPage' in query;
|
||||
const data =
|
||||
(isInfiniteQuery
|
||||
? query.data?.pages?.flatMap((p) => p.items)
|
||||
: query.data?.items) ?? [];
|
||||
|
||||
const hasNextPage = isInfiniteQuery
|
||||
? query.data?.pages[query.data.pages.length - 1]?.meta.next
|
||||
: query.data?.meta.next;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasNextPage &&
|
||||
isInfiniteQuery &&
|
||||
data.length > 0 &&
|
||||
inViewport &&
|
||||
enterCount > 0 &&
|
||||
query.isFetchingNextPage === false
|
||||
) {
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [inViewport, enterCount, hasNextPage]);
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton cols={columns.length} />;
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
|
||||
<p>Could not find any events</p>
|
||||
{'cursor' in props && props.cursor !== 0 && (
|
||||
<Button
|
||||
className="mt-8"
|
||||
variant="outline"
|
||||
onClick={() => props.setCursor((p) => p - 1)}
|
||||
>
|
||||
Go to previous page
|
||||
</Button>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data ?? []} columns={columns} />
|
||||
{'cursor' in props && (
|
||||
<Pagination
|
||||
className="mt-2"
|
||||
setCursor={props.setCursor}
|
||||
cursor={props.cursor}
|
||||
count={Number.POSITIVE_INFINITY}
|
||||
take={50}
|
||||
loading={isFetching}
|
||||
/>
|
||||
<EventsDataTable data={data} columns={columns} />
|
||||
{isInfiniteQuery && (
|
||||
<div className="w-full h-10 center-center pt-10" ref={ref}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
||||
isInfiniteQuery && query.isFetchingNextPage && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ const CopyInput = ({ label, value, className }: Props) => {
|
||||
onClick={() => clipboard(value)}
|
||||
>
|
||||
{!!label && <Label>{label}</Label>}
|
||||
<div className="font-mono flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 ">
|
||||
<div className="font-mono flex items-center justify-between rounded border-input bg-card p-2 px-3 ">
|
||||
{value}
|
||||
<CopyIcon size={16} />
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@ const TagInput = ({
|
||||
<input
|
||||
ref={inputRef}
|
||||
placeholder={`${placeholder} ↵`}
|
||||
className="min-w-20 flex-1 py-1 focus-visible:outline-none"
|
||||
className="min-w-20 flex-1 py-1 focus-visible:outline-none bg-card"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -66,7 +66,7 @@ export const GridCell: React.FC<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="truncate w-full">{children}</div>
|
||||
</Component>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { getPropertyLabel } from '@/translations/properties';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { operators } from '@openpanel/constants';
|
||||
import { X } from 'lucide-react';
|
||||
import type { Options as NuqsOptions } from 'nuqs';
|
||||
|
||||
@@ -20,7 +21,8 @@ export function OverviewFiltersButtons({
|
||||
nuqsOptions,
|
||||
}: OverviewFiltersButtonsProps) {
|
||||
const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions);
|
||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||
const [filters, setFilter, setFilters, removeFilter] =
|
||||
useEventQueryFilters(nuqsOptions);
|
||||
if (filters.length === 0 && events.length === 0) return null;
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
@@ -36,20 +38,23 @@ export function OverviewFiltersButtons({
|
||||
</Button>
|
||||
))}
|
||||
{filters.map((filter) => {
|
||||
if (!filter.value[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={filter.name}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => setFilter(filter.name, [], 'is')}
|
||||
onClick={() => removeFilter(filter.name)}
|
||||
>
|
||||
<span className="mr-1">{getPropertyLabel(filter.name)} is</span>
|
||||
<strong className="font-semibold">{filter.value.join(', ')}</strong>
|
||||
<span>{getPropertyLabel(filter.name)}</span>
|
||||
<span className="opacity-40 ml-2 lowercase">
|
||||
{operators[filter.operator]}
|
||||
</span>
|
||||
{filter.value.length > 0 && (
|
||||
<strong className="font-semibold ml-2">
|
||||
{filter.value.join(', ')}
|
||||
</strong>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -31,6 +31,10 @@ export interface OverviewFiltersDrawerContentProps {
|
||||
mode: 'profiles' | 'events';
|
||||
}
|
||||
|
||||
const excludePropertyFilter = (name: string) => {
|
||||
return ['*', 'duration', 'created_at', 'has_profile'].includes(name);
|
||||
};
|
||||
|
||||
export function OverviewFiltersDrawerContent({
|
||||
projectId,
|
||||
nuqsOptions,
|
||||
@@ -60,10 +64,12 @@ export function OverviewFiltersDrawerContent({
|
||||
value={event}
|
||||
onChange={setEvent}
|
||||
// First items is * which is only used for report editing
|
||||
items={eventNames.slice(1).map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
items={eventNames
|
||||
.filter((item) => !excludePropertyFilter(item.name))
|
||||
.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IGetTopGenericInput } from '@openpanel/db';
|
||||
|
||||
export const OVERVIEW_COLUMNS_NAME: Record<
|
||||
IGetTopGenericInput['column'],
|
||||
string
|
||||
> = {
|
||||
country: 'Country',
|
||||
region: 'Region',
|
||||
city: 'City',
|
||||
browser: 'Browser',
|
||||
brand: 'Brand',
|
||||
os: 'OS',
|
||||
device: 'Device',
|
||||
browser_version: 'Browser version',
|
||||
os_version: 'OS version',
|
||||
model: 'Model',
|
||||
referrer: 'Referrer',
|
||||
referrer_name: 'Referrer name',
|
||||
referrer_type: 'Referrer type',
|
||||
utm_source: 'UTM source',
|
||||
utm_medium: 'UTM medium',
|
||||
utm_campaign: 'UTM campaign',
|
||||
utm_term: 'UTM term',
|
||||
utm_content: 'UTM content',
|
||||
};
|
||||
|
||||
export const OVERVIEW_COLUMNS_NAME_PLURAL: Record<
|
||||
IGetTopGenericInput['column'],
|
||||
string
|
||||
> = {
|
||||
country: 'Countries',
|
||||
region: 'Regions',
|
||||
city: 'Cities',
|
||||
browser: 'Browsers',
|
||||
brand: 'Brands',
|
||||
os: 'OSs',
|
||||
device: 'Devices',
|
||||
browser_version: 'Browser versions',
|
||||
os_version: 'OS versions',
|
||||
model: 'Models',
|
||||
referrer: 'Referrers',
|
||||
referrer_name: 'Referrer names',
|
||||
referrer_type: 'Referrer types',
|
||||
utm_source: 'UTM sources',
|
||||
utm_medium: 'UTM mediums',
|
||||
utm_campaign: 'UTM campaigns',
|
||||
utm_term: 'UTM terms',
|
||||
utm_content: 'UTM contents',
|
||||
};
|
||||
@@ -1,25 +1,12 @@
|
||||
import { pushModal } from '@/modals';
|
||||
import { ScanEyeIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import { Button, type ButtonProps } from '../ui/button';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
type Props = Omit<ButtonProps, 'children'>;
|
||||
|
||||
type Props = {
|
||||
chart: IChartProps;
|
||||
};
|
||||
|
||||
const OverviewDetailsButton = ({ chart }: Props) => {
|
||||
const OverviewDetailsButton = (props: Props) => {
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
pushModal('OverviewChartDetails', {
|
||||
chart: chart,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button size="icon" variant="ghost" {...props}>
|
||||
<ScanEyeIcon size={18} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
192
apps/dashboard/src/components/overview/overview-metric-card.tsx
Normal file
192
apps/dashboard/src/components/overview/overview-metric-card.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IChartData, RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart } from 'recharts';
|
||||
|
||||
import { average, getPreviousMetric, sum } from '@openpanel/common';
|
||||
import type { IChartMetric, Metrics } from '@openpanel/validation';
|
||||
import {
|
||||
PreviousDiffIndicator,
|
||||
PreviousDiffIndicatorPure,
|
||||
getDiffIndicator,
|
||||
} from '../report-chart/common/previous-diff-indicator';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
|
||||
interface MetricCardProps {
|
||||
id: string;
|
||||
data: {
|
||||
current: number;
|
||||
previous?: number;
|
||||
}[];
|
||||
metric: {
|
||||
current: number;
|
||||
previous?: number | null;
|
||||
};
|
||||
unit?: string;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
inverted?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function OverviewMetricCard({
|
||||
id,
|
||||
data,
|
||||
metric,
|
||||
unit,
|
||||
label,
|
||||
onClick,
|
||||
active,
|
||||
inverted = false,
|
||||
isLoading = false,
|
||||
}: MetricCardProps) {
|
||||
const number = useNumber();
|
||||
const { current, previous } = metric;
|
||||
|
||||
const renderValue = (value: number, unitClassName?: string, short = true) => {
|
||||
if (unit === 'min') {
|
||||
return <>{fancyMinutes(value)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{short ? number.short(value) : number.format(value)}
|
||||
{unit && <span className={unitClassName}>{unit}</span>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const graphColors = getDiffIndicator(
|
||||
inverted,
|
||||
getPreviousMetric(current, previous)?.state,
|
||||
'#6ee7b7', // green
|
||||
'#fda4af', // red
|
||||
'#93c5fd', // blue
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltiper
|
||||
content={
|
||||
<span>
|
||||
{label}:{' '}
|
||||
<span className="font-semibold">
|
||||
{renderValue(current, 'ml-1 font-light text-xl', false)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
sideOffset={-20}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
|
||||
active && 'bg-def-100',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={cn('group relative p-4')}>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={height / 4}
|
||||
data={data}
|
||||
style={{ marginTop: (height / 4) * 3 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`colorUv${id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={graphColors}
|
||||
stopOpacity={0.2}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={graphColors}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey={'current'}
|
||||
type="step"
|
||||
fill={`url(#colorUv${id})`}
|
||||
fillOpacity={1}
|
||||
stroke={graphColors}
|
||||
strokeWidth={1}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<OverviewMetricCardNumber
|
||||
label={label}
|
||||
value={renderValue(current, 'ml-1 font-light text-xl')}
|
||||
enhancer={
|
||||
<PreviousDiffIndicatorPure
|
||||
className="text-sm"
|
||||
size="sm"
|
||||
inverted={inverted}
|
||||
{...getPreviousMetric(current, previous)}
|
||||
/>
|
||||
}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltiper>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewMetricCardNumber({
|
||||
label,
|
||||
value,
|
||||
enhancer,
|
||||
className,
|
||||
isLoading,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
enhancer?: React.ReactNode;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||
<span className="truncate text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="truncate font-mono text-3xl font-bold">{value}</div>
|
||||
{enhancer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
apps/dashboard/src/components/overview/overview-metrics-v2.tsx
Normal file
258
apps/dashboard/src/components/overview/overview-metrics-v2.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { type RouterOutputs, api } from '@/trpc/client';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import React from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { createChartTooltip } from '../charts/chart-tooltip';
|
||||
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { OverviewLiveHistogram } from './overview-live-histogram';
|
||||
import { OverviewMetricCard } from './overview-metric-card';
|
||||
|
||||
interface OverviewMetricsProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const TITLES = [
|
||||
{
|
||||
title: 'Unique Visitors',
|
||||
key: 'unique_visitors',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Sessions',
|
||||
key: 'total_sessions',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Pageviews',
|
||||
key: 'total_screen_views',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Pages per session',
|
||||
key: 'views_per_session',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Bounce Rate',
|
||||
key: 'bounce_rate',
|
||||
unit: '%',
|
||||
inverted: true,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration',
|
||||
key: 'avg_session_duration',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function OverviewMetricsV2({ projectId }: OverviewMetricsProps) {
|
||||
const { range, interval, metric, setMetric, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
const [filters] = useEventQueryFilters();
|
||||
|
||||
const activeMetric = TITLES[metric]!;
|
||||
const overviewQuery = api.overview.stats.useQuery({
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const data =
|
||||
overviewQuery.data?.series?.map((item) => ({
|
||||
...item,
|
||||
timestamp: new Date(item.date).getTime(),
|
||||
})) || [];
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval: 'day' });
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
||||
<div className="card mb-2 grid grid-cols-4 overflow-hidden rounded-md">
|
||||
{TITLES.map((title, index) => (
|
||||
<OverviewMetricCard
|
||||
key={title.key}
|
||||
id={title.key}
|
||||
onClick={() => setMetric(index)}
|
||||
label={title.title}
|
||||
metric={{
|
||||
current: overviewQuery.data?.metrics[title.key] ?? 0,
|
||||
previous: overviewQuery.data?.metrics[`prev_${title.key}`],
|
||||
}}
|
||||
unit={title.unit}
|
||||
data={data.map((item) => ({
|
||||
current: item[title.key],
|
||||
previous: item[`prev_${title.key}`],
|
||||
}))}
|
||||
active={metric === index}
|
||||
isLoading={overviewQuery.isLoading}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'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-2',
|
||||
)}
|
||||
>
|
||||
<OverviewLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="text-center text-muted-foreground mb-2">
|
||||
{activeMetric.title}
|
||||
</div>
|
||||
<div className="w-full h-[150px]">
|
||||
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
||||
<TooltipProvider metric={activeMetric}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[
|
||||
0,
|
||||
activeMetric.key === 'bounce_rate' ? 100 : 'dataMax',
|
||||
]}
|
||||
width={25}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="linear"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
stroke={'hsl(var(--foreground) / 0.2)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: 'hsl(var(--foreground) / 0.2)',
|
||||
fill: 'hsl(var(--def-100))',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'hsl(var(--foreground) / 0.2)',
|
||||
fill: 'hsl(var(--def-100))',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={activeMetric.key}
|
||||
type="linear"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'hsl(var(--def-100))',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
fill: 'hsl(var(--def-100))',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
RouterOutputs['overview']['stats']['series'][number],
|
||||
{
|
||||
metric: (typeof TITLES)[number];
|
||||
}
|
||||
>(({ context: { metric }, data }) => {
|
||||
const formatDate = useFormatDateInterval('day');
|
||||
const number = useNumber();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{formatDate(new Date(data.date))}</div>
|
||||
</div>
|
||||
<React.Fragment>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">{metric.title}</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data[metric.key])}
|
||||
{!!data[`prev_${metric.key}`] && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(data[`prev_${metric.key}`])})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
data[metric.key],
|
||||
data[`prev_${metric.key}`],
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -218,7 +218,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
<OverviewLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card col-span-6 p-4">
|
||||
{/* <div className="card col-span-6 p-4">
|
||||
<ReportChart
|
||||
key={selectedMetric.id}
|
||||
options={{
|
||||
@@ -233,7 +233,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
lineType: 'linear',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,18 @@ import { useState } from 'react';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { api } from '@/trpc/client';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import {
|
||||
OverviewWidgetTableGeneric,
|
||||
OverviewWidgetTableLoading,
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -27,7 +34,7 @@ export default function OverviewTopDevices({
|
||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
|
||||
devices: {
|
||||
device: {
|
||||
title: 'Top devices',
|
||||
btn: 'Devices',
|
||||
chart: {
|
||||
@@ -221,7 +228,7 @@ export default function OverviewTopDevices({
|
||||
},
|
||||
},
|
||||
},
|
||||
brands: {
|
||||
brand: {
|
||||
title: 'Top Brands',
|
||||
btn: 'Brands',
|
||||
chart: {
|
||||
@@ -257,7 +264,7 @@ export default function OverviewTopDevices({
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
model: {
|
||||
title: 'Top Models',
|
||||
btn: 'Models',
|
||||
chart: {
|
||||
@@ -302,11 +309,24 @@ export default function OverviewTopDevices({
|
||||
},
|
||||
});
|
||||
|
||||
const number = useNumber();
|
||||
|
||||
const query = api.overview.topGeneric.useQuery({
|
||||
projectId,
|
||||
interval,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
@@ -321,39 +341,44 @@ export default function OverviewTopDevices({
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ReportChart
|
||||
options={{
|
||||
...widget.chart.options,
|
||||
hideID: true,
|
||||
onClick: (item) => {
|
||||
switch (widget.key) {
|
||||
case 'devices':
|
||||
setFilter('device', item.names[0]);
|
||||
break;
|
||||
case 'browser':
|
||||
setFilter('browser', item.names[0]);
|
||||
break;
|
||||
case 'browser_version':
|
||||
setFilter('browser_version', item.names[1]);
|
||||
break;
|
||||
case 'os':
|
||||
setFilter('os', item.names[0]);
|
||||
break;
|
||||
case 'os_version':
|
||||
setFilter('os_version', item.names[1]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
}}
|
||||
report={{
|
||||
...widget.chart.report,
|
||||
previous: false,
|
||||
}}
|
||||
/>
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading className="-m-4" />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
className="-m-4"
|
||||
data={query.data ?? []}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon name={item.name || NOT_SET_VALUE} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter(widget.key, item.name);
|
||||
}}
|
||||
>
|
||||
{item.name || 'Not set'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart.report} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
<OverviewDetailsButton
|
||||
onClick={() =>
|
||||
pushModal('OverviewTopGenericModal', {
|
||||
projectId,
|
||||
column: widget.key,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { Widget, WidgetBody } from '../../widget';
|
||||
import { OverviewChartToggle } from '../overview-chart-toggle';
|
||||
import OverviewDetailsButton from '../overview-details-button';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from '../overview-widget';
|
||||
import { useOverviewOptions } from '../useOverviewOptions';
|
||||
import { useOverviewWidget } from '../useOverviewWidget';
|
||||
@@ -175,7 +174,6 @@ export default function OverviewTopEvents({
|
||||
/>
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart.report} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
|
||||
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
|
||||
import { api } from '@/trpc/client';
|
||||
import type { IGetTopGenericInput } from '@openpanel/db';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import {
|
||||
OVERVIEW_COLUMNS_NAME,
|
||||
OVERVIEW_COLUMNS_NAME_PLURAL,
|
||||
} from './overview-constants';
|
||||
import { OverviewWidgetTableGeneric } from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
interface OverviewTopGenericModalProps {
|
||||
projectId: string;
|
||||
column: IGetTopGenericInput['column'];
|
||||
}
|
||||
|
||||
export default function OverviewTopGenericModal({
|
||||
projectId,
|
||||
column,
|
||||
}: OverviewTopGenericModalProps) {
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const { startDate, endDate, range, interval } = useOverviewOptions();
|
||||
const query = api.overview.topGeneric.useInfiniteQuery(
|
||||
{
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
interval,
|
||||
limit: 50,
|
||||
column,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (lastPage.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pages.length + 1;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = query.data?.pages.flat() || [];
|
||||
const isEmpty = !query.hasNextPage && !query.isFetching;
|
||||
|
||||
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
|
||||
const columnName = OVERVIEW_COLUMNS_NAME[column];
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title={`Top ${columnNamePlural}`} />
|
||||
<ScrollArea className="-mx-6 px-2 max-h-[calc(80vh)]">
|
||||
<OverviewWidgetTableGeneric
|
||||
data={data}
|
||||
column={{
|
||||
name: columnName,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon name={item.prefix || item.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter(column, item.name);
|
||||
}}
|
||||
>
|
||||
{item.prefix && (
|
||||
<span className="mr-1 row inline-flex items-center gap-1">
|
||||
<span>{item.prefix}</span>
|
||||
<span>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{item.name || 'Not set'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div className="row center-center p-4 pb-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { getCountry } from '@/translations/countries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { api } from '@/trpc/client';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import {
|
||||
OverviewWidgetTableGeneric,
|
||||
OverviewWidgetTableLoading,
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
|
||||
interface OverviewTopGeoProps {
|
||||
projectId: string;
|
||||
@@ -25,139 +33,39 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
|
||||
countries: {
|
||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
|
||||
country: {
|
||||
title: 'Top countries',
|
||||
btn: 'Countries',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Country', isPageFilter ? 'Views' : 'Sessions'],
|
||||
renderSerieName(name) {
|
||||
return getCountry(name[0]) || NOT_SET_VALUE;
|
||||
},
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'country',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top countries',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
regions: {
|
||||
region: {
|
||||
title: 'Top regions',
|
||||
btn: 'Regions',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Region', isPageFilter ? 'Views' : 'Sessions'],
|
||||
renderSerieName(name) {
|
||||
return name[1] || NOT_SET_VALUE;
|
||||
},
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'country',
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
name: 'region',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top regions',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
cities: {
|
||||
city: {
|
||||
title: 'Top cities',
|
||||
btn: 'Cities',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['City', isPageFilter ? 'Views' : 'Sessions'],
|
||||
renderSerieName(name) {
|
||||
return name[1] || NOT_SET_VALUE;
|
||||
},
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'country',
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
name: 'city',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top cities',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const number = useNumber();
|
||||
|
||||
const query = api.overview.topGeneric.useQuery({
|
||||
projectId,
|
||||
interval,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
@@ -172,35 +80,59 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ReportChart
|
||||
options={{
|
||||
hideID: true,
|
||||
onClick: (item) => {
|
||||
switch (widget.key) {
|
||||
case 'countries':
|
||||
setWidget('regions');
|
||||
setFilter('country', item.names[0]);
|
||||
break;
|
||||
case 'regions':
|
||||
setWidget('cities');
|
||||
setFilter('region', item.names[1]);
|
||||
break;
|
||||
case 'cities':
|
||||
setFilter('city', item.names[1]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
...widget.chart.options,
|
||||
}}
|
||||
report={{
|
||||
...widget.chart.report,
|
||||
previous: false,
|
||||
}}
|
||||
/>
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading className="-m-4" />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
className="-m-4"
|
||||
data={query.data ?? []}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon
|
||||
name={item.prefix || item.name || NOT_SET_VALUE}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
if (widget.key === 'country') {
|
||||
setWidget('region');
|
||||
} else if (widget.key === 'region') {
|
||||
setWidget('city');
|
||||
}
|
||||
setFilter(widget.key, item.name);
|
||||
}}
|
||||
>
|
||||
{item.prefix && (
|
||||
<span className="mr-1 row inline-flex items-center gap-1">
|
||||
<span>{item.prefix}</span>
|
||||
<span>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{item.name || 'Not set'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart.report} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
<OverviewDetailsButton
|
||||
onClick={() =>
|
||||
pushModal('OverviewTopGenericModal', {
|
||||
projectId,
|
||||
column: widget.key,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
|
||||
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
|
||||
import { api } from '@/trpc/client';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { OverviewWidgetTablePages } from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
interface OverviewTopPagesProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function getPath(path: string) {
|
||||
try {
|
||||
return new URL(path).pathname;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export default function OverviewTopPagesModal({
|
||||
projectId,
|
||||
}: OverviewTopPagesProps) {
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const { startDate, endDate, range, interval } = useOverviewOptions();
|
||||
const query = api.overview.topPages.useInfiniteQuery(
|
||||
{
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
mode: 'page',
|
||||
range,
|
||||
interval: 'day',
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (_, pages) => pages.length + 1,
|
||||
},
|
||||
);
|
||||
|
||||
const data = query.data?.pages.flat();
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title="Top Pages" />
|
||||
<ScrollArea className="-mx-6 px-2 max-h-[calc(80vh)]">
|
||||
<OverviewWidgetTablePages
|
||||
data={data ?? []}
|
||||
lastColumnName={'Sessions'}
|
||||
/>
|
||||
<div className="row center-center p-4 pb-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
loading={query.isFetching}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -2,179 +2,81 @@
|
||||
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ExternalLinkIcon, FilterIcon, Globe2Icon } from 'lucide-react';
|
||||
import { Globe2Icon } from 'lucide-react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { pushModal } from '@/modals';
|
||||
import { api } from '@/trpc/client';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import OverviewTopBots from './overview-top-bots';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import {
|
||||
OverviewWidgetTableBots,
|
||||
OverviewWidgetTableLoading,
|
||||
OverviewWidgetTablePages,
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
|
||||
interface OverviewTopPagesProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
||||
const renderSerieName = (names: string[]) => {
|
||||
if (domain) {
|
||||
if (names[0] === NOT_SET_VALUE) {
|
||||
return names[1];
|
||||
}
|
||||
|
||||
return names.join('');
|
||||
}
|
||||
return (
|
||||
<Tooltiper content={names.join('')} side="left" className="text-left">
|
||||
{names[1] || NOT_SET_VALUE}
|
||||
</Tooltiper>
|
||||
);
|
||||
};
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
|
||||
top: {
|
||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
|
||||
page: {
|
||||
title: 'Top pages',
|
||||
btn: 'Top pages',
|
||||
chart: {
|
||||
options: {
|
||||
renderSerieName,
|
||||
columns: ['URL', 'Views'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: 'screen_view',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'origin',
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Top pages',
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
meta: {
|
||||
columns: {
|
||||
sessions: 'Sessions',
|
||||
},
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
entry: {
|
||||
title: 'Entry Pages',
|
||||
btn: 'Entries',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['URL', 'Sessions'],
|
||||
renderSerieName,
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'origin',
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Entry Pages',
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
meta: {
|
||||
columns: {
|
||||
sessions: 'Entries',
|
||||
},
|
||||
},
|
||||
},
|
||||
exits: {
|
||||
exit: {
|
||||
title: 'Exit Pages',
|
||||
btn: 'Exits',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['URL', 'Sessions'],
|
||||
renderSerieName,
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: 'session_end',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'origin',
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Exit Pages',
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
meta: {
|
||||
columns: {
|
||||
sessions: 'Exits',
|
||||
},
|
||||
},
|
||||
},
|
||||
bot: {
|
||||
title: 'Bots',
|
||||
btn: 'Bots',
|
||||
// @ts-expect-error
|
||||
chart: null,
|
||||
},
|
||||
// bot: {
|
||||
// title: 'Bots',
|
||||
// btn: 'Bots',
|
||||
// },
|
||||
});
|
||||
|
||||
const query = api.overview.topPages.useQuery({
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
mode: widget.key,
|
||||
range,
|
||||
interval,
|
||||
});
|
||||
|
||||
const data = query.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
@@ -194,53 +96,36 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{widget.key === 'bot' ? (
|
||||
<OverviewTopBots projectId={projectId} />
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading className="-m-4" />
|
||||
) : (
|
||||
<ReportChart
|
||||
options={{
|
||||
hideID: true,
|
||||
dropdownMenuContent: (serie) => [
|
||||
{
|
||||
title: 'Visit page',
|
||||
icon: ExternalLinkIcon,
|
||||
onClick: () => {
|
||||
window.open(serie.names.join(''), '_blank');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Set filter',
|
||||
icon: FilterIcon,
|
||||
onClick: () => {
|
||||
setFilter('path', serie.names[1]);
|
||||
},
|
||||
},
|
||||
],
|
||||
...widget.chart.options,
|
||||
}}
|
||||
report={{
|
||||
...widget.chart.report,
|
||||
previous: false,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{/*<OverviewWidgetTableBots className="-m-4" data={data ?? []} />*/}
|
||||
<OverviewWidgetTablePages
|
||||
className="-m-4"
|
||||
data={data ?? []}
|
||||
lastColumnName={widget.meta.columns.sessions}
|
||||
showDomain={!!domain}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</WidgetBody>
|
||||
{widget.chart?.report?.name && (
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart.report} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={() => {
|
||||
setDomain((p) => !p);
|
||||
}}
|
||||
icon={Globe2Icon}
|
||||
>
|
||||
{domain ? 'Hide domain' : 'Show domain'}
|
||||
</Button>
|
||||
</WidgetFooter>
|
||||
)}
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton
|
||||
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
|
||||
/>
|
||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={() => {
|
||||
setDomain((p) => !p);
|
||||
}}
|
||||
icon={Globe2Icon}
|
||||
>
|
||||
{domain ? 'Hide domain' : 'Show domain'}
|
||||
</Button>
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { pushModal } from '@/modals';
|
||||
import { api } from '@/trpc/client';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import {
|
||||
OverviewWidgetTableGeneric,
|
||||
OverviewWidgetTableLoading,
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
|
||||
interface OverviewTopSourcesProps {
|
||||
projectId: string;
|
||||
@@ -21,302 +24,53 @@ interface OverviewTopSourcesProps {
|
||||
export default function OverviewTopSources({
|
||||
projectId,
|
||||
}: OverviewTopSourcesProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
|
||||
all: {
|
||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
|
||||
referrer_name: {
|
||||
title: 'Top sources',
|
||||
btn: 'All',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Source', isPageFilter ? 'Views' : 'Sessions'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'referrer_name',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
domain: {
|
||||
referrer: {
|
||||
title: 'Top urls',
|
||||
btn: 'URLs',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['URL', isPageFilter ? 'Views' : 'Sessions'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'referrer',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top urls',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
type: {
|
||||
referrer_type: {
|
||||
title: 'Top types',
|
||||
btn: 'Types',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Type', isPageFilter ? 'Views' : 'Sessions'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'referrer_type',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top types',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
utm_source: {
|
||||
title: 'UTM Source',
|
||||
btn: 'Source',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Utm Source', isPageFilter ? 'Views' : 'Sessions'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'properties.__query.utm_source',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'UTM Source',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
utm_medium: {
|
||||
title: 'UTM Medium',
|
||||
btn: 'Medium',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Utm Medium', isPageFilter ? 'Views' : 'Sessions'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'properties.__query.utm_medium',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'UTM Medium',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
utm_campaign: {
|
||||
title: 'UTM Campaign',
|
||||
btn: 'Campaign',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Utm Campaign', isPageFilter ? 'Views' : 'Sessions'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'properties.__query.utm_campaign',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'UTM Campaign',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
utm_term: {
|
||||
title: 'UTM Term',
|
||||
btn: 'Term',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Utm Term', isPageFilter ? 'Views' : 'Sessions'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'properties.__query.utm_term',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'UTM Term',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
utm_content: {
|
||||
title: 'UTM Content',
|
||||
btn: 'Content',
|
||||
chart: {
|
||||
options: {
|
||||
columns: ['Utm Content', isPageFilter ? 'Views' : 'Sessions'],
|
||||
},
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: isPageFilter ? 'screen_view' : 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'properties.__query.utm_content',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'UTM Content',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const query = api.overview.topGeneric.useQuery({
|
||||
projectId,
|
||||
interval,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
@@ -337,51 +91,53 @@ export default function OverviewTopSources({
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ReportChart
|
||||
report={{
|
||||
...widget.chart.report,
|
||||
previous: false,
|
||||
}}
|
||||
options={{
|
||||
...widget.chart.options,
|
||||
renderSerieName: (name) =>
|
||||
name[0] === NOT_SET_VALUE ? 'Direct / Not set' : name[0],
|
||||
onClick: (item) => {
|
||||
switch (widget.key) {
|
||||
case 'all':
|
||||
setFilter('referrer_name', item.names[0]);
|
||||
setWidget('domain');
|
||||
break;
|
||||
case 'domain':
|
||||
setFilter('referrer', item.names[0]);
|
||||
break;
|
||||
case 'type':
|
||||
setFilter('referrer_type', item.names[0]);
|
||||
setWidget('domain');
|
||||
break;
|
||||
case 'utm_source':
|
||||
setFilter('properties.__query.utm_source', item.names[0]);
|
||||
break;
|
||||
case 'utm_medium':
|
||||
setFilter('properties.__query.utm_medium', item.names[0]);
|
||||
break;
|
||||
case 'utm_campaign':
|
||||
setFilter('properties.__query.utm_campaign', item.names[0]);
|
||||
break;
|
||||
case 'utm_term':
|
||||
setFilter('properties.__query.utm_term', item.names[0]);
|
||||
break;
|
||||
case 'utm_content':
|
||||
setFilter('properties.__query.utm_content', item.names[0]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading className="-m-4" />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
className="-m-4"
|
||||
data={query.data ?? []}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon name={item.name || NOT_SET_VALUE} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
if (widget.key.startsWith('utm_')) {
|
||||
setFilter(
|
||||
`properties.__query.${widget.key}`,
|
||||
item.name,
|
||||
);
|
||||
} else {
|
||||
setFilter(widget.key, item.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(item.name || 'Direct / Not set')
|
||||
.replace(/https?:\/\//, '')
|
||||
.replace('www.', '')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart.report} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
<OverviewDetailsButton
|
||||
onClick={() =>
|
||||
pushModal('OverviewTopGenericModal', {
|
||||
projectId,
|
||||
column: widget.key,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
330
apps/dashboard/src/components/overview/overview-widget-table.tsx
Normal file
330
apps/dashboard/src/components/overview/overview-widget-table.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { WidgetTable, type Props as WidgetTableProps } from '../widget-table';
|
||||
|
||||
type Props<T> = WidgetTableProps<T> & {
|
||||
getColumnPercentage: (item: T) => number;
|
||||
};
|
||||
|
||||
export const OverviewWidgetTable = <T,>({
|
||||
data,
|
||||
keyExtractor,
|
||||
columns,
|
||||
getColumnPercentage,
|
||||
className,
|
||||
}: Props<T>) => {
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<WidgetTable
|
||||
data={data ?? []}
|
||||
keyExtractor={keyExtractor}
|
||||
className={'text-sm min-h-[358px] @container'}
|
||||
columnClassName="px-2 group/row items-center"
|
||||
eachRow={(item) => {
|
||||
return (
|
||||
<div className="absolute inset-0 !p-0">
|
||||
<div
|
||||
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative"
|
||||
style={{
|
||||
width: `${getColumnPercentage(item) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
columns={columns.map((column, index) => {
|
||||
return {
|
||||
...column,
|
||||
className: cn(
|
||||
index === 0
|
||||
? 'w-full flex-1 font-medium min-w-0'
|
||||
: 'text-right justify-end row w-20 font-mono',
|
||||
index !== 0 &&
|
||||
index !== columns.length - 1 &&
|
||||
'hidden @[310px]:row',
|
||||
column.className,
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function OverviewWidgetTableLoading({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
|
||||
keyExtractor={(item) => item.toString()}
|
||||
getColumnPercentage={() => 0}
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
render: () => <Skeleton className="h-4 w-1/3" />,
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||
},
|
||||
// {
|
||||
// name: 'Duration',
|
||||
// render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||
// },
|
||||
{
|
||||
name: 'Sessions',
|
||||
render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getPath(path: string, showDomain = false) {
|
||||
try {
|
||||
const url = new URL(path);
|
||||
if (showDomain) {
|
||||
return url.hostname + url.pathname;
|
||||
}
|
||||
return url.pathname;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export function OverviewWidgetTablePages({
|
||||
data,
|
||||
lastColumnName,
|
||||
className,
|
||||
showDomain = false,
|
||||
}: {
|
||||
className?: string;
|
||||
lastColumnName: string;
|
||||
data: {
|
||||
origin: string;
|
||||
path: string;
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
}[];
|
||||
showDomain?: boolean;
|
||||
}) {
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon name={item.origin} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter('path', item.path);
|
||||
setFilter('origin', item.origin);
|
||||
}}
|
||||
>
|
||||
{showDomain ? (
|
||||
<>
|
||||
<span className="opacity-40">{item.origin}</span>
|
||||
<span>{item.path}</span>
|
||||
</>
|
||||
) : (
|
||||
item.path
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
className: 'w-16',
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Duration',
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.avg_duration, 'min');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: lastColumnName,
|
||||
// className: 'w-28',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.sessions)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewWidgetTableBots({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
data: {
|
||||
total_sessions: number;
|
||||
origin: string;
|
||||
path: string;
|
||||
sessions: number;
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
}[];
|
||||
}) {
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon name={item.origin} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter('path', item.path);
|
||||
}}
|
||||
>
|
||||
{getPath(item.path)}
|
||||
</button>
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Bot',
|
||||
// className: 'w-28',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">Google bot</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Date',
|
||||
// className: 'w-28',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">Google bot</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewWidgetTableGeneric({
|
||||
data,
|
||||
column,
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
data: RouterOutputs['overview']['topGeneric'];
|
||||
column: {
|
||||
name: string;
|
||||
render: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
) => React.ReactNode;
|
||||
};
|
||||
}) {
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.name}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
column,
|
||||
{
|
||||
name: 'BR',
|
||||
className: 'w-16',
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
},
|
||||
// {
|
||||
// name: 'Duration',
|
||||
// render(item) {
|
||||
// return number.shortWithUnit(item.avg_session_duration, 'min');
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'Sessions',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.sessions)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export function useOverviewOptions() {
|
||||
setStartDate(null);
|
||||
setEndDate(null);
|
||||
setStorageItem('range', value);
|
||||
setInterval(null);
|
||||
}
|
||||
setRange(value);
|
||||
},
|
||||
|
||||
@@ -30,3 +30,27 @@ export function useOverviewWidget<T extends string>(
|
||||
})),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function useOverviewWidgetV2<T extends string>(
|
||||
key: string,
|
||||
widgets: Record<T, { title: string; btn: string; meta?: any }>,
|
||||
) {
|
||||
const keys = Object.keys(widgets) as T[];
|
||||
const [widget, setWidget] = useQueryState<T>(
|
||||
key,
|
||||
parseAsStringEnum(keys)
|
||||
.withDefault(keys[0]!)
|
||||
.withOptions({ history: 'push' }),
|
||||
);
|
||||
return [
|
||||
{
|
||||
...widgets[widget],
|
||||
key: widget,
|
||||
},
|
||||
setWidget,
|
||||
mapKeys(widgets).map((key) => ({
|
||||
...widgets[key],
|
||||
key,
|
||||
})),
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
|
||||
|
||||
async function ProjectChart({ id }: { id: string }) {
|
||||
const chart = await chQuery<{ value: number; date: string }>(
|
||||
`SELECT countDistinct(profile_id) as value, toStartOfDay(created_at) as date FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(id)} AND name = 'session_start' AND created_at >= now() - interval '1 month' GROUP BY date ORDER BY date ASC`,
|
||||
`SELECT countDistinct(profile_id) as value, toStartOfDay(created_at) as date FROM ${TABLE_NAMES.sessions} WHERE sign = 1 AND project_id = ${escape(id)} AND created_at >= now() - interval '1 month' GROUP BY date ORDER BY date ASC`,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -73,27 +73,27 @@ function Metric({ value, label }: { value: React.ReactNode; label: string }) {
|
||||
|
||||
async function ProjectMetrics({ id }: { id: string }) {
|
||||
const [metrics] = await chQuery<{
|
||||
total: number;
|
||||
months_3: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
(
|
||||
SELECT count(DISTINCT profile_id) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(id)}
|
||||
) as total,
|
||||
SELECT uniq(DISTINCT profile_id) as count FROM ${TABLE_NAMES.sessions} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '6 months'
|
||||
) as months_3,
|
||||
(
|
||||
SELECT count(DISTINCT profile_id) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '1 month'
|
||||
SELECT uniq(DISTINCT profile_id) as count FROM ${TABLE_NAMES.sessions} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '1 month'
|
||||
) as month,
|
||||
(
|
||||
SELECT count(DISTINCT profile_id) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '1 day'
|
||||
SELECT uniq(DISTINCT profile_id) as count FROM ${TABLE_NAMES.sessions} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '1 day'
|
||||
) as day
|
||||
`,
|
||||
);
|
||||
|
||||
return (
|
||||
<FadeIn className="flex gap-4">
|
||||
<Metric label="Total" value={shortNumber('en')(metrics?.total)} />
|
||||
<Metric label="3 months" value={shortNumber('en')(metrics?.months_3)} />
|
||||
<Metric label="Month" value={shortNumber('en')(metrics?.month)} />
|
||||
<Metric label="24h" value={shortNumber('en')(metrics?.day)} />
|
||||
</FadeIn>
|
||||
|
||||
@@ -91,3 +91,65 @@ export function PreviousDiffIndicator({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreviousDiffIndicatorPureProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
inverted?: boolean;
|
||||
size?: 'sm' | 'lg' | 'md';
|
||||
className?: string;
|
||||
showPrevious?: boolean;
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicatorPure({
|
||||
diff,
|
||||
state,
|
||||
inverted,
|
||||
size = 'sm',
|
||||
className,
|
||||
showPrevious = true,
|
||||
}: PreviousDiffIndicatorPureProps) {
|
||||
const variant = getDiffIndicator(
|
||||
inverted,
|
||||
state,
|
||||
'bg-emerald-300',
|
||||
'bg-rose-300',
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (diff === null || diff === undefined || !showPrevious) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (state === 'positive') {
|
||||
return <ArrowUpIcon strokeWidth={3} size={10} color="#000" />;
|
||||
}
|
||||
if (state === 'negative') {
|
||||
return <ArrowDownIcon strokeWidth={3} size={10} color="#000" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 font-mono font-medium',
|
||||
size === 'lg' && 'gap-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-2.5 items-center justify-center rounded-full',
|
||||
variant,
|
||||
size === 'lg' && 'size-8',
|
||||
size === 'md' && 'size-6',
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
{diff.toFixed(1)}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const data = {
|
||||
'samsung internet': 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png',
|
||||
'vstat.info': 'https://vstat.info',
|
||||
'yahoo!': 'https://yahoo.com',
|
||||
android: 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
|
||||
android: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Android_robot.svg/1745px-Android_robot.svg.png',
|
||||
'android browser': 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
|
||||
silk: 'https://m.media-amazon.com/images/I/51VCjQCvF0L.png',
|
||||
kakaotalk: 'https://www.kakaocorp.com/',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { last } from 'ramda';
|
||||
import { getPreviousMetric, round } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { MetricCardNumber } from '../metric/metric-card';
|
||||
@@ -36,6 +37,7 @@ export function Chart({
|
||||
previous,
|
||||
},
|
||||
}: Props) {
|
||||
const number = useNumber();
|
||||
const { isEditMode } = useReportChartContext();
|
||||
const mostDropoffs = findMostDropoffs(steps);
|
||||
const lastStep = last(steps)!;
|
||||
@@ -50,39 +52,39 @@ export function Chart({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-8 p-4 px-8">
|
||||
<div className="flex flex-1 items-center gap-8 min-w-0">
|
||||
<MetricCardNumber
|
||||
label="Converted"
|
||||
value={lastStep.count}
|
||||
enhancer={
|
||||
<PreviousDiffIndicator
|
||||
size="md"
|
||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MetricCardNumber
|
||||
label="Percent"
|
||||
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 2) : 0}%`}
|
||||
enhancer={
|
||||
<PreviousDiffIndicator
|
||||
size="md"
|
||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MetricCardNumber
|
||||
className="flex-1"
|
||||
label="Most dropoffs"
|
||||
value={mostDropoffs.event.displayName}
|
||||
enhancer={
|
||||
<PreviousDiffIndicator
|
||||
size="md"
|
||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<MetricCardNumber
|
||||
className="flex-1"
|
||||
label="Converted"
|
||||
value={lastStep.count}
|
||||
enhancer={
|
||||
<PreviousDiffIndicator
|
||||
size="md"
|
||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MetricCardNumber
|
||||
className="flex-1"
|
||||
label="Percent"
|
||||
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 2) : 0}%`}
|
||||
enhancer={
|
||||
<PreviousDiffIndicator
|
||||
size="md"
|
||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MetricCardNumber
|
||||
className="flex-1"
|
||||
label="Most dropoffs"
|
||||
value={mostDropoffs.event.displayName}
|
||||
enhancer={
|
||||
<PreviousDiffIndicator
|
||||
size="md"
|
||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col divide-y divide-def-200">
|
||||
@@ -109,7 +111,9 @@ export function Chart({
|
||||
<span>
|
||||
Last period:{' '}
|
||||
<span className="font-mono">
|
||||
{previous?.steps?.[index]?.previousCount}
|
||||
{number.format(
|
||||
previous?.steps?.[index]?.previousCount,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<PreviousDiffIndicator
|
||||
@@ -127,7 +131,7 @@ export function Chart({
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-mono">
|
||||
{step.previousCount}
|
||||
{number.format(step.previousCount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +143,9 @@ export function Chart({
|
||||
<span>
|
||||
Last period:{' '}
|
||||
<span className="font-mono">
|
||||
{previous?.steps?.[index]?.dropoffCount}
|
||||
{number.format(
|
||||
previous?.steps?.[index]?.dropoffCount,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<PreviousDiffIndicator
|
||||
@@ -164,7 +170,7 @@ export function Chart({
|
||||
)}
|
||||
>
|
||||
{isMostDropoffs && <AlertCircleIcon size={14} />}
|
||||
{step.dropoffCount}
|
||||
{number.format(step.dropoffCount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,7 +182,7 @@ export function Chart({
|
||||
<span>
|
||||
Last period:{' '}
|
||||
<span className="font-mono">
|
||||
{previous?.steps?.[index]?.count}
|
||||
{number.format(previous?.steps?.[index]?.count)}
|
||||
</span>
|
||||
</span>
|
||||
<PreviousDiffIndicator
|
||||
@@ -193,7 +199,9 @@ export function Chart({
|
||||
Current:
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-mono">{step.count}</span>
|
||||
<span className="text-lg font-mono">
|
||||
{number.format(step.count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipComplete>
|
||||
@@ -204,7 +212,7 @@ export function Chart({
|
||||
<span>
|
||||
Last period:{' '}
|
||||
<span className="font-mono">
|
||||
{previous?.steps?.[index]?.count}
|
||||
{number.format(previous?.steps?.[index]?.count)}
|
||||
</span>
|
||||
</span>
|
||||
<PreviousDiffIndicator
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface Props<T> {
|
||||
export interface Props<T> {
|
||||
columns: {
|
||||
name: string;
|
||||
render: (item: T) => React.ReactNode;
|
||||
@@ -9,6 +9,8 @@ interface Props<T> {
|
||||
keyExtractor: (item: T) => string;
|
||||
data: T[];
|
||||
className?: string;
|
||||
eachRow?: (item: T) => React.ReactNode;
|
||||
columnClassName?: string;
|
||||
}
|
||||
|
||||
export const WidgetTableHead = ({
|
||||
@@ -21,7 +23,7 @@ export const WidgetTableHead = ({
|
||||
return (
|
||||
<thead
|
||||
className={cn(
|
||||
'text-def-1000 sticky top-0 z-10 border-b border-border bg-def-100 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-medium',
|
||||
'text-def-1000 sticky top-0 z-10 border-b border-border bg-def-100 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-right [&_th:first-child]:text-left [&_th]:font-medium',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -35,36 +37,69 @@ export function WidgetTable<T>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
eachRow,
|
||||
columnClassName,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className={cn('w-full', className)}>
|
||||
<WidgetTableHead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.name} className={cn(column.className)}>
|
||||
{column.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</WidgetTableHead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className={
|
||||
'border-b border-border text-right last:border-0 [&_td:first-child]:text-left [&_td]:p-4'
|
||||
}
|
||||
<div className={cn('w-full', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'border-b border-border text-right last:border-0 [&_div:first-child]:text-left grid',
|
||||
'[&>div]:p-2',
|
||||
columnClassName,
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
columns.length > 1
|
||||
? `1fr ${columns
|
||||
.slice(1)
|
||||
.map((col) => 'auto')
|
||||
.join(' ')}`
|
||||
: '1fr',
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.name}
|
||||
className={cn(column.className, 'font-medium font-sans text-sm')}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.name} className={cn(column.className)}>
|
||||
{column.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{column.name}
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="col">
|
||||
{data.map((item) => (
|
||||
<div
|
||||
key={keyExtractor(item)}
|
||||
className={cn(
|
||||
'border-b border-border text-right last:border-0 [&_div:first-child]:text-left grid relative',
|
||||
'[&>div]:p-2',
|
||||
columnClassName,
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
columns.length > 1
|
||||
? `1fr ${columns
|
||||
.slice(1)
|
||||
.map((col) => 'auto')
|
||||
.join(' ')}`
|
||||
: '1fr',
|
||||
}}
|
||||
>
|
||||
{eachRow?.(item)}
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.name}
|
||||
className={cn(column.className, 'relative h-8')}
|
||||
>
|
||||
{column.render(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
|
||||
if (filter.name === name) {
|
||||
return {
|
||||
...filter,
|
||||
operator,
|
||||
operator: newValue.length === 0 ? 'isNull' : operator,
|
||||
value: newValue,
|
||||
};
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
|
||||
{
|
||||
id: name,
|
||||
name,
|
||||
operator,
|
||||
operator: newValue.length === 0 ? 'isNull' : operator,
|
||||
value: newValue,
|
||||
},
|
||||
];
|
||||
@@ -102,7 +102,14 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
|
||||
[setFilters],
|
||||
);
|
||||
|
||||
return [filters, setFilter, setFilters] as const;
|
||||
const removeFilter = useCallback(
|
||||
(name: string) => {
|
||||
setFilters((prev) => prev.filter((filter) => filter.name !== name));
|
||||
},
|
||||
[setFilters],
|
||||
);
|
||||
|
||||
return [filters, setFilter, setFilters, removeFilter] as const;
|
||||
}
|
||||
|
||||
export const eventQueryNamesFilter = parseAsArrayOf(parseAsString).withDefault(
|
||||
|
||||
@@ -66,6 +66,9 @@ export function useNumber() {
|
||||
if (unit === 'min') {
|
||||
return fancyMinutes(value);
|
||||
}
|
||||
if (unit === '%') {
|
||||
return `${format(round(value * 100, 1))}${unit ? ` ${unit}` : ''}`;
|
||||
}
|
||||
return `${format(value)}${unit ? ` ${unit}` : ''}`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ModalHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative -m-6 mb-6 flex justify-between rounded-t-lg bg-gradient-to-b from-def-300 to-background p-6 pb-0',
|
||||
'relative -m-6 mb-4 flex justify-between rounded-t-lg bg-gradient-to-b from-def-300 to-background p-6 pb-0',
|
||||
className,
|
||||
)}
|
||||
style={{}}
|
||||
|
||||
@@ -13,13 +13,14 @@ import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
createdAt?: Date;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default function EventDetails({ id }: Props) {
|
||||
const { projectId } = useAppParams();
|
||||
export default function EventDetails({ id, createdAt, projectId }: Props) {
|
||||
const [, setEvents] = useEventQueryNamesFilter();
|
||||
const [, setFilter] = useEventQueryFilters();
|
||||
const query = api.event.byId.useQuery({ id, projectId });
|
||||
const query = api.event.byId.useQuery({ id, projectId, createdAt });
|
||||
|
||||
if (query.isLoading || query.isFetching) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { createPushModal } from 'pushmodal';
|
||||
|
||||
@@ -9,11 +9,23 @@ import { ModalContent } from './Modal/Container';
|
||||
|
||||
const Loading = () => (
|
||||
<ModalContent className="flex items-center justify-center p-16">
|
||||
<Loader className="animate-spin" size={40} />
|
||||
<Loader2Icon className="animate-spin" size={40} />
|
||||
</ModalContent>
|
||||
);
|
||||
|
||||
const modals = {
|
||||
OverviewTopPagesModal: dynamic(
|
||||
() => import('../components/overview/overview-top-pages-modal'),
|
||||
{
|
||||
loading: Loading,
|
||||
},
|
||||
),
|
||||
OverviewTopGenericModal: dynamic(
|
||||
() => import('../components/overview/overview-top-generic-modal'),
|
||||
{
|
||||
loading: Loading,
|
||||
},
|
||||
),
|
||||
RequestPasswordReset: dynamic(() => import('./request-reset-password'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
|
||||
@@ -8,16 +8,16 @@ export function getProfileName(
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!profile.isExternal) {
|
||||
if (short) {
|
||||
const name =
|
||||
[profile.firstName, profile.lastName].filter(Boolean).join(' ') ||
|
||||
profile.email;
|
||||
|
||||
if (!name) {
|
||||
if (short && profile.id.length > 10) {
|
||||
return `${profile.id.slice(0, 4)}...${profile.id.slice(-4)}`;
|
||||
}
|
||||
return profile.id;
|
||||
}
|
||||
|
||||
return (
|
||||
[profile.firstName, profile.lastName].filter(Boolean).join(' ') ||
|
||||
profile.email ||
|
||||
profile.id
|
||||
);
|
||||
return name;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user