dashboard: update event and profile list

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-18 09:08:02 +01:00
parent 2057fe083b
commit 3a8404f704
34 changed files with 942 additions and 318 deletions

View File

@@ -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',

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -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} />
</>
);
}

View File

@@ -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();
},

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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}

View File

@@ -64,7 +64,7 @@ export default function EventListener() {
></div>
</div>
{counter === 0 ? (
'Listening to events'
'Listening'
) : (
<>
<AnimatedNumbers

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}
/>
);

View File

@@ -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,
};
}

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
);
}

View 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>
);
}