update profile page
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
import { chQuery } from '@openpanel/db';
|
||||
|
||||
import MostEvents from './most-events';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
const MostEventsServer = async ({ projectId, profileId }: Props) => {
|
||||
const data = await chQuery<{ count: number; name: string }>(
|
||||
`SELECT count(*) as count, name FROM events WHERE name NOT IN ('screen_view', 'session_start', 'session_end') AND project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY name ORDER BY count DESC`
|
||||
);
|
||||
return <MostEvents data={data} />;
|
||||
};
|
||||
|
||||
export default withLoadingWidget(MostEventsServer);
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Widget, WidgetHead, WidgetTitle } from '@/components/widget';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
data: { count: number; name: string }[];
|
||||
};
|
||||
|
||||
const MostEvents = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((item) => item.count));
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={BellIcon}>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-slate-100"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
<div className="relative flex justify-between text-sm">
|
||||
<div>{item.name}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default MostEvents;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
@@ -19,11 +21,16 @@ import {
|
||||
getEventList,
|
||||
getEventsCount,
|
||||
getProfileById,
|
||||
getProfileMetrics,
|
||||
} from '@openpanel/db';
|
||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||
|
||||
import { EventList } from '../../events/event-list';
|
||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
||||
import MostEventsServer from './most-events';
|
||||
import PopularRoutesServer from './popular-routes';
|
||||
import ProfileActivityServer from './profile-activity';
|
||||
import ProfileMetrics from './profile-metrics';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -57,60 +64,75 @@ export default async function Page({
|
||||
};
|
||||
const startDate = parseAsString.parseServerSide(searchParams.startDate);
|
||||
const endDate = parseAsString.parseServerSide(searchParams.endDate);
|
||||
const [profile, events, count, conversions] = await Promise.all([
|
||||
const [profile, events, count, metrics] = await Promise.all([
|
||||
getProfileById(profileId, projectId),
|
||||
getEventList(eventListOptions),
|
||||
getEventsCount(eventListOptions),
|
||||
getConversionEventNames(projectId),
|
||||
getProfileMetrics(profileId, projectId),
|
||||
]);
|
||||
|
||||
const chartSelectedEvents: IChartEvent[] = [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'Events',
|
||||
},
|
||||
];
|
||||
|
||||
if (conversions.length) {
|
||||
chartSelectedEvents.push({
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: conversions.map((c) => c.name),
|
||||
},
|
||||
],
|
||||
id: 'B',
|
||||
name: '*',
|
||||
displayName: 'Conversions',
|
||||
});
|
||||
}
|
||||
|
||||
const profileChart: IChartInput = {
|
||||
const pageViewsChart: IChartInput = {
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
chartType: 'histogram',
|
||||
events: chartSelectedEvents,
|
||||
breakdowns: [],
|
||||
chartType: 'linear',
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'Events',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'path',
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: 'Events',
|
||||
range: '30d',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
const eventsChart: IChartInput = {
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
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',
|
||||
@@ -125,69 +147,54 @@ export default async function Page({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
organizationSlug={organizationSlug}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
|
||||
{getProfileName(profile)}
|
||||
<PageLayout organizationSlug={organizationSlug} title={<div />} />
|
||||
<StickyBelowHeader className="!relative !top-auto !z-0 flex items-center gap-8 p-8">
|
||||
<div className="flex flex-1 gap-4">
|
||||
<ProfileAvatar {...profile} size={'lg'} />
|
||||
<div className="">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{getProfileName(profile)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<ListPropertiesIcon {...profile.properties} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* <StickyBelowHeader className="flex justify-between p-4">
|
||||
<OverviewFiltersDrawer
|
||||
projectId={projectId}
|
||||
mode="events"
|
||||
nuqsOptions={{ shallow: false }}
|
||||
/>
|
||||
<OverviewFiltersButtons
|
||||
nuqsOptions={{ shallow: false }}
|
||||
className="justify-end p-0"
|
||||
/>
|
||||
</StickyBelowHeader> */}
|
||||
</div>
|
||||
<ProfileMetrics profileId={profileId} projectId={projectId} />
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<EventList data={events} count={count} />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-6">
|
||||
<div className="col-span-2">
|
||||
<ProfileActivityServer
|
||||
profileId={profileId}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex gap-2">
|
||||
<ChartSwitch {...profileChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Profile</span>
|
||||
<ProfileAvatar {...profile} />
|
||||
</WidgetHead>
|
||||
<div className="grid grid-cols-1 text-sm">
|
||||
<ValueRow name={'ID'} value={profile.id} />
|
||||
<ValueRow name={'First name'} value={profile.firstName} />
|
||||
<ValueRow name={'Last name'} value={profile.lastName} />
|
||||
<ValueRow name={'Mail'} value={profile.email} />
|
||||
<ValueRow
|
||||
name={'Last seen'}
|
||||
value={profile.createdAt.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</Widget>
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Properties</span>
|
||||
</WidgetHead>
|
||||
<div className="grid grid-cols-1 text-sm">
|
||||
{Object.entries(profile.properties)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => (
|
||||
<ValueRow key={key} name={key} value={value} />
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
<div className="col-span-2">
|
||||
<MostEventsServer profileId={profileId} projectId={projectId} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<PopularRoutesServer profileId={profileId} projectId={projectId} />
|
||||
</div>
|
||||
<Widget className="col-span-3 w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Page views</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex gap-2">
|
||||
<ChartSwitch {...pageViewsChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget className="col-span-3 w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex gap-2">
|
||||
<ChartSwitch {...eventsChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<EventList data={events} count={count} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -199,7 +206,7 @@ function ValueRow({ name, value }: { name: string; value?: unknown }) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-row justify-between p-2 px-4">
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="font-medium capitalize text-muted-foreground">
|
||||
{name.replace('_', ' ')}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
import { chQuery } from '@openpanel/db';
|
||||
|
||||
import PopularRoutes from './popular-routes';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
const PopularRoutesServer = async ({ projectId, profileId }: Props) => {
|
||||
const data = await chQuery<{ count: number; path: string }>(
|
||||
`SELECT count(*) as count, path FROM events WHERE name = 'screen_view' AND project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY path ORDER BY count DESC`
|
||||
);
|
||||
return <PopularRoutes data={data} />;
|
||||
};
|
||||
|
||||
export default withLoadingWidget(PopularRoutesServer);
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Widget, WidgetHead, WidgetTitle } from '@/components/widget';
|
||||
import { BellIcon, MonitorPlayIcon } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
data: { count: number; path: string }[];
|
||||
};
|
||||
|
||||
const PopularRoutes = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((item) => item.count));
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={MonitorPlayIcon}>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-slate-100"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
<div className="relative flex justify-between text-sm">
|
||||
<div>{item.path}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularRoutes;
|
||||
@@ -0,0 +1,20 @@
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
import { chQuery } from '@openpanel/db';
|
||||
|
||||
import ProfileActivity from './profile-activity';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
const ProfileActivityServer = async ({ projectId, profileId }: Props) => {
|
||||
const data = await chQuery<{ count: number; date: string }>(
|
||||
`SELECT count(*) as count, toStartOfDay(created_at) as date FROM events WHERE project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY date ORDER BY date DESC`
|
||||
);
|
||||
return <ProfileActivity data={data} />;
|
||||
};
|
||||
|
||||
export default withLoadingWidget(ProfileActivityServer);
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Widget,
|
||||
WidgetBody,
|
||||
WidgetHead,
|
||||
WidgetTitle,
|
||||
} from '@/components/widget';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
addMonths,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
format,
|
||||
startOfMonth,
|
||||
subMonths,
|
||||
} from 'date-fns';
|
||||
import { ActivityIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
data: { count: number; date: string }[];
|
||||
};
|
||||
|
||||
const ProfileActivity = ({ data }: Props) => {
|
||||
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
||||
const endDate = endOfMonth(startDate);
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex justify-between">
|
||||
<WidgetTitle icon={ActivityIcon}>Activity</WidgetTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setStartDate(subMonths(startDate, 1))}
|
||||
>
|
||||
<ChevronLeftIcon size={14} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setStartDate(addMonths(startDate, 1))}
|
||||
>
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<div className="p-1 text-xs">
|
||||
{format(subMonths(startDate, 1), 'MMMM yyyy')}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 p-1">
|
||||
{eachDayOfInterval({
|
||||
start: startOfMonth(subMonths(startDate, 1)),
|
||||
end: endOfMonth(subMonths(startDate, 1)),
|
||||
}).map((date) => {
|
||||
const hit = data.find((item) =>
|
||||
item.date.includes(date.toISOString().split('T')[0])
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className={cn(
|
||||
'aspect-square w-full rounded',
|
||||
hit ? 'bg-blue-600' : 'bg-slate-100'
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="p-1 text-xs">{format(startDate, 'MMMM yyyy')}</div>
|
||||
<div className="grid grid-cols-7 gap-1 p-1">
|
||||
{eachDayOfInterval({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
}).map((date) => {
|
||||
const hit = data.find((item) =>
|
||||
item.date.includes(date.toISOString().split('T')[0])
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className={cn(
|
||||
'aspect-square w-full rounded',
|
||||
hit ? 'bg-blue-600' : 'bg-slate-100'
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileActivity;
|
||||
@@ -0,0 +1,17 @@
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
|
||||
import { getProfileMetrics } from '@openpanel/db';
|
||||
|
||||
import ProfileMetrics from './profile-metrics';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
const ProfileMetricsServer = async ({ projectId, profileId }: Props) => {
|
||||
const data = await getProfileMetrics(profileId, projectId);
|
||||
return <ProfileMetrics data={data} />;
|
||||
};
|
||||
|
||||
export default withLoadingWidget(ProfileMetricsServer);
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import type { IProfileMetrics } from '@openpanel/db';
|
||||
|
||||
type Props = {
|
||||
data: IProfileMetrics;
|
||||
};
|
||||
|
||||
const ProfileMetrics = ({ data }: Props) => {
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="rounded-xl text-right">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
First seen
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
{formatDistanceToNow(data.firstSeen)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl text-right">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Last seen
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
{formatDistanceToNow(data.lastSeen)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl text-right">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Sessions
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
{number.format(data.sessions)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl text-right">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Avg. Session
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
{number.formatWithUnit(data.durationAvg / 1000, 'min')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl text-right">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
P90. Session
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
{number.formatWithUnit(data.durationP90 / 1000, 'min')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl text-right">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Page views
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
{number.format(data.screenViews)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileMetrics;
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Suspense } from 'react';
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
|
||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||
import ProfileLastSeenServer from './profile-last-seen';
|
||||
import LastActiveUsersServer from '../retention/last-active-users';
|
||||
import ProfileListServer from './profile-list';
|
||||
import ProfileTopServer from './profile-top';
|
||||
|
||||
@@ -31,17 +29,6 @@ export default function Page({
|
||||
return (
|
||||
<>
|
||||
<PageLayout title="Profiles" organizationSlug={organizationSlug} />
|
||||
{/* <StickyBelowHeader className="flex justify-between p-4">
|
||||
<OverviewFiltersDrawer
|
||||
projectId={projectId}
|
||||
nuqsOptions={nuqsOptions}
|
||||
mode="events"
|
||||
/>
|
||||
<OverviewFiltersButtons
|
||||
className="justify-end p-0"
|
||||
nuqsOptions={nuqsOptions}
|
||||
/>
|
||||
</StickyBelowHeader> */}
|
||||
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
|
||||
<ProfileListServer
|
||||
projectId={projectId}
|
||||
@@ -51,7 +38,7 @@ export default function Page({
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ProfileLastSeenServer projectId={projectId} />
|
||||
<LastActiveUsersServer projectId={projectId} />
|
||||
<ProfileTopServer
|
||||
projectId={projectId}
|
||||
organizationSlug={organizationSlug}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
|
||||
import { getProfileList, getProfileListCount } from '@openpanel/db';
|
||||
import type { IChartEventFilter } from '@openpanel/validation';
|
||||
|
||||
@@ -9,15 +11,13 @@ interface Props {
|
||||
filters?: IChartEventFilter[];
|
||||
}
|
||||
|
||||
export default async function ProfileListServer({
|
||||
projectId,
|
||||
cursor,
|
||||
filters,
|
||||
}: Props) {
|
||||
const limit = 50;
|
||||
|
||||
async function ProfileListServer({ projectId, cursor, filters }: Props) {
|
||||
const [profiles, count] = await Promise.all([
|
||||
getProfileList({
|
||||
projectId,
|
||||
take: 10,
|
||||
take: limit,
|
||||
cursor,
|
||||
filters,
|
||||
}),
|
||||
@@ -26,5 +26,7 @@ export default async function ProfileListServer({
|
||||
filters,
|
||||
}),
|
||||
]);
|
||||
return <ProfileList data={profiles} count={count} />;
|
||||
return <ProfileList data={profiles} count={count} limit={limit} />;
|
||||
}
|
||||
|
||||
export default withLoadingWidget(ProfileListServer);
|
||||
|
||||
@@ -19,8 +19,9 @@ import type { IServiceProfile } from '@openpanel/db';
|
||||
interface ProfileListProps {
|
||||
data: IServiceProfile[];
|
||||
count: number;
|
||||
limit?: number;
|
||||
}
|
||||
export function ProfileList({ data, count }: ProfileListProps) {
|
||||
export function ProfileList({ data, count, limit = 50 }: ProfileListProps) {
|
||||
const { organizationSlug, projectId } = useAppParams();
|
||||
const { cursor, setCursor } = useCursor();
|
||||
return (
|
||||
@@ -32,7 +33,7 @@ export function ProfileList({ data, count }: ProfileListProps) {
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={10}
|
||||
take={limit}
|
||||
/>
|
||||
</WidgetHead>
|
||||
{data.length ? (
|
||||
@@ -84,7 +85,7 @@ export function ProfileList({ data, count }: ProfileListProps) {
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={10}
|
||||
take={limit}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -97,7 +98,7 @@ export function ProfileList({ data, count }: ProfileListProps) {
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCursor(Math.max(0, count / 10 - 1))}
|
||||
onClick={() => setCursor(Math.max(0, count / limit - 1))}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { Widget, WidgetHead } from '@/components/widget';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import Link from 'next/link';
|
||||
import { escape } from 'sqlstring';
|
||||
@@ -13,14 +14,11 @@ interface Props {
|
||||
organizationSlug: string;
|
||||
}
|
||||
|
||||
export default async function ProfileTopServer({
|
||||
organizationSlug,
|
||||
projectId,
|
||||
}: Props) {
|
||||
async function ProfileTopServer({ organizationSlug, projectId }: Props) {
|
||||
// Days since last event from users
|
||||
// group by days
|
||||
const res = await chQuery<{ profile_id: string; count: number }>(
|
||||
`SELECT profile_id, count(*) as count from events where profile_id != '' and project_id = ${escape(projectId)} group by profile_id order by count() DESC LIMIT 10`
|
||||
`SELECT profile_id, count(*) as count from events where profile_id != '' and project_id = ${escape(projectId)} group by profile_id order by count() DESC LIMIT 50`
|
||||
);
|
||||
const profiles = await getProfiles({ ids: res.map((r) => r.profile_id) });
|
||||
const list = res.map((item) => {
|
||||
@@ -71,3 +69,5 @@ export default async function ProfileTopServer({
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
export default withLoadingWidget(ProfileTopServer);
|
||||
|
||||
@@ -56,8 +56,6 @@ function Tooltip({ payload }: any) {
|
||||
const Chart = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((d) => d.retention));
|
||||
const number = useNumber();
|
||||
console.log('data', data);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<ResponsiveContainer>
|
||||
|
||||
@@ -15,9 +15,10 @@ interface ProfileAvatarProps
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variants = cva('', {
|
||||
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',
|
||||
@@ -39,11 +40,13 @@ export function ProfileAvatar({
|
||||
{avatar && <AvatarImage src={avatar} />}
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
size === 'sm'
|
||||
? 'text-xs'
|
||||
: size === 'xs'
|
||||
? 'text-[8px]'
|
||||
: 'text-base',
|
||||
size === 'lg'
|
||||
? 'text-lg'
|
||||
: size === 'sm'
|
||||
? 'text-xs'
|
||||
: size === 'xs'
|
||||
? 'text-[8px]'
|
||||
: 'text-base',
|
||||
'bg-slate-200 text-slate-800'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface WidgetHeadProps {
|
||||
children: React.ReactNode;
|
||||
@@ -17,6 +18,34 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export interface WidgetTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
export function WidgetTitle({
|
||||
children,
|
||||
className,
|
||||
icon: Icon,
|
||||
}: WidgetTitleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center gap-4',
|
||||
className,
|
||||
!!Icon && 'pl-12'
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<div className="absolute left-0 rounded-lg bg-slate-100 p-2">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
)}
|
||||
<div className="title">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WidgetBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Suspense } from 'react';
|
||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||
import { Widget, WidgetHead } from '@/components/widget';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
type Props = Record<string, unknown> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const withLoadingWidget = <P extends Props>(
|
||||
Component: React.ComponentType<P>
|
||||
) => {
|
||||
const withLoadingWidget = <P,>(Component: React.ComponentType<P>) => {
|
||||
const WithLoadingWidget: React.ComponentType<P> = (props) => {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Widget className={props.className}>
|
||||
<Widget
|
||||
className={cn(
|
||||
'w-full',
|
||||
props &&
|
||||
typeof props === 'object' &&
|
||||
'className' in props &&
|
||||
typeof props.className === 'string' &&
|
||||
props?.className
|
||||
)}
|
||||
>
|
||||
<WidgetHead>
|
||||
<span className="title">Loading...</span>
|
||||
</WidgetHead>
|
||||
|
||||
@@ -5,7 +5,7 @@ const withSuspense = <P,>(
|
||||
Fallback: React.ComponentType<P>
|
||||
) => {
|
||||
const WithSuspense: React.ComponentType<P> = (props) => {
|
||||
const fallback = <Fallback {...(props as any)} key="faaaaalling" />;
|
||||
const fallback = <Fallback {...(props as any)} />;
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<Component {...(props as any)} />
|
||||
|
||||
Reference in New Issue
Block a user