feat: dashboard v2, esm, upgrades (#211)
* esm * wip * wip * wip * wip * wip * wip * subscription notice * wip * wip * wip * fix envs * fix: update docker build * fix * esm/types * delete dashboard :D * add patches to dockerfiles * update packages + catalogs + ts * wip * remove native libs * ts * improvements * fix redirects and fetching session * try fix favicon * fixes * fix * order and resize reportds within a dashboard * improvements * wip * added userjot to dashboard * fix * add op * wip * different cache key * improve date picker * fix table * event details loading * redo onboarding completely * fix login * fix * fix * extend session, billing and improve bars * fix * reduce price on 10M
This commit is contained in:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
74
apps/start/src/components/profiles/latest-events.tsx
Normal file
74
apps/start/src/components/profiles/latest-events.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget } from '@/components/widget';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { ActivityIcon } from 'lucide-react';
|
||||
import { EventsViewOptions, useEventsViewOptions } from '../events/table';
|
||||
import { EventItem } from '../events/table/item';
|
||||
import {
|
||||
WidgetAbsoluteButtons,
|
||||
WidgetHead,
|
||||
WidgetTitle,
|
||||
} from '../overview/overview-widget';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
type Props = {
|
||||
profileId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export const LatestEvents = ({
|
||||
profileId,
|
||||
projectId,
|
||||
organizationId,
|
||||
}: Props) => {
|
||||
const [viewOptions] = useEventsViewOptions();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const query = useQuery(
|
||||
trpc.event.events.queryOptions({
|
||||
projectId,
|
||||
profileId,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleShowMore = () => {
|
||||
router.navigate({
|
||||
to: '/$organizationId/$projectId/profiles/$profileId/events',
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget className="w-full overflow-hidden">
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={ActivityIcon}>Latest Events</WidgetTitle>
|
||||
<WidgetAbsoluteButtons>
|
||||
<Button variant="outline" size="sm" onClick={handleShowMore}>
|
||||
All
|
||||
</Button>
|
||||
<EventsViewOptions />
|
||||
</WidgetAbsoluteButtons>
|
||||
</WidgetHead>
|
||||
|
||||
<ScrollArea className="h-72">
|
||||
{query.data?.data?.map((event) => (
|
||||
<EventItem
|
||||
className="border-0 rounded-none border-b last:border-b-0 [&_[data-slot='inner']]:px-4"
|
||||
key={event.id}
|
||||
event={event}
|
||||
viewOptions={viewOptions}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
33
apps/start/src/components/profiles/most-events.tsx
Normal file
33
apps/start/src/components/profiles/most-events.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Widget } from '@/components/widget';
|
||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
data: { count: number; name: string }[];
|
||||
};
|
||||
|
||||
export const MostEvents = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((item) => item.count));
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Popular events</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.name} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.name}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
33
apps/start/src/components/profiles/popular-routes.tsx
Normal file
33
apps/start/src/components/profiles/popular-routes.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Widget } from '@/components/widget';
|
||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
data: { count: number; path: string }[];
|
||||
};
|
||||
|
||||
export const PopularRoutes = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((item) => item.count));
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Most visted pages</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.path} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.path}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
92
apps/start/src/components/profiles/profile-activity.tsx
Normal file
92
apps/start/src/components/profiles/profile-activity.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
addMonths,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
format,
|
||||
formatISO,
|
||||
isSameMonth,
|
||||
startOfMonth,
|
||||
subMonths,
|
||||
} from 'date-fns';
|
||||
import { ActivityIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
WidgetAbsoluteButtons,
|
||||
WidgetHead,
|
||||
WidgetTitle,
|
||||
} from '../overview/overview-widget';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type Props = {
|
||||
data: { count: number; date: string }[];
|
||||
};
|
||||
|
||||
const MonthCalendar = ({
|
||||
month,
|
||||
data,
|
||||
}: { month: Date; data: Props['data'] }) => (
|
||||
<div>
|
||||
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
|
||||
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
||||
{eachDayOfInterval({
|
||||
start: startOfMonth(month),
|
||||
end: endOfMonth(month),
|
||||
}).map((date) => {
|
||||
const hit = data.find((item) =>
|
||||
item.date.includes(formatISO(date, { representation: 'date' })),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className={cn(
|
||||
'aspect-square w-full rounded',
|
||||
hit ? 'bg-highlight' : 'bg-def-200',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ProfileActivity = ({ data }: Props) => {
|
||||
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="row justify-between relative">
|
||||
<WidgetTitle icon={ActivityIcon}>Activity</WidgetTitle>
|
||||
<WidgetAbsoluteButtons>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setStartDate(subMonths(startDate, 1))}
|
||||
>
|
||||
<ChevronLeftIcon size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={isSameMonth(startDate, new Date())}
|
||||
onClick={() => setStartDate(addMonths(startDate, 1))}
|
||||
>
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
</WidgetAbsoluteButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{[3, 2, 1, 0].map((offset) => (
|
||||
<MonthCalendar
|
||||
key={offset}
|
||||
month={subMonths(startDate, offset)}
|
||||
data={data}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
59
apps/start/src/components/profiles/profile-avatar.tsx
Normal file
59
apps/start/src/components/profiles/profile-avatar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AvatarImage } from '@radix-ui/react-avatar';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { type GetProfileNameProps, getProfileName } from '@/utils/getters';
|
||||
import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||
|
||||
interface ProfileAvatarProps
|
||||
extends VariantProps<typeof variants>,
|
||||
GetProfileNameProps {
|
||||
className?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
const variants = cva('shrink-0', {
|
||||
variants: {
|
||||
size: {
|
||||
lg: 'h-14 w-14 rounded [&>span]:rounded',
|
||||
default: 'h-8 w-8 rounded [&>span]:rounded',
|
||||
sm: 'h-6 w-6 rounded [&>span]:rounded',
|
||||
xs: 'h-4 w-4 rounded [&>span]:rounded',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
export function ProfileAvatar({
|
||||
avatar,
|
||||
className,
|
||||
size,
|
||||
...profile
|
||||
}: ProfileAvatarProps) {
|
||||
const name = getProfileName(profile);
|
||||
const isValidAvatar = avatar?.startsWith('http');
|
||||
|
||||
return (
|
||||
<Avatar className={cn(variants({ className, size }), className)}>
|
||||
{isValidAvatar && <AvatarImage src={avatar} className="rounded-full" />}
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
size === 'lg'
|
||||
? 'text-lg'
|
||||
: size === 'sm'
|
||||
? 'text-sm'
|
||||
: size === 'xs'
|
||||
? 'text-[8px]'
|
||||
: 'text-base',
|
||||
'bg-def-200 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{name?.at(0)?.toUpperCase() ?? '🧔♂️'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
105
apps/start/src/components/profiles/profile-charts.tsx
Normal file
105
apps/start/src/components/profiles/profile-charts.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import { WidgetHead } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
profileId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ProfileCharts = memo(
|
||||
({ profileId, projectId }: Props) => {
|
||||
const pageViewsChart: IChartProps = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: 'screen_view',
|
||||
displayName: 'Events',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'path',
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: 'Events',
|
||||
range: '30d',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
const eventsChart: IChartProps = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'Events',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'name',
|
||||
name: 'name',
|
||||
},
|
||||
],
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: 'Events',
|
||||
range: '30d',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<span className="title">Page views</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ReportChart report={pageViewsChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ReportChart report={eventsChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
},
|
||||
(a, b) => {
|
||||
return a.profileId === b.profileId && a.projectId === b.projectId;
|
||||
},
|
||||
);
|
||||
110
apps/start/src/components/profiles/profile-metrics.tsx
Normal file
110
apps/start/src/components/profiles/profile-metrics.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||
|
||||
import type { IProfileMetrics } from '@openpanel/db';
|
||||
|
||||
type Props = {
|
||||
data: IProfileMetrics;
|
||||
};
|
||||
|
||||
const PROFILE_METRICS = [
|
||||
{
|
||||
title: 'Total Events',
|
||||
key: 'totalEvents',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Sessions',
|
||||
key: 'sessions',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Page Views',
|
||||
key: 'screenViews',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Avg Events/Session',
|
||||
key: 'avgEventsPerSession',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Bounce Rate',
|
||||
key: 'bounceRate',
|
||||
unit: '%',
|
||||
inverted: true,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration (Avg)',
|
||||
key: 'durationAvg',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration (P90)',
|
||||
key: 'durationP90',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'First seen',
|
||||
key: 'firstSeen',
|
||||
unit: 'timeAgo',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Last seen',
|
||||
key: 'lastSeen',
|
||||
unit: 'timeAgo',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Days Active',
|
||||
key: 'uniqueDaysActive',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversion Events',
|
||||
key: 'conversionEvents',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Avg Time Between Sessions (h)',
|
||||
key: 'avgTimeBetweenSessions',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const ProfileMetrics = ({ data }: Props) => {
|
||||
return (
|
||||
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
||||
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
|
||||
{PROFILE_METRICS.map((metric) => (
|
||||
<OverviewMetricCard
|
||||
key={metric.key}
|
||||
id={metric.key}
|
||||
label={metric.title}
|
||||
metric={{
|
||||
current:
|
||||
metric.unit === 'timeAgo' &&
|
||||
typeof data[metric.key] === 'string'
|
||||
? new Date(data[metric.key] as string).getTime()
|
||||
: (data[metric.key] as number) || 0,
|
||||
previous: null, // Profile metrics don't have previous period comparison
|
||||
}}
|
||||
unit={metric.unit}
|
||||
data={[]}
|
||||
inverted={metric.inverted}
|
||||
isLoading={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
114
apps/start/src/components/profiles/profile-properties.tsx
Normal file
114
apps/start/src/components/profiles/profile-properties.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import { Widget } from '@/components/widget';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
import type { IServiceEvent, IServiceProfile } from '@openpanel/db';
|
||||
import { WidgetButtons, WidgetHead } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
profile: IServiceProfile;
|
||||
};
|
||||
|
||||
export const ProfileProperties = ({ profile }: Props) => {
|
||||
const [tab, setTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum(['profile', 'properties']).withDefault('profile'),
|
||||
);
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<div className="title">Profile Information</div>
|
||||
<WidgetButtons>
|
||||
{[
|
||||
{
|
||||
key: 'profile',
|
||||
btn: 'Profile',
|
||||
},
|
||||
{
|
||||
key: 'properties',
|
||||
btn: 'Properties',
|
||||
},
|
||||
].map((w) => (
|
||||
<button
|
||||
type="button"
|
||||
key={w.key}
|
||||
onClick={() => setTab(w.key as 'profile' | 'properties')}
|
||||
className={cn(w.key === tab && 'active')}
|
||||
>
|
||||
{w.btn}
|
||||
</button>
|
||||
))}
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
|
||||
{tab === 'profile' && (
|
||||
<KeyValueGrid
|
||||
copyable
|
||||
className="border-0"
|
||||
columns={3}
|
||||
data={[
|
||||
{ name: 'id', value: profile.id },
|
||||
{ name: 'firstName', value: profile.firstName },
|
||||
{ name: 'lastName', value: profile.lastName },
|
||||
{ name: 'email', value: profile.email },
|
||||
{ name: 'isExternal', value: profile.isExternal ? 'Yes' : 'No' },
|
||||
{
|
||||
name: 'createdAt',
|
||||
value: formatDateTime(new Date(profile.createdAt)),
|
||||
},
|
||||
...(profile.properties.country
|
||||
? [{ name: 'country', value: profile.properties.country }]
|
||||
: []),
|
||||
...(profile.properties.city
|
||||
? [{ name: 'city', value: profile.properties.city }]
|
||||
: []),
|
||||
...(profile.properties.os
|
||||
? [{ name: 'os', value: profile.properties.os }]
|
||||
: []),
|
||||
...(profile.properties.browser
|
||||
? [{ name: 'browser', value: profile.properties.browser }]
|
||||
: []),
|
||||
...(profile.properties.device
|
||||
? [{ name: 'device', value: profile.properties.device }]
|
||||
: []),
|
||||
...(profile.properties.referrer_name
|
||||
? [
|
||||
{
|
||||
name: 'referrerName',
|
||||
value: profile.properties.referrer_name,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map((item) => ({
|
||||
...item,
|
||||
event: {
|
||||
...profile,
|
||||
...profile.properties,
|
||||
} as unknown as IServiceEvent,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'properties' && (
|
||||
<KeyValueGrid
|
||||
copyable
|
||||
className="border-0"
|
||||
columns={3}
|
||||
data={Object.entries(profile.properties)
|
||||
.filter(([, value]) => value !== undefined && value !== '')
|
||||
.map(([key, value]) => ({
|
||||
name: key,
|
||||
value: value,
|
||||
event: {
|
||||
...profile,
|
||||
...profile.properties,
|
||||
} as unknown as IServiceEvent,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
131
apps/start/src/components/profiles/table/columns.tsx
Normal file
131
apps/start/src/components/profiles/table/columns.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { formatDateTime, formatTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { isToday } from 'date-fns';
|
||||
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
|
||||
import { ProfileAvatar } from '../profile-avatar';
|
||||
|
||||
export function useColumns(type: 'profiles' | 'power-users') {
|
||||
const columns: ColumnDef<IServiceProfile>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${profile.id}`}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
title={getProfileName(profile, false)}
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{getProfileName(profile)}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'referrer',
|
||||
header: 'Referrer',
|
||||
cell({ row }) {
|
||||
const { referrer, referrer_name } = row.original.properties;
|
||||
const ref = referrer_name || referrer;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={ref} />
|
||||
<span className="truncate">{ref}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: 'Country',
|
||||
cell({ row }) {
|
||||
const { country, city } = row.original.properties;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={country} />
|
||||
<span className="truncate">{city}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'os',
|
||||
header: 'OS',
|
||||
cell({ row }) {
|
||||
const { os } = row.original.properties;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={os} />
|
||||
<span className="truncate">{os}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'browser',
|
||||
header: 'Browser',
|
||||
cell({ row }) {
|
||||
const { browser } = row.original.properties;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={browser} />
|
||||
<span className="truncate">{browser}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'model',
|
||||
header: 'Model',
|
||||
cell({ row }) {
|
||||
const { model, brand } = row.original.properties;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={brand} />
|
||||
<span className="truncate">
|
||||
{brand} / {model}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Last seen',
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
return (
|
||||
<Tooltiper asChild content={formatDateTime(profile.createdAt)}>
|
||||
<div className="text-muted-foreground">
|
||||
{isToday(profile.createdAt)
|
||||
? formatTime(profile.createdAt)
|
||||
: formatDateTime(profile.createdAt)}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (type === 'power-users') {
|
||||
columns.unshift({
|
||||
accessorKey: 'count',
|
||||
header: 'Events',
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
// @ts-expect-error
|
||||
return <div>{profile.count}</div>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
94
apps/start/src/components/profiles/table/index.tsx
Normal file
94
apps/start/src/components/profiles/table/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useColumns } from './columns';
|
||||
|
||||
import { DataTable } from '@/components/ui/data-table/data-table';
|
||||
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
||||
import {
|
||||
AnimatedSearchInput,
|
||||
DataTableToolbarContainer,
|
||||
} from '@/components/ui/data-table/data-table-toolbar';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { arePropsEqual } from '@/utils/are-props-equal';
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { memo } from 'react';
|
||||
|
||||
type Props = {
|
||||
query: UseQueryResult<RouterOutputs['profile']['list'], unknown>;
|
||||
type: 'profiles' | 'power-users';
|
||||
};
|
||||
|
||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceProfile[];
|
||||
|
||||
export const ProfilesTable = memo(
|
||||
({ type, query }: Props) => {
|
||||
const { data, isLoading } = query;
|
||||
const columns = useColumns(type);
|
||||
|
||||
const { setPage, state: pagination } = useDataTablePagination();
|
||||
const { columnVisibility, setColumnVisibility } =
|
||||
useDataTableColumnVisibility(columns);
|
||||
|
||||
const table = useReactTable({
|
||||
data: isLoading ? LOADING_DATA : (data?.data ?? []),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
manualSorting: true,
|
||||
columns,
|
||||
rowCount: data?.meta.count,
|
||||
pageCount: Math.ceil(
|
||||
(data?.meta.count || 0) / (pagination.pageSize || 1),
|
||||
),
|
||||
filterFns: {
|
||||
isWithinRange: () => true,
|
||||
},
|
||||
state: {
|
||||
pagination,
|
||||
columnVisibility,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
|
||||
const nextPagination =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(pagination)
|
||||
: updaterOrValue;
|
||||
setPage(nextPagination.pageIndex + 1);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileTableToolbar table={table} />
|
||||
<DataTable
|
||||
table={table}
|
||||
loading={isLoading}
|
||||
empty={{
|
||||
title: 'No profiles',
|
||||
description: "Looks like you haven't identified any profiles yet.",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'type']),
|
||||
);
|
||||
|
||||
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
||||
const { search, setSearch } = useSearchQueryState();
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<AnimatedSearchInput
|
||||
placeholder="Search profiles"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user