wip
This commit is contained in:
@@ -38,9 +38,10 @@
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
"@openpanel/json": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/sdk": "^1.0.8",
|
||||
"@openpanel/sdk-info": "workspace:^",
|
||||
"@openpanel/validation": "workspace:^",
|
||||
"@openpanel/web": "^1.0.1",
|
||||
"@openpanel/web": "^1.0.12",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
@@ -116,7 +117,6 @@
|
||||
"pushmodal": "^1.0.3",
|
||||
"ramda": "^0.29.1",
|
||||
"random-animal-name": "^0.1.1",
|
||||
"rrweb-player": "2.0.0-alpha.20",
|
||||
"rc-virtual-list": "^3.14.5",
|
||||
"react": "catalog:",
|
||||
"react-animate-height": "^3.2.3",
|
||||
@@ -142,6 +142,7 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"rrweb-player": "2.0.0-alpha.20",
|
||||
"short-unique-id": "^5.0.3",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.4.0",
|
||||
|
||||
@@ -69,11 +69,11 @@ export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
|
||||
className="h-full"
|
||||
>
|
||||
<ScrollArea className="flex-1 min-h-0" ref={viewportRef}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex w-full flex-col">
|
||||
{visibleEvents.map(({ event, offsetMs }) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-3 duration-300 fill-mode-both"
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-3 min-w-0 duration-300 fill-mode-both"
|
||||
>
|
||||
<ReplayEventItem
|
||||
event={event}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
function formatOffset(ms: number): string {
|
||||
const sign = ms < 0 ? '-' : '+';
|
||||
const abs = Math.abs(ms);
|
||||
const totalSeconds = Math.floor(abs / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${sign}${m}:${s.toString().padStart(2, '0')}`;
|
||||
function formatTime(date: Date | string): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const m = d.getMinutes().toString().padStart(2, '0');
|
||||
const s = d.getSeconds().toString().padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
export function ReplayEventItem({
|
||||
event,
|
||||
offsetMs,
|
||||
isCurrent,
|
||||
onClick,
|
||||
}: {
|
||||
@@ -48,7 +44,7 @@ export function ReplayEventItem({
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{formatOffset(offsetMs)}
|
||||
{formatTime(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -48,7 +48,7 @@ function ScrollArea({
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!block"
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useLocation } from '@tanstack/react-router';
|
||||
|
||||
export function usePageTabs(tabs: { id: string; label: string }[]) {
|
||||
const location = useLocation();
|
||||
const tab = location.pathname.split('/').pop();
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
const tab = segments[segments.length - 1];
|
||||
|
||||
if (!tab) {
|
||||
return {
|
||||
|
||||
@@ -83,6 +83,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from '.
|
||||
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
|
||||
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
||||
|
||||
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
||||
@@ -557,6 +558,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
|
||||
path: '/',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
|
||||
id: '/sessions',
|
||||
path: '/sessions',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({
|
||||
id: '/events',
|
||||
@@ -633,6 +640,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -695,6 +703,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -778,6 +787,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -851,6 +861,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/profiles/'
|
||||
| '/$organizationId/$projectId/settings/'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -913,6 +924,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/settings/imports'
|
||||
| '/$organizationId/$projectId/settings/widgets'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -995,6 +1007,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -1578,6 +1591,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
|
||||
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||
path: '/sessions'
|
||||
fullPath: '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': {
|
||||
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
||||
path: '/events'
|
||||
@@ -1689,6 +1709,7 @@ const AppOrganizationIdProjectIdProfilesTabsRouteWithChildren =
|
||||
|
||||
interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren {
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||
}
|
||||
|
||||
@@ -1696,6 +1717,8 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren: AppOrganizat
|
||||
{
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute:
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute,
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute:
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute,
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute:
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SessionsTable } from '@/components/sessions/table';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, profileId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { debouncedSearch } = useSearchQueryState();
|
||||
|
||||
const query = useInfiniteQuery(
|
||||
trpc.session.list.infiniteQueryOptions(
|
||||
{
|
||||
projectId,
|
||||
profileId,
|
||||
take: 50,
|
||||
search: debouncedSearch,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return <SessionsTable query={query} />;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ function Component() {
|
||||
label: 'Overview',
|
||||
},
|
||||
{ id: 'events', label: 'Events' },
|
||||
{ id: 'sessions', label: 'Sessions' },
|
||||
]);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { EventsTable } from '@/components/events/table';
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { ReplayShell } from '@/components/sessions/replay';
|
||||
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/use-event-query-filters';
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import { Widget, WidgetBody, WidgetHead, WidgetTitle } from '@/components/widget';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Link, createFileRoute } from '@tanstack/react-router';
|
||||
import type { IServiceEvent, IServiceSession } from '@openpanel/db';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/sessions_/$sessionId',
|
||||
@@ -27,101 +28,353 @@ export const Route = createFileRoute(
|
||||
projectId: params.projectId,
|
||||
}),
|
||||
),
|
||||
context.queryClient.prefetchQuery(
|
||||
context.trpc.event.events.queryOptions({
|
||||
projectId: params.projectId,
|
||||
sessionId: params.sessionId,
|
||||
filters: [],
|
||||
columnVisibility: {},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
},
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle('Sessions'),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('Session') }],
|
||||
}),
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, sessionId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
function sessionToFakeEvent(session: IServiceSession): IServiceEvent {
|
||||
return session as unknown as IServiceEvent;
|
||||
}
|
||||
|
||||
const LIMIT = 50;
|
||||
function VisitedRoutes({ paths }: { paths: string[] }) {
|
||||
const counted = paths.reduce<Record<string, number>>((acc, p) => {
|
||||
acc[p] = (acc[p] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
|
||||
const max = sorted[0]?.[1] ?? 1;
|
||||
|
||||
if (sorted.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Visited pages</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{sorted.map(([path, count]) => (
|
||||
<div key={path} className="relative px-3 py-2 group">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200 group-hover:bg-def-300"
|
||||
style={{ width: `${(count / max) * 100}%` }}
|
||||
/>
|
||||
<div className="relative flex justify-between gap-2 min-w-0">
|
||||
<span className="truncate text-sm">{path}</span>
|
||||
<span className="shrink-0 text-sm font-medium">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDistribution({ events }: { events: IServiceEvent[] }) {
|
||||
const counted = events.reduce<Record<string, number>>((acc, e) => {
|
||||
acc[e.name] = (acc[e.name] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
|
||||
const max = sorted[0]?.[1] ?? 1;
|
||||
|
||||
if (sorted.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Event distribution</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{sorted.map(([name, count]) => (
|
||||
<div key={name} className="relative px-3 py-2 group">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200 group-hover:bg-def-300"
|
||||
style={{ width: `${(count / max) * 100}%` }}
|
||||
/>
|
||||
<div className="relative flex justify-between gap-2">
|
||||
<span className="text-sm">{name.replace(/_/g, ' ')}</span>
|
||||
<span className="shrink-0 text-sm font-medium">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const { projectId, sessionId, organizationId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const number = useNumber()
|
||||
|
||||
const { data: session } = useSuspenseQuery(
|
||||
trpc.session.byId.queryOptions({
|
||||
trpc.session.byId.queryOptions({ sessionId, projectId }),
|
||||
);
|
||||
|
||||
const { data: eventsData } = useSuspenseQuery(
|
||||
trpc.event.events.queryOptions({
|
||||
projectId,
|
||||
sessionId,
|
||||
filters: [],
|
||||
columnVisibility: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const events = eventsData?.data ?? [];
|
||||
|
||||
const isIdentified =
|
||||
session.profileId && session.profileId !== session.deviceId;
|
||||
|
||||
const { data: profile } = useSuspenseQuery(
|
||||
trpc.profile.byId.queryOptions({
|
||||
profileId: session.profileId,
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
||||
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||
const [eventNames] = useEventQueryNamesFilter();
|
||||
const columnVisibility = useReadColumnVisibility('events');
|
||||
const query = useInfiniteQuery(
|
||||
trpc.event.events.infiniteQueryOptions(
|
||||
{
|
||||
projectId,
|
||||
sessionId,
|
||||
filters,
|
||||
events: eventNames,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
columnVisibility: columnVisibility ?? {},
|
||||
},
|
||||
{
|
||||
enabled: columnVisibility !== null,
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
},
|
||||
),
|
||||
);
|
||||
const fakeEvent = sessionToFakeEvent(session);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageContainer className="col gap-8">
|
||||
<PageHeader
|
||||
title={`Session: ${session.id.slice(0, 4)}...${session.id.slice(-4)}`}
|
||||
title={`Session: ${session.id}`}
|
||||
>
|
||||
<div className="row gap-4 mb-6">
|
||||
{session.country && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.country} />
|
||||
<span>
|
||||
{session.country}
|
||||
{session.city && ` / ${session.city}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{session.device && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.device} />
|
||||
<span className="capitalize">{session.device}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.os && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.os} />
|
||||
<span>{session.os}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.model && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.model} />
|
||||
<span>{session.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.country && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.country} />
|
||||
<span>
|
||||
{session.country}
|
||||
{session.city && ` / ${session.city}`}
|
||||
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{session.device && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.device} />
|
||||
<span className="capitalize">{session.device}</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
)}
|
||||
{session.os && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.os} />
|
||||
<span>{session.os}</span>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
{session.model && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.model} />
|
||||
<span>{session.model}</span>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
{session.browser && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={session.browser} />
|
||||
<span>{session.browser}</span>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div className="mb-6">
|
||||
<ReplayShell sessionId={sessionId} projectId={projectId} />
|
||||
|
||||
{session.hasReplay && <ReplayShell sessionId={sessionId} projectId={projectId} />}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
|
||||
{/* Left column */}
|
||||
<div className="col gap-6">
|
||||
{/* Session info */}
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Session info</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<KeyValueGrid
|
||||
className="border-0"
|
||||
columns={1}
|
||||
copyable
|
||||
data={[
|
||||
{ name: 'duration', value: number.formatWithUnit(session.duration/1000, 'min') },
|
||||
{ name: 'createdAt', value: session.createdAt },
|
||||
{ name: 'endedAt', value: session.endedAt },
|
||||
{ name: 'screenViews', value: session.screenViewCount },
|
||||
{ name: 'events', value: session.eventCount },
|
||||
{ name: 'bounce', value: session.isBounce ? 'Yes' : 'No' },
|
||||
...(session.entryPath
|
||||
? [{ name: 'entryPath', value: session.entryPath }]
|
||||
: []),
|
||||
...(session.exitPath
|
||||
? [{ name: 'exitPath', value: session.exitPath }]
|
||||
: []),
|
||||
...(session.referrerName
|
||||
? [{ name: 'referrerName', value: session.referrerName }]
|
||||
: []),
|
||||
...(session.referrer
|
||||
? [{ name: 'referrer', value: session.referrer }]
|
||||
: []),
|
||||
...(session.utmSource
|
||||
? [{ name: 'utmSource', value: session.utmSource }]
|
||||
: []),
|
||||
...(session.utmMedium
|
||||
? [{ name: 'utmMedium', value: session.utmMedium }]
|
||||
: []),
|
||||
...(session.utmCampaign
|
||||
? [{ name: 'utmCampaign', value: session.utmCampaign }]
|
||||
: []),
|
||||
...(session.revenue > 0
|
||||
? [{ name: 'revenue', value: `$${session.revenue}` }]
|
||||
: []),
|
||||
{ name: 'country', value: session.country, event: fakeEvent },
|
||||
...(session.city
|
||||
? [{ name: 'city', value: session.city, event: fakeEvent }]
|
||||
: []),
|
||||
...(session.os
|
||||
? [{ name: 'os', value: session.os, event: fakeEvent }]
|
||||
: []),
|
||||
...(session.browser
|
||||
? [
|
||||
{
|
||||
name: 'browser',
|
||||
value: session.browser,
|
||||
event: fakeEvent,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(session.device
|
||||
? [
|
||||
{
|
||||
name: 'device',
|
||||
value: session.device,
|
||||
event: fakeEvent,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(session.brand
|
||||
? [{ name: 'brand', value: session.brand, event: fakeEvent }]
|
||||
: []),
|
||||
...(session.model
|
||||
? [{ name: 'model', value: session.model, event: fakeEvent }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Widget>
|
||||
|
||||
{/* Profile card */}
|
||||
{isIdentified && profile && (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Profile</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
<Link
|
||||
to="/$organizationId/$projectId/profiles/$profileId"
|
||||
params={{
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: session.profileId,
|
||||
}}
|
||||
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
|
||||
>
|
||||
<ProfileAvatar {...profile} size="lg" />
|
||||
<div className="col min-w-0 gap-0.5">
|
||||
<span className="truncate font-medium">
|
||||
{getProfileName(profile, false) ?? session.profileId}
|
||||
</span>
|
||||
{profile.email && (
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{profile.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
{/* Visited pages */}
|
||||
<VisitedRoutes
|
||||
paths={events
|
||||
.filter((e) => e.name === 'screen_view' && e.path)
|
||||
.map((e) => e.path)}
|
||||
/>
|
||||
|
||||
{/* Event distribution */}
|
||||
<EventDistribution events={events} />
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="col gap-6">
|
||||
{/* Events list */}
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Events</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="divide-y">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="row items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<EventIcon name={event.name} meta={event.meta} size="sm" />
|
||||
<div className="col min-w-0 flex-1">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{event.name === 'screen_view' && event.path
|
||||
? event.path
|
||||
: event.name.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{formatDateTime(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
<EventsTable query={query} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,14 @@ import { OpenPanel } from '@openpanel/web';
|
||||
|
||||
const clientId = import.meta.env.VITE_OP_CLIENT_ID;
|
||||
|
||||
const createOpInstance = () => {
|
||||
if (!clientId || clientId === 'undefined') {
|
||||
return new Proxy({} as OpenPanel, {
|
||||
get: () => () => {},
|
||||
});
|
||||
}
|
||||
|
||||
return new OpenPanel({
|
||||
clientId,
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const op = createOpInstance();
|
||||
export const op = new OpenPanel({
|
||||
clientId,
|
||||
disabled: clientId === 'undefined' || !clientId,
|
||||
// apiUrl: 'http://localhost:3333',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
// sessionReplay: {
|
||||
// enabled: true,
|
||||
// }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user