dashboard: update event and profile list
This commit is contained in:
@@ -8,7 +8,7 @@ interface Props {
|
||||
filters?: any[];
|
||||
}
|
||||
|
||||
export function EventChart({ projectId, filters, events }: Props) {
|
||||
export function EventsPerDayChart({ projectId, filters, events }: Props) {
|
||||
const fallback: IChartEvent[] = [
|
||||
{
|
||||
id: 'A',
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Widget, WidgetHead } from '@/components/Widget';
|
||||
import { isSameDay } from 'date-fns';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
import { EventListItem } from '../event-list-item';
|
||||
|
||||
function showDateHeader(a: Date, b?: Date) {
|
||||
if (!b) return true;
|
||||
return !isSameDay(a, b);
|
||||
}
|
||||
|
||||
interface EventListProps {
|
||||
data: IServiceCreateEventPayload[];
|
||||
}
|
||||
export function EventConversionsList({ data }: EventListProps) {
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetHead>
|
||||
<div className="title">Conversions</div>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-2 overflow-y-auto max-h-80 p-4">
|
||||
{data.map((item, index, list) => (
|
||||
<Fragment key={item.id}>
|
||||
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
|
||||
<div className="flex flex-row justify-between gap-2 [&:not(:first-child)]:mt-12">
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-slate-100 border border-slate-300 rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2">
|
||||
{item.createdAt.toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EventListItem {...item} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Widget } from '@/components/Widget';
|
||||
|
||||
import { db, getEvents } from '@openpanel/db';
|
||||
|
||||
import { EventConversionsList } from './event-conversions-list';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default async function EventConversionsListServer({ projectId }: Props) {
|
||||
const conversions = await db.eventMeta.findMany({
|
||||
where: {
|
||||
project_id: projectId,
|
||||
conversion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (conversions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const events = await getEvents(
|
||||
`SELECT * FROM events WHERE project_id = '${projectId}' AND name IN (${conversions.map((c) => `'${c.name}'`).join(', ')}) ORDER BY created_at DESC LIMIT 20;`,
|
||||
{
|
||||
profile: true,
|
||||
meta: true,
|
||||
}
|
||||
);
|
||||
|
||||
return <EventConversionsList data={events} />;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ChartSwitchShortcut } from '@/components/report/chart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KeyValue } from '@/components/ui/key-value';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
@@ -17,6 +20,8 @@ import { round } from 'mathjs';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
import { EventEdit } from './event-edit';
|
||||
|
||||
interface Props {
|
||||
event: IServiceCreateEventPayload;
|
||||
open: boolean;
|
||||
@@ -24,6 +29,7 @@ interface Props {
|
||||
}
|
||||
export function EventDetails({ event, open, setOpen }: Props) {
|
||||
const { name } = event;
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
||||
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
|
||||
|
||||
@@ -140,79 +146,91 @@ export function EventDetails({ event, open, setOpen }: Props) {
|
||||
.filter((item) => typeof item.value === 'string' && item.value);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent>
|
||||
<div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent>
|
||||
<div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{properties.length > 0 && (
|
||||
{properties.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Params</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{properties.map((item) => (
|
||||
<KeyValue
|
||||
key={item.name}
|
||||
name={item.name}
|
||||
value={item.value}
|
||||
onClick={() => {
|
||||
setFilter(
|
||||
`properties.${item.name}`,
|
||||
item.value ? String(item.value) : '',
|
||||
'is'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Params</div>
|
||||
<div className="text-sm font-medium mb-2">Common</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{properties.map((item) => (
|
||||
{common.map((item) => (
|
||||
<KeyValue
|
||||
key={item.name}
|
||||
name={item.name}
|
||||
value={item.value}
|
||||
onClick={() => {
|
||||
setFilter(
|
||||
`properties.${item.name}`,
|
||||
item.value ? String(item.value) : '',
|
||||
'is'
|
||||
);
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Common</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{common.map((item) => (
|
||||
<KeyValue
|
||||
key={item.name}
|
||||
name={item.name}
|
||||
value={item.value}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm font-medium mb-2">
|
||||
<div>Similar events</div>
|
||||
<button
|
||||
className="hover:underline text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEvents([event.name]);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm font-medium mb-2">
|
||||
<div>Similar events</div>
|
||||
<button
|
||||
className="hover:underline text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEvents([event.name]);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
<ChartSwitchShortcut
|
||||
projectId={event.projectId}
|
||||
chartType="histogram"
|
||||
events={[
|
||||
{
|
||||
id: 'A',
|
||||
name: event.name,
|
||||
displayName: 'Similar events',
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<ChartSwitchShortcut
|
||||
projectId={event.projectId}
|
||||
chartType="histogram"
|
||||
events={[
|
||||
{
|
||||
id: 'A',
|
||||
name: event.name,
|
||||
displayName: 'Similar events',
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
className="w-full"
|
||||
onClick={() => setIsEditOpen(true)}
|
||||
>
|
||||
Customize "{name}"
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<EventEdit event={event} open={isEditOpen} setOpen={setIsEditOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,8 +66,7 @@ export function EventEdit({ event, open, setOpen }: Props) {
|
||||
|
||||
const mutation = api.event.updateEventMeta.useMutation({
|
||||
onSuccess() {
|
||||
// @ts-expect-error
|
||||
document.querySelector('#close-sheet')?.click();
|
||||
setOpen(false);
|
||||
toast('Event updated');
|
||||
router.refresh();
|
||||
},
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Tooltip, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
@@ -155,7 +157,7 @@ export function EventIcon({ className, name, size, meta }: EventIconProps) {
|
||||
|
||||
return (
|
||||
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
|
||||
<Icon size={20} className={`text-${color}-700`} />
|
||||
<Icon size={size === 'sm' ? 14 : 20} className={`text-${color}-700`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
||||
import { KeyValueSubtle } from '@/components/ui/key-value';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
import { EventDetails } from './event-details';
|
||||
import { EventEdit } from './event-edit';
|
||||
import { EventIcon } from './event-icon';
|
||||
|
||||
type EventListItemProps = IServiceCreateEventPayload;
|
||||
|
||||
export function EventListItem(props: EventListItemProps) {
|
||||
const {
|
||||
profile,
|
||||
createdAt,
|
||||
name,
|
||||
path,
|
||||
duration,
|
||||
brand,
|
||||
browser,
|
||||
city,
|
||||
country,
|
||||
device,
|
||||
os,
|
||||
projectId,
|
||||
meta,
|
||||
} = props;
|
||||
const params = useAppParams();
|
||||
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const { createdAt, name, path, duration, meta, profile } = props;
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
|
||||
const number = useNumber();
|
||||
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `Route: ${path}`;
|
||||
}
|
||||
|
||||
return name.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventDetails
|
||||
@@ -47,87 +52,44 @@ export function EventListItem(props: EventListItemProps) {
|
||||
open={isDetailsOpen}
|
||||
setOpen={setIsDetailsOpen}
|
||||
/>
|
||||
<EventEdit event={props} open={isEditOpen} setOpen={setIsEditOpen} />
|
||||
<div
|
||||
<button
|
||||
onClick={() => setIsDetailsOpen(true)}
|
||||
className={cn(
|
||||
'p-4 flex flex-col gap-2 hover:bg-slate-50 rounded-lg transition-colors',
|
||||
'w-full card p-4 flex hover:bg-slate-50 rounded-lg transition-colors justify-between items-center',
|
||||
meta?.conversion && `bg-${meta.color}-50 hover:bg-${meta.color}-100`
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-4 items-center">
|
||||
<button onClick={() => setIsEditOpen(true)}>
|
||||
<EventIcon name={name} meta={meta} projectId={projectId} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsDetailsOpen(true)}
|
||||
className="text-left font-semibold hover:underline"
|
||||
<div className="flex gap-4 items-center text-left text-sm">
|
||||
<EventIcon size="sm" name={name} meta={meta} projectId={projectId} />
|
||||
<span>
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
{' '}
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Tooltiper
|
||||
asChild
|
||||
content={`${profile?.firstName} ${profile?.lastName}`}
|
||||
>
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
href={`/${organizationId}/${projectId}/profiles/${profile?.id}`}
|
||||
className="text-muted-foreground text-sm hover:underline whitespace-nowrap max-w-[80px] overflow-hidden text-ellipsis"
|
||||
>
|
||||
{name.replace(/_/g, ' ')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
{profile?.firstName} {profile?.lastName}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{path && (
|
||||
<KeyValueSubtle
|
||||
name={'Path'}
|
||||
value={
|
||||
path +
|
||||
(duration
|
||||
? ` (${number.shortWithUnit(duration / 1000, 'min')})`
|
||||
: '')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{profile && (
|
||||
<KeyValueSubtle
|
||||
name={'Profile'}
|
||||
value={
|
||||
<>
|
||||
{profile.avatar && <ProfileAvatar size="xs" {...profile} />}
|
||||
{getProfileName(profile)}
|
||||
</>
|
||||
}
|
||||
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
||||
/>
|
||||
)}
|
||||
<KeyValueSubtle
|
||||
name={'From'}
|
||||
onClick={() => setFilter('city', city)}
|
||||
value={
|
||||
<>
|
||||
{country && <SerieIcon name={country} />}
|
||||
{city}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<KeyValueSubtle
|
||||
name={'Device'}
|
||||
onClick={() => setFilter('device', device)}
|
||||
value={
|
||||
<>
|
||||
{device && <SerieIcon name={device} />}
|
||||
{brand || os}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{browser !== 'WebKit' && browser !== '' && (
|
||||
<KeyValueSubtle
|
||||
name={'Browser'}
|
||||
onClick={() => setFilter('browser', browser)}
|
||||
value={
|
||||
<>
|
||||
{browser && <SerieIcon name={browser} />}
|
||||
{browser}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useCursor } from '@/hooks/useCursor';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { isSameDay } from 'date-fns';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
GanttChartIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
@@ -56,21 +60,26 @@ export function EventList({ data, count }: EventListProps) {
|
||||
</FullPageEmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col md:flex-row justify-between gap-2">
|
||||
<EventListener />
|
||||
<Pagination
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col my-4 card p-4 gap-0.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.map((item, index, list) => (
|
||||
<Fragment key={item.id}>
|
||||
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
|
||||
<div className="text-muted-foreground font-medium text-sm [&:not(:first-child)]:mt-12 text-center">
|
||||
{item.createdAt.toLocaleDateString()}
|
||||
<div className="flex flex-row justify-between gap-2 [&:not(:first-child)]:mt-12">
|
||||
{index === 0 ? <EventListener /> : <div />}
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-slate-100 border border-slate-300 rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2">
|
||||
{item.createdAt.toLocaleDateString()}
|
||||
</div>
|
||||
{index === 0 && (
|
||||
<Pagination
|
||||
size="sm"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EventListItem {...item} />
|
||||
@@ -78,6 +87,7 @@ export function EventList({ data, count }: EventListProps) {
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
className="mt-2"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function EventListener() {
|
||||
></div>
|
||||
</div>
|
||||
{counter === 0 ? (
|
||||
'Listening to events'
|
||||
'Listening'
|
||||
) : (
|
||||
<>
|
||||
<AnimatedNumbers
|
||||
|
||||
@@ -11,7 +11,8 @@ import { parseAsInteger } from 'nuqs';
|
||||
import { getEventList, getEventsCount } from '@openpanel/db';
|
||||
|
||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||
import { EventChart } from './event-chart';
|
||||
import { EventsPerDayChart } from './charts/events-per-day-chart';
|
||||
import EventConversionsListServer from './event-conversions-list';
|
||||
import { EventList } from './event-list';
|
||||
|
||||
interface PageProps {
|
||||
@@ -70,13 +71,18 @@ export default async function Page({
|
||||
nuqsOptions={nuqsOptions}
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<EventChart
|
||||
projectId={projectId}
|
||||
events={eventsFilter}
|
||||
filters={filters}
|
||||
/>
|
||||
<EventList data={events} count={count} />
|
||||
<div className="grid md:grid-cols-2 p-4 gap-4">
|
||||
<div>
|
||||
<EventList data={events} count={count} />
|
||||
</div>
|
||||
<div>
|
||||
<EventsPerDayChart
|
||||
projectId={projectId}
|
||||
events={eventsFilter}
|
||||
filters={filters}
|
||||
/>
|
||||
<EventConversionsListServer projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,13 @@ import { OverviewFiltersButtons } from '@/components/overview/filters/overview-f
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { ChartSwitch } from '@/components/report/chart';
|
||||
import { KeyValue } from '@/components/ui/key-value';
|
||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||
import {
|
||||
eventQueryFiltersParser,
|
||||
eventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { parseAsInteger, parseAsString } from 'nuqs';
|
||||
@@ -117,7 +116,7 @@ export default async function Page({
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: 'Events',
|
||||
range: '7d',
|
||||
range: '1m',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
@@ -149,29 +148,71 @@ export default async function Page({
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
|
||||
<Widget>
|
||||
<WidgetHead>
|
||||
<span className="title">Properties</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex gap-2 flex-wrap">
|
||||
{Object.entries(profile.properties)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => (
|
||||
<KeyValue key={key} name={key} value={value} />
|
||||
))}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget>
|
||||
<WidgetHead>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex gap-2">
|
||||
<ChartSwitch {...profileChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<div>
|
||||
<EventList data={events} count={count} />
|
||||
</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 justify-between items-center">
|
||||
<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>
|
||||
</div>
|
||||
<EventList data={events} count={count} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueRow({ name, value }: { name: string; value?: unknown }) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-row justify-between p-2 px-4">
|
||||
<div className="font-medium text-muted-foreground capitalize">
|
||||
{name.replace('_', ' ')}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center text-right">
|
||||
{typeof value === 'string' ? (
|
||||
<>
|
||||
<SerieIcon name={value} /> {value}
|
||||
</>
|
||||
) : (
|
||||
<>{value}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
|
||||
import { getProfileList, getProfileListCount } from '@openpanel/db';
|
||||
|
||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||
import { ProfileList } from './profile-list';
|
||||
import ProfileLastSeenServer from './profile-last-seen';
|
||||
import ProfileListServer from './profile-list';
|
||||
import ProfileTopServer from './profile-top';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -29,19 +29,7 @@ export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
searchParams: { cursor, f },
|
||||
}: PageProps) {
|
||||
const [profiles, count] = await Promise.all([
|
||||
getProfileList({
|
||||
projectId,
|
||||
take: 50,
|
||||
cursor: parseAsInteger.parseServerSide(cursor ?? '') ?? undefined,
|
||||
filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined,
|
||||
}),
|
||||
getProfileListCount({
|
||||
projectId,
|
||||
filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined,
|
||||
}),
|
||||
getExists(organizationId, projectId),
|
||||
]);
|
||||
await getExists(organizationId, projectId);
|
||||
|
||||
return (
|
||||
<PageLayout title="Profiles" organizationSlug={organizationId}>
|
||||
@@ -56,7 +44,22 @@ export default async function Page({
|
||||
nuqsOptions={nuqsOptions}
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<ProfileList data={profiles} count={count} />
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ProfileListServer
|
||||
projectId={projectId}
|
||||
cursor={parseAsInteger.parseServerSide(cursor ?? '') ?? undefined}
|
||||
filters={
|
||||
eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ProfileLastSeenServer projectId={projectId} />
|
||||
<ProfileTopServer
|
||||
projectId={projectId}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { chQuery } from '@openpanel/db';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default async function ProfileLastSeenServer({ projectId }: Props) {
|
||||
interface Row {
|
||||
days: number;
|
||||
count: number;
|
||||
}
|
||||
// Days since last event from users
|
||||
// group by days
|
||||
const res = await chQuery<Row>(
|
||||
`SELECT age('days',created_at, now()) as days, count(distinct profile_id) as count FROM events where project_id = '${projectId}' group by days order by days ASC`
|
||||
);
|
||||
|
||||
const take = 18;
|
||||
const split = take / 2;
|
||||
const max = Math.max(...res.map((item) => item.count));
|
||||
const renderItem = (item: Row) => (
|
||||
<div
|
||||
key={item.days}
|
||||
className="flex-1 shrink-0 h-full flex flex-col items-center"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="w-full flex-1 bg-slate-200 rounded flex flex-col justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded',
|
||||
item.days < split ? 'bg-blue-600' : 'bg-blue-400'
|
||||
)}
|
||||
style={{
|
||||
height: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{item.count} profiles last seen{' '}
|
||||
{item.days === 0 ? 'today' : `${item.days} days ago`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="text-xs mt-1">{item.days}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<div className="title">Last seen</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<div className="flex aspect-[3/1] w-full gap-1 items-end">
|
||||
{res.length >= 18 ? (
|
||||
<>
|
||||
{res.slice(0, split).map(renderItem)}
|
||||
{res.slice(-split).map(renderItem)}
|
||||
</>
|
||||
) : (
|
||||
res.map(renderItem)
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-xs text-muted-foreground">DAYS</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getProfileList, getProfileListCount } from '@openpanel/db';
|
||||
import type { IChartEventFilter } from '@openpanel/validation';
|
||||
|
||||
import { ProfileList } from './profile-list';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
cursor?: number;
|
||||
filters?: IChartEventFilter[];
|
||||
}
|
||||
|
||||
export default async function ProfileListServer({
|
||||
projectId,
|
||||
cursor,
|
||||
filters,
|
||||
}: Props) {
|
||||
const [profiles, count] = await Promise.all([
|
||||
getProfileList({
|
||||
projectId,
|
||||
take: 10,
|
||||
cursor,
|
||||
filters,
|
||||
}),
|
||||
getProfileListCount({
|
||||
projectId,
|
||||
filters,
|
||||
}),
|
||||
]);
|
||||
return <ProfileList data={profiles} count={count} />;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { ListPropertiesIcon } from '@/components/events/ListPropertiesIcon';
|
||||
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
||||
import { Pagination } from '@/components/Pagination';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { Widget, WidgetHead } from '@/components/Widget';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useCursor } from '@/hooks/useCursor';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
|
||||
interface ProfileListProps {
|
||||
data: IServiceProfile[];
|
||||
count: number;
|
||||
}
|
||||
export function ProfileList({ data, count }: ProfileListProps) {
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const { cursor, setCursor } = useCursor();
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetHead className="flex justify-between items-center">
|
||||
<div className="title">Profiles</div>
|
||||
<Pagination
|
||||
size="sm"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={10}
|
||||
/>
|
||||
</WidgetHead>
|
||||
{data.length ? (
|
||||
<>
|
||||
<WidgetTable
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
columns={[
|
||||
{
|
||||
name: 'Name',
|
||||
render(profile) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${organizationId}/${projectId}/profiles/${profile.id}`}
|
||||
className="flex gap-2 items-center font-medium"
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{profile.firstName} {profile.lastName}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
render(profile) {
|
||||
return <ListPropertiesIcon {...profile.properties} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Last seen',
|
||||
render(profile) {
|
||||
return (
|
||||
<Tooltiper
|
||||
asChild
|
||||
content={profile.createdAt.toLocaleString()}
|
||||
>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{profile.createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="p-4 border-t border-border">
|
||||
<Pagination
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={10}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<FullPageEmptyState title="No profiles here" icon={UsersIcon}>
|
||||
{cursor !== 0 ? (
|
||||
<>
|
||||
<p>Looks like you have reached the end of the list</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCursor(count / 10 - 1)}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p>Looks like there is no profiles here</p>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ListPropertiesIcon } from '@/components/events/ListPropertiesIcon';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { Widget, WidgetHead } from '@/components/Widget';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { chQuery, getProfiles } from '@openpanel/db';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export default async function ProfileTopServer({
|
||||
organizationId,
|
||||
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 = '${projectId}' group by profile_id order by count() DESC LIMIT 10`
|
||||
);
|
||||
const profiles = await getProfiles({ ids: res.map((r) => r.profile_id) });
|
||||
const list = res.map((item) => {
|
||||
return {
|
||||
count: item.count,
|
||||
...(profiles.find((p) => p.id === item.profile_id) ?? {}),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<div className="title">Power users</div>
|
||||
</WidgetHead>
|
||||
<WidgetTable
|
||||
data={list.filter((item) => !!item.id)}
|
||||
keyExtractor={(item) => item.id!}
|
||||
columns={[
|
||||
{
|
||||
name: 'Name',
|
||||
render(profile) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${organizationId}/${projectId}/profiles/${profile.id}`}
|
||||
className="flex gap-2 items-center font-medium"
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{profile.firstName} {profile.lastName}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
render(profile) {
|
||||
return <ListPropertiesIcon {...profile.properties} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
render(profile) {
|
||||
return profile.count;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
|
||||
@@ -20,38 +27,72 @@ export function Pagination({
|
||||
count,
|
||||
cursor,
|
||||
setCursor,
|
||||
className,
|
||||
size = 'base',
|
||||
}: {
|
||||
take?: number;
|
||||
count?: number;
|
||||
take: number;
|
||||
count: number;
|
||||
cursor: number;
|
||||
setCursor: Dispatch<SetStateAction<number>>;
|
||||
className?: string;
|
||||
size?: 'sm' | 'base';
|
||||
}) {
|
||||
const isNextDisabled =
|
||||
count !== undefined && take !== undefined && cursor * take + take >= count;
|
||||
|
||||
const lastCursor = Math.floor(count / take) - 1;
|
||||
const isNextDisabled = count === 0 || lastCursor === cursor;
|
||||
return (
|
||||
<div className="flex select-none items-center justify-end gap-2">
|
||||
<div className="font-medium text-xs">Page: {cursor + 1}</div>
|
||||
{typeof count === 'number' && (
|
||||
<div className="font-medium text-xs">Total rows: {count}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex select-none items-center justify-end gap-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{size === 'base' && (
|
||||
<>
|
||||
<div className="font-medium text-xs">Page: {cursor + 1}</div>
|
||||
{typeof count === 'number' && (
|
||||
<div className="font-medium text-xs">Total rows: {count}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{size === 'base' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCursor(0)}
|
||||
disabled={cursor === 0}
|
||||
className="max-sm:hidden"
|
||||
>
|
||||
<ChevronsLeftIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => setCursor((p) => Math.max(0, p - 1))}
|
||||
disabled={cursor === 0}
|
||||
>
|
||||
Previous
|
||||
<ChevronLeftIcon size={14} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCursor((p) => p + 1)}
|
||||
size="icon"
|
||||
onClick={() => setCursor((p) => Math.min(lastCursor, p + 1))}
|
||||
disabled={isNextDisabled}
|
||||
>
|
||||
Next
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
{size === 'base' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCursor(lastCursor)}
|
||||
disabled={isNextDisabled}
|
||||
className="max-sm:hidden"
|
||||
>
|
||||
<ChevronsRightIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { toDots } from '@openpanel/common';
|
||||
|
||||
import { Table, TableBody, TableCell, TableRow } from '../ui/table';
|
||||
|
||||
interface ListPropertiesProps {
|
||||
data: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ListProperties({
|
||||
data,
|
||||
className = 'mini',
|
||||
}: ListPropertiesProps) {
|
||||
const dots = toDots(data);
|
||||
return (
|
||||
<Table className={className}>
|
||||
<TableBody>
|
||||
{Object.keys(dots).map((key) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="font-medium">{key}</TableCell>
|
||||
<TableCell>
|
||||
{typeof dots[key] === 'boolean'
|
||||
? dots[key]
|
||||
? 'true'
|
||||
: 'false'
|
||||
: dots[key]}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
69
apps/dashboard/src/components/events/ListPropertiesIcon.tsx
Normal file
69
apps/dashboard/src/components/events/ListPropertiesIcon.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { SerieIcon } from '../report/chart/SerieIcon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
country?: string;
|
||||
city?: string;
|
||||
os?: string;
|
||||
os_version?: string;
|
||||
browser?: string;
|
||||
browser_version?: string;
|
||||
referrer_name?: string;
|
||||
referrer_type?: string;
|
||||
}
|
||||
|
||||
export function ListPropertiesIcon({
|
||||
country,
|
||||
city,
|
||||
os,
|
||||
os_version,
|
||||
browser,
|
||||
browser_version,
|
||||
referrer_name,
|
||||
referrer_type,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{country && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SerieIcon name={country} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{country}, {city}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{os && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SerieIcon name={os} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{os} ({os_version})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{browser && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SerieIcon name={browser} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{browser} ({browser_version})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{referrer_name && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SerieIcon name={referrer_name} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{referrer_name} ({referrer_type})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BarChartIcon, LineChartIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
export function OverviewChartToggle() {
|
||||
const { chartType, setChartType } = useOverviewOptions();
|
||||
return (
|
||||
<Button
|
||||
size={'icon'}
|
||||
variant={'outline'}
|
||||
onClick={() => {
|
||||
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
|
||||
}}
|
||||
>
|
||||
{chartType === 'bar' ? (
|
||||
<LineChartIcon size={16} />
|
||||
) : (
|
||||
<BarChartIcon size={16} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
@@ -15,7 +16,7 @@ interface OverviewTopDevicesProps {
|
||||
export default function OverviewTopDevices({
|
||||
projectId,
|
||||
}: OverviewTopDevicesProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
const { interval, range, previous, startDate, endDate, chartType } =
|
||||
useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||
@@ -41,7 +42,7 @@ export default function OverviewTopDevices({
|
||||
name: 'device',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -71,7 +72,7 @@ export default function OverviewTopDevices({
|
||||
name: 'browser',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -101,7 +102,7 @@ export default function OverviewTopDevices({
|
||||
name: 'browser_version',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -131,7 +132,7 @@ export default function OverviewTopDevices({
|
||||
name: 'os',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -161,7 +162,7 @@ export default function OverviewTopDevices({
|
||||
name: 'os_version',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -176,7 +177,10 @@ export default function OverviewTopDevices({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle />
|
||||
</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { ChartSwitch } from '@/components/report/chart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { BarChartIcon, LineChart, LineChartIcon } from 'lucide-react';
|
||||
|
||||
import { Widget, WidgetBody } from '../../Widget';
|
||||
import { OverviewChartToggle } from '../overview-chart-toggle';
|
||||
import { WidgetButtons, WidgetHead } from '../overview-widget';
|
||||
import { useOverviewOptions } from '../useOverviewOptions';
|
||||
import { useOverviewWidget } from '../useOverviewWidget';
|
||||
@@ -17,8 +20,15 @@ export default function OverviewTopEvents({
|
||||
projectId,
|
||||
conversions,
|
||||
}: OverviewTopEventsProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
const {
|
||||
interval,
|
||||
range,
|
||||
previous,
|
||||
startDate,
|
||||
endDate,
|
||||
chartType,
|
||||
setChartType,
|
||||
} = useOverviewOptions();
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
|
||||
all: {
|
||||
@@ -50,7 +60,7 @@ export default function OverviewTopEvents({
|
||||
name: 'name',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType: chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -89,7 +99,7 @@ export default function OverviewTopEvents({
|
||||
name: 'name',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType: chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -104,7 +114,10 @@ export default function OverviewTopEvents({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle />
|
||||
</div>
|
||||
<WidgetButtons>
|
||||
{widgets
|
||||
.filter((item) => item.hide !== true)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
@@ -13,7 +14,7 @@ interface OverviewTopGeoProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
const { interval, range, previous, startDate, endDate, chartType } =
|
||||
useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||
@@ -39,7 +40,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
name: 'country',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -69,7 +70,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
name: 'region',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -99,7 +100,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
name: 'city',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -114,7 +115,10 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle />
|
||||
</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
@@ -13,7 +14,7 @@ interface OverviewTopPagesProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
const { interval, range, previous, startDate, endDate, chartType } =
|
||||
useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
|
||||
@@ -38,7 +39,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Top sources',
|
||||
@@ -68,7 +69,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Top sources',
|
||||
@@ -98,7 +99,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Top sources',
|
||||
@@ -113,7 +114,10 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle />
|
||||
</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
@@ -15,7 +16,7 @@ interface OverviewTopSourcesProps {
|
||||
export default function OverviewTopSources({
|
||||
projectId,
|
||||
}: OverviewTopSourcesProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
const { interval, range, previous, startDate, endDate, chartType } =
|
||||
useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||
@@ -41,7 +42,7 @@ export default function OverviewTopSources({
|
||||
name: 'referrer_name',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top groups',
|
||||
@@ -71,7 +72,7 @@ export default function OverviewTopSources({
|
||||
name: 'referrer',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -101,7 +102,7 @@ export default function OverviewTopSources({
|
||||
name: 'referrer_type',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top types',
|
||||
@@ -131,7 +132,7 @@ export default function OverviewTopSources({
|
||||
name: 'properties.query.utm_source',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -161,7 +162,7 @@ export default function OverviewTopSources({
|
||||
name: 'properties.query.utm_medium',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -191,7 +192,7 @@ export default function OverviewTopSources({
|
||||
name: 'properties.query.utm_campaign',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -221,7 +222,7 @@ export default function OverviewTopSources({
|
||||
name: 'properties.query.utm_term',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -251,7 +252,7 @@ export default function OverviewTopSources({
|
||||
name: 'properties.query.utm_content',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
@@ -266,7 +267,11 @@ export default function OverviewTopSources({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle />
|
||||
</div>
|
||||
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
|
||||
@@ -19,7 +19,10 @@ import { WidgetHead as WidgetHeadBase } from '../Widget';
|
||||
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
|
||||
return (
|
||||
<WidgetHeadBase
|
||||
className={cn('flex flex-col p-0 [&_.title]:p-4', className)}
|
||||
className={cn(
|
||||
'flex flex-col p-0 [&_.title]:p-4 [&_.title]:flex [&_.title]:justify-between [&_.title]:items-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,12 @@ import { mapKeys } from '@openpanel/validation';
|
||||
const nuqsOptions = { history: 'push' } as const;
|
||||
|
||||
export function useOverviewOptions() {
|
||||
const [chartType, setChartType] = useQueryState(
|
||||
'ct',
|
||||
parseAsStringEnum(['bar', 'linear'])
|
||||
.withDefault('bar')
|
||||
.withOptions(nuqsOptions)
|
||||
);
|
||||
const [previous, setPrevious] = useQueryState(
|
||||
'compare',
|
||||
parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
|
||||
@@ -68,5 +74,9 @@ export function useOverviewOptions() {
|
||||
// Toggles
|
||||
liveHistogram,
|
||||
setLiveHistogram,
|
||||
|
||||
// Other
|
||||
chartType,
|
||||
setChartType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@ import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||
|
||||
interface ProfileAvatarProps
|
||||
extends VariantProps<typeof variants>,
|
||||
Partial<Pick<IServiceProfile, 'avatar' | 'first_name'>> {
|
||||
Partial<Pick<IServiceProfile, 'avatar' | 'firstName'>> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variants = cva('', {
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-12 w-12 rounded-full [&>span]:rounded-full',
|
||||
default: 'h-8 w-8 rounded [&>span]:rounded',
|
||||
sm: 'h-6 w-6 rounded [&>span]:rounded',
|
||||
xs: 'h-4 w-4 rounded [&>span]:rounded',
|
||||
},
|
||||
@@ -30,7 +30,7 @@ const variants = cva('', {
|
||||
|
||||
export function ProfileAvatar({
|
||||
avatar,
|
||||
first_name,
|
||||
firstName,
|
||||
className,
|
||||
size,
|
||||
}: ProfileAvatarProps) {
|
||||
@@ -47,7 +47,7 @@ export function ProfileAvatar({
|
||||
'bg-slate-200 text-slate-800'
|
||||
)}
|
||||
>
|
||||
{first_name?.at(0) ?? '🫣'}
|
||||
{firstName?.at(0) ?? '🫣'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
|
||||
interface SerieIconProps extends LucideProps {
|
||||
name: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function getProxyImage(url: string) {
|
||||
@@ -26,14 +26,16 @@ function getProxyImage(url: string) {
|
||||
|
||||
const createImageIcon = (url: string) => {
|
||||
return function (props: LucideProps) {
|
||||
return <img className="w-4 h-4 object-cover rounded" src={url} />;
|
||||
return <img className="h-4 object-contain rounded-[2px]" src={url} />;
|
||||
} as LucideIcon;
|
||||
};
|
||||
|
||||
const createFlagIcon = (url: string) => {
|
||||
return function (props: LucideProps) {
|
||||
return (
|
||||
<span className={`rounded !block !leading-[1rem] fi fi-${url}`}></span>
|
||||
<span
|
||||
className={`rounded-[2px] overflow-hidden !block !leading-[1rem] fi fi-${url}`}
|
||||
></span>
|
||||
);
|
||||
} as LucideIcon;
|
||||
};
|
||||
@@ -92,6 +94,20 @@ const mapper: Record<string, LucideIcon> = {
|
||||
),
|
||||
snapchat: createImageIcon(getProxyImage('https://snapchat.com')),
|
||||
|
||||
// OS
|
||||
'mac os': createImageIcon(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/c/c9/Finder_Icon_macOS_Big_Sur.png'
|
||||
),
|
||||
windows: createImageIcon(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/c/c7/Windows_logo_-_2012.png'
|
||||
),
|
||||
ios: createImageIcon(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/9/96/IOS_17_logo.png'
|
||||
),
|
||||
android: createImageIcon(
|
||||
'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png'
|
||||
),
|
||||
|
||||
// Misc
|
||||
mobile: SmartphoneIcon,
|
||||
desktop: MonitorIcon,
|
||||
@@ -220,6 +236,10 @@ const mapper: Record<string, LucideIcon> = {
|
||||
|
||||
export function SerieIcon({ name, ...props }: SerieIconProps) {
|
||||
const Icon = useMemo(() => {
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapped = mapper[name.toLowerCase()] ?? null;
|
||||
|
||||
if (mapped) {
|
||||
@@ -234,7 +254,7 @@ export function SerieIcon({ name, ...props }: SerieIconProps) {
|
||||
}, [name]);
|
||||
|
||||
return Icon ? (
|
||||
<div className="w-4 h-4 flex-shrink-0 relative [&_a]:!w-4 [&_a]:!h-4 [&_svg]:!rounded">
|
||||
<div className="h-4 flex-shrink-0 relative [&_a]:![&_a]:!h-4 [&_svg]:!rounded-[2px]">
|
||||
<Icon size={16} {...props} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -101,7 +101,7 @@ const SheetFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
'sticky bottom-0 left-0 right-0 mt-auto',
|
||||
'sticky bottom-0 left-0 right-0 mt-auto bg-white',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -27,3 +27,17 @@ const TooltipContent = React.forwardRef<
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
interface TooltiperProps {
|
||||
asChild: boolean;
|
||||
content: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export function Tooltiper({ asChild, content, children }: TooltiperProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
|
||||
<TooltipContent>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
34
apps/dashboard/src/components/widget-table.tsx
Normal file
34
apps/dashboard/src/components/widget-table.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
interface Props<T> {
|
||||
columns: {
|
||||
name: string;
|
||||
render: (item: T) => React.ReactNode;
|
||||
}[];
|
||||
keyExtractor: (item: T) => string;
|
||||
data: T[];
|
||||
}
|
||||
|
||||
export function WidgetTable<T>({ columns, data, keyExtractor }: Props<T>) {
|
||||
return (
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b border-border text-slate-500 [&_th]:font-medium text-sm [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th:last-child]:text-right [&_th]:whitespace-nowrap">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.name}>{column.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="text-sm border-b border-border last:border-0 [&_td]:p-4 [&_td:first-child]:text-left text-right"
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.name}>{column.render(item)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user