wip event list item
This commit is contained in:
@@ -1,15 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||||
import { ListProperties } from '@/components/events/ListProperties';
|
|
||||||
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
|
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
|
||||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { formatDateTime } from '@/utils/date';
|
|
||||||
import { getProfileName } from '@/utils/getters';
|
import { getProfileName } from '@/utils/getters';
|
||||||
import { round } from '@/utils/math';
|
import { round } from '@/utils/math';
|
||||||
import Link from 'next/link';
|
import { useQueryState } from 'nuqs';
|
||||||
|
|
||||||
import { EventIcon } from './event-icon';
|
import { EventIcon } from './event-icon';
|
||||||
|
|
||||||
@@ -21,49 +18,202 @@ export function EventListItem({
|
|||||||
name,
|
name,
|
||||||
properties,
|
properties,
|
||||||
path,
|
path,
|
||||||
|
duration,
|
||||||
|
referrer,
|
||||||
|
referrerName,
|
||||||
|
referrerType,
|
||||||
|
brand,
|
||||||
|
model,
|
||||||
|
browser,
|
||||||
|
browserVersion,
|
||||||
|
os,
|
||||||
|
osVersion,
|
||||||
|
city,
|
||||||
|
region,
|
||||||
|
country,
|
||||||
|
continent,
|
||||||
|
device,
|
||||||
}: EventListItemProps) {
|
}: EventListItemProps) {
|
||||||
const params = useAppParams();
|
const params = useAppParams();
|
||||||
|
|
||||||
const bullets = useMemo(() => {
|
const [, setPath] = useQueryState('path');
|
||||||
const bullets: React.ReactNode[] = [
|
const [, setReferrer] = useQueryState('referrer');
|
||||||
<span>{formatDateTime(createdAt)}</span>,
|
const [, setReferrerName] = useQueryState('referrerName');
|
||||||
];
|
const [, setReferrerType] = useQueryState('referrerType');
|
||||||
|
const [, setBrand] = useQueryState('brand');
|
||||||
|
const [, setModel] = useQueryState('model');
|
||||||
|
const [, setBrowser] = useQueryState('browser');
|
||||||
|
const [, setBrowserVersion] = useQueryState('browserVersion');
|
||||||
|
const [, setOs] = useQueryState('os');
|
||||||
|
const [, setOsVersion] = useQueryState('osVersion');
|
||||||
|
const [, setCity] = useQueryState('city');
|
||||||
|
const [, setRegion] = useQueryState('region');
|
||||||
|
const [, setCountry] = useQueryState('country');
|
||||||
|
const [, setContinent] = useQueryState('continent');
|
||||||
|
const [, setDevice] = useQueryState('device');
|
||||||
|
|
||||||
if (profile) {
|
const keyValueList = [
|
||||||
bullets.push(
|
{
|
||||||
<Link
|
name: 'Duration',
|
||||||
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
value: duration ? round(duration / 1000, 1) : undefined,
|
||||||
className="flex items-center gap-1 text-black font-medium hover:underline"
|
},
|
||||||
>
|
{
|
||||||
<ProfileAvatar size="xs" {...(profile ?? {})}></ProfileAvatar>
|
name: 'Referrer',
|
||||||
{getProfileName(profile)}
|
value: referrer,
|
||||||
</Link>
|
onClick() {
|
||||||
);
|
setReferrer(referrer ?? null);
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Referrer name',
|
||||||
|
value: referrerName,
|
||||||
|
onClick() {
|
||||||
|
setReferrerName(referrerName ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Referrer type',
|
||||||
|
value: referrerType,
|
||||||
|
onClick() {
|
||||||
|
setReferrerType(referrerType ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Brand',
|
||||||
|
value: brand,
|
||||||
|
onClick() {
|
||||||
|
setBrand(brand ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Model',
|
||||||
|
value: model,
|
||||||
|
onClick() {
|
||||||
|
setModel(model ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Browser',
|
||||||
|
value: browser,
|
||||||
|
onClick() {
|
||||||
|
setBrowser(browser ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Browser version',
|
||||||
|
value: browserVersion,
|
||||||
|
onClick() {
|
||||||
|
setBrowserVersion(browserVersion ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OS',
|
||||||
|
value: os,
|
||||||
|
onClick() {
|
||||||
|
setOs(os ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OS cersion',
|
||||||
|
value: osVersion,
|
||||||
|
onClick() {
|
||||||
|
setOsVersion(osVersion ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'City',
|
||||||
|
value: city,
|
||||||
|
onClick() {
|
||||||
|
setCity(city ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Region',
|
||||||
|
value: region,
|
||||||
|
onClick() {
|
||||||
|
setRegion(region ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Country',
|
||||||
|
value: country,
|
||||||
|
onClick() {
|
||||||
|
setCountry(country ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Continent',
|
||||||
|
value: continent,
|
||||||
|
onClick() {
|
||||||
|
setContinent(continent ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Device',
|
||||||
|
value: device,
|
||||||
|
onClick() {
|
||||||
|
setDevice(device ?? null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].filter((item) => typeof item.value === 'string' && item.value);
|
||||||
|
|
||||||
if (typeof properties.duration === 'number') {
|
const propertiesList = Object.entries(properties)
|
||||||
bullets.push(`${round(properties.duration / 1000, 1)}s`);
|
.map(([name, value]) => ({
|
||||||
}
|
name,
|
||||||
|
value: value as string | number | undefined,
|
||||||
switch (name) {
|
}))
|
||||||
case 'screen_view': {
|
.filter((item) => typeof item.value === 'string' && item.value);
|
||||||
if (path) {
|
|
||||||
bullets.push(path);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bullets;
|
|
||||||
}, [name, createdAt, profile, properties, params, path]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableListItem
|
<ExpandableListItem
|
||||||
title={name.split('_').join(' ')}
|
title={name.split('_').join(' ')}
|
||||||
bullets={bullets}
|
content={
|
||||||
|
<>
|
||||||
|
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
|
||||||
|
{profile && (
|
||||||
|
<KeyValueSubtle
|
||||||
|
name="Profile"
|
||||||
|
// icon={<ProfileAvatar size="xs" {...(profile ?? {})} />}
|
||||||
|
value={getProfileName(profile)}
|
||||||
|
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{path && (
|
||||||
|
<KeyValueSubtle
|
||||||
|
name="Path"
|
||||||
|
value={path}
|
||||||
|
onClick={() => {
|
||||||
|
setPath(path);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
image={<EventIcon name={name} />}
|
image={<EventIcon name={name} />}
|
||||||
>
|
>
|
||||||
<ListProperties data={properties} className="rounded-none border-none" />
|
{propertiesList.length > 0 && (
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
<div className="font-medium">Your properties</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
{propertiesList.map((item) => (
|
||||||
|
<KeyValue key={item.name} name={item.name} value={item.value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
<div className="font-medium">Properties</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
{keyValueList.map((item) => (
|
||||||
|
<KeyValue
|
||||||
|
onClick={item.onClick}
|
||||||
|
key={item.name}
|
||||||
|
name={item.name}
|
||||||
|
value={item.value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ExpandableListItem>
|
</ExpandableListItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
|||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||||
import { GanttChartIcon } from 'lucide-react';
|
import { GanttChartIcon } from 'lucide-react';
|
||||||
|
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
|
||||||
|
|
||||||
import { EventListItem } from './event-list-item';
|
import { EventListItem } from './event-list-item';
|
||||||
|
|
||||||
@@ -15,22 +16,21 @@ interface ListEventsProps {
|
|||||||
}
|
}
|
||||||
export function ListEvents({ projectId }: ListEventsProps) {
|
export function ListEvents({ projectId }: ListEventsProps) {
|
||||||
const pagination = usePagination();
|
const pagination = usePagination();
|
||||||
const [eventFilters, setEventFilters] = useState<string[]>([]);
|
const [eventFilters, setEventFilters] = useQueryState(
|
||||||
const eventsQuery = api.event.list.useQuery(
|
'events',
|
||||||
{
|
parseAsArrayOf(parseAsString).withDefault([])
|
||||||
|
);
|
||||||
|
const eventsQuery = api.event.list.useQuery({
|
||||||
events: eventFilters,
|
events: eventFilters,
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
...pagination,
|
...pagination,
|
||||||
},
|
});
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
|
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
|
||||||
|
|
||||||
const filterEventsQuery = api.chart.events.useQuery({
|
const filterEventsQuery = api.chart.events.useQuery({
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterEvents = (filterEventsQuery.data ?? []).map((item) => ({
|
const filterEvents = (filterEventsQuery.data ?? []).map((item) => ({
|
||||||
value: item.name,
|
value: item.name,
|
||||||
label: item.name,
|
label: item.name,
|
||||||
@@ -61,7 +61,7 @@ export function ListEvents({ projectId }: ListEventsProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{events.map((item) => (
|
{events.map((item) => (
|
||||||
<EventListItem key={item.id} {...item} />
|
<EventListItem key={item.createdAt.toString()} {...item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { Button } from '../ui/button';
|
|||||||
|
|
||||||
interface ExpandableListItemProps {
|
interface ExpandableListItemProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
bullets: React.ReactNode[];
|
content: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
image?: React.ReactNode;
|
image?: React.ReactNode;
|
||||||
initialOpen?: boolean;
|
initialOpen?: boolean;
|
||||||
}
|
}
|
||||||
export function ExpandableListItem({
|
export function ExpandableListItem({
|
||||||
title,
|
title,
|
||||||
bullets,
|
content,
|
||||||
image,
|
image,
|
||||||
initialOpen = false,
|
initialOpen = false,
|
||||||
children,
|
children,
|
||||||
@@ -22,15 +22,15 @@ export function ExpandableListItem({
|
|||||||
const [open, setOpen] = useState(initialOpen ?? false);
|
const [open, setOpen] = useState(initialOpen ?? false);
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-xl overflow-hidden">
|
<div className="bg-white shadow rounded-xl overflow-hidden">
|
||||||
<div className="p-3 sm:p-6 flex gap-4 items-start">
|
<div className="p-3 sm:p-6 flex gap-4 items-center">
|
||||||
<div className="flex gap-1">{image}</div>
|
<div className="flex gap-1">{image}</div>
|
||||||
<div className="flex flex-col flex-1 gap-1 min-w-0">
|
<div className="flex flex-col flex-1 gap-1 min-w-0">
|
||||||
<span className="text-lg font-medium leading-none mb-1">{title}</span>
|
<span className="text-lg font-medium leading-none mb-1">{title}</span>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-sm text-muted-foreground">
|
{!!content && (
|
||||||
{bullets.map((bullet, index) => (
|
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
|
||||||
<span key={index}>{bullet}</span>
|
{content}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
51
apps/web/src/components/ui/key-value.tsx
Normal file
51
apps/web/src/components/ui/key-value.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface KeyValueProps {
|
||||||
|
name: string;
|
||||||
|
value: string | number | undefined;
|
||||||
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyValue({ href, onClick, name, value }: KeyValueProps) {
|
||||||
|
const clickable = href || onClick;
|
||||||
|
const Component = href ? (Link as any) : onClick ? 'button' : 'div';
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className="group flex border border-border rounded-md text-xs divide-x font-medium self-start min-w-0 max-w-full"
|
||||||
|
{...{ href, onClick }}
|
||||||
|
>
|
||||||
|
<div className="p-1 px-2">{name}</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-1 px-2 font-mono text-blue-700 bg-slate-50 whitespace-nowrap overflow-hidden text-ellipsis',
|
||||||
|
clickable && 'group-hover:underline'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyValueSubtle({ href, onClick, name, value }: KeyValueProps) {
|
||||||
|
const clickable = href || onClick;
|
||||||
|
const Component = href ? (Link as any) : onClick ? 'button' : 'div';
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className="group flex text-xs gap-2 font-medium self-start min-w-0 max-w-full items-center"
|
||||||
|
{...{ href, onClick }}
|
||||||
|
>
|
||||||
|
<div className="text-gray-400">{name}</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-slate-100 rounded p-1 px-2 text-gray-600 whitespace-nowrap overflow-hidden text-ellipsis',
|
||||||
|
clickable && 'group-hover:underline'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "event_meta" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"conversion" BOOLEAN NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "event_meta_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -165,3 +165,13 @@ model ShareOverview {
|
|||||||
|
|
||||||
@@map("shares")
|
@@map("shares")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model EventMeta {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
name String
|
||||||
|
conversion Boolean
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("event_meta")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user