improve event list / event details
This commit is contained in:
@@ -249,6 +249,7 @@ export async function postEvent(
|
|||||||
if (duration < 0) {
|
if (duration < 0) {
|
||||||
contextLogger.send('duration is wrong', {
|
contextLogger.send('duration is wrong', {
|
||||||
payload,
|
payload,
|
||||||
|
duration,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Skip update duration if it's wrong
|
// Skip update duration if it's wrong
|
||||||
@@ -270,6 +271,7 @@ export async function postEvent(
|
|||||||
} else if (payload.name !== 'screen_view') {
|
} else if (payload.name !== 'screen_view') {
|
||||||
contextLogger.send('no previous job', {
|
contextLogger.send('no previous job', {
|
||||||
prevEventJob,
|
prevEventJob,
|
||||||
|
payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,3 +78,32 @@ export function wsVisitors(
|
|||||||
redisSub.off('pmessage', pmessage);
|
redisSub.off('pmessage', pmessage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wsEvents(
|
||||||
|
connection: {
|
||||||
|
socket: WebSocket;
|
||||||
|
},
|
||||||
|
req: FastifyRequest<{
|
||||||
|
Params: {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const { params } = req;
|
||||||
|
|
||||||
|
redisSub.subscribe('event');
|
||||||
|
|
||||||
|
const message = (channel: string, message: string) => {
|
||||||
|
const event = getSafeJson<IServiceCreateEventPayload>(message);
|
||||||
|
if (event?.projectId === params.projectId) {
|
||||||
|
connection.socket.send(JSON.stringify(event));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
redisSub.on('message', message);
|
||||||
|
|
||||||
|
connection.socket.on('close', () => {
|
||||||
|
redisSub.unsubscribe('event');
|
||||||
|
redisSub.off('message', message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const liveRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
|||||||
{ websocket: true },
|
{ websocket: true },
|
||||||
controller.wsVisitors
|
controller.wsVisitors
|
||||||
);
|
);
|
||||||
|
fastify.get('/events/:projectId', { websocket: true }, controller.wsEvents);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ChartSwitchShortcut } from '@/components/report/chart';
|
||||||
|
|
||||||
|
import type { IChartEvent } from '@mixan/validation';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
events?: string[];
|
||||||
|
filters?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventChart({ projectId, filters, events }: Props) {
|
||||||
|
const fallback: IChartEvent[] = [
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: '*',
|
||||||
|
displayName: 'All events',
|
||||||
|
segment: 'event',
|
||||||
|
filters: filters ?? [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4 mb-8">
|
||||||
|
<ChartSwitchShortcut
|
||||||
|
projectId={projectId}
|
||||||
|
range="1m"
|
||||||
|
chartType="histogram"
|
||||||
|
events={
|
||||||
|
events && events.length > 0
|
||||||
|
? events.map((name) => ({
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
displayName: name,
|
||||||
|
segment: 'event',
|
||||||
|
filters: filters ?? [],
|
||||||
|
}))
|
||||||
|
: fallback
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
|
||||||
|
import { Chart } from '@/components/report/chart/Chart';
|
||||||
|
import { KeyValue } from '@/components/ui/key-value';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
|
import { round } from 'mathjs';
|
||||||
|
|
||||||
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: IServiceCreateEventPayload;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
export function EventDetails({ event, open, setOpen }: Props) {
|
||||||
|
const { name } = event;
|
||||||
|
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
||||||
|
const common = [
|
||||||
|
{
|
||||||
|
name: 'Duration',
|
||||||
|
value: event.duration ? round(event.duration / 1000, 1) : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Referrer',
|
||||||
|
value: event.referrer,
|
||||||
|
onClick() {
|
||||||
|
setFilter('referrer', event.referrer ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Referrer name',
|
||||||
|
value: event.referrerName,
|
||||||
|
onClick() {
|
||||||
|
setFilter('referrer_name', event.referrerName ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Referrer type',
|
||||||
|
value: event.referrerType,
|
||||||
|
onClick() {
|
||||||
|
setFilter('referrer_type', event.referrerType ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Brand',
|
||||||
|
value: event.brand,
|
||||||
|
onClick() {
|
||||||
|
setFilter('brand', event.brand ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Model',
|
||||||
|
value: event.model,
|
||||||
|
onClick() {
|
||||||
|
setFilter('model', event.model ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Browser',
|
||||||
|
value: event.browser,
|
||||||
|
onClick() {
|
||||||
|
setFilter('browser', event.browser ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Browser version',
|
||||||
|
value: event.browserVersion,
|
||||||
|
onClick() {
|
||||||
|
setFilter('browser_version', event.browserVersion ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OS',
|
||||||
|
value: event.os,
|
||||||
|
onClick() {
|
||||||
|
setFilter('os', event.os ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OS version',
|
||||||
|
value: event.osVersion,
|
||||||
|
onClick() {
|
||||||
|
setFilter('os_version', event.osVersion ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'City',
|
||||||
|
value: event.city,
|
||||||
|
onClick() {
|
||||||
|
setFilter('city', event.city ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Region',
|
||||||
|
value: event.region,
|
||||||
|
onClick() {
|
||||||
|
setFilter('region', event.region ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Country',
|
||||||
|
value: event.country,
|
||||||
|
onClick() {
|
||||||
|
setFilter('country', event.country ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Continent',
|
||||||
|
value: event.continent,
|
||||||
|
onClick() {
|
||||||
|
setFilter('continent', event.continent ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Device',
|
||||||
|
value: event.device,
|
||||||
|
onClick() {
|
||||||
|
setFilter('device', event.device ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].filter((item) => typeof item.value === 'string' && item.value);
|
||||||
|
|
||||||
|
const properties = Object.entries(event.properties)
|
||||||
|
.map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: value as string | number | undefined,
|
||||||
|
}))
|
||||||
|
.filter((item) => typeof item.value === 'string' && item.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetContent className="overflow-y-scroll">
|
||||||
|
<div className="overflow-y-scroll">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{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">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="text-sm font-medium mb-2">Similar events</div>
|
||||||
|
<ChartSwitchShortcut
|
||||||
|
projectId={event.projectId}
|
||||||
|
chartType="histogram"
|
||||||
|
events={[
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: event.name,
|
||||||
|
displayName: 'Similar events',
|
||||||
|
segment: 'event',
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '@/app/_trpc/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EventIconColors,
|
||||||
|
EventIconMapper,
|
||||||
|
EventIconRecords,
|
||||||
|
} from './event-icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: IServiceCreateEventPayload;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventEdit({ event, open, setOpen }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { name, meta, projectId } = event;
|
||||||
|
|
||||||
|
const [selectedIcon, setIcon] = useState(
|
||||||
|
meta?.icon ??
|
||||||
|
EventIconRecords[name]?.icon ??
|
||||||
|
EventIconRecords.default?.icon ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
const [selectedColor, setColor] = useState(
|
||||||
|
meta?.color ??
|
||||||
|
EventIconRecords[name]?.color ??
|
||||||
|
EventIconRecords.default?.color ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
const [conversion, setConversion] = useState(!!meta?.conversion);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (meta?.icon) {
|
||||||
|
setIcon(meta.icon);
|
||||||
|
}
|
||||||
|
}, [meta?.icon]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (meta?.color) {
|
||||||
|
setColor(meta.color);
|
||||||
|
}
|
||||||
|
}, [meta?.color]);
|
||||||
|
useEffect(() => {
|
||||||
|
setConversion(meta?.conversion ?? false);
|
||||||
|
}, [meta?.conversion]);
|
||||||
|
|
||||||
|
const SelectedIcon = EventIconMapper[selectedIcon]!;
|
||||||
|
|
||||||
|
const mutation = api.event.updateEventMeta.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
// @ts-expect-error
|
||||||
|
document.querySelector('#close-sheet')?.click();
|
||||||
|
toast('Event updated');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const getBg = (color: string) => `bg-${color}-200`;
|
||||||
|
const getText = (color: string) => `text-${color}-700`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Edit "{name}"</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex flex-col gap-8 my-8">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-4 block">Conversion</Label>
|
||||||
|
<label className="cursor-pointer flex items-center select-none border border-border rounded-md p-4 gap-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={conversion}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked === 'indeterminate') return;
|
||||||
|
setConversion(checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span>Yes, this event is important!</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-4 block">Pick a icon</Label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{Object.entries(EventIconMapper).map(([name, Icon]) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => {
|
||||||
|
setIcon(name);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer inline-flex transition-all bg-slate-100 flex items-center justify-center',
|
||||||
|
name === selectedIcon
|
||||||
|
? 'scale-110 ring-1 ring-black'
|
||||||
|
: '[&_svg]:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-4 block">Pick a color</Label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{EventIconColors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
onClick={() => {
|
||||||
|
setColor(color);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer transition-all flex justify-center items-center',
|
||||||
|
color === selectedColor ? 'ring-1 ring-black' : '',
|
||||||
|
getBg(color)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{SelectedIcon ? (
|
||||||
|
<SelectedIcon size={16} />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className={`${getText(color)} opacity-70`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12.1" cy="12.1" r="4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate({
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
icon: selectedIcon,
|
||||||
|
color: selectedColor,
|
||||||
|
conversion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Update event
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ type EventIconProps = VariantProps<typeof variants> & {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const records: Record<
|
export const EventIconRecords: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -59,9 +59,13 @@ const records: Record<
|
|||||||
icon: 'ActivityIcon',
|
icon: 'ActivityIcon',
|
||||||
color: 'teal',
|
color: 'teal',
|
||||||
},
|
},
|
||||||
|
link_out: {
|
||||||
|
icon: 'ExternalLinkIcon',
|
||||||
|
color: 'indigo',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const icons: Record<string, LucideIcon> = {
|
export const EventIconMapper: Record<string, LucideIcon> = {
|
||||||
DownloadIcon: Icons.DownloadIcon,
|
DownloadIcon: Icons.DownloadIcon,
|
||||||
BotIcon: Icons.BotIcon,
|
BotIcon: Icons.BotIcon,
|
||||||
BoxIcon: Icons.BoxIcon,
|
BoxIcon: Icons.BoxIcon,
|
||||||
@@ -76,9 +80,41 @@ const icons: Record<string, LucideIcon> = {
|
|||||||
ConeIcon: Icons.ConeIcon,
|
ConeIcon: Icons.ConeIcon,
|
||||||
MonitorPlayIcon: Icons.MonitorPlayIcon,
|
MonitorPlayIcon: Icons.MonitorPlayIcon,
|
||||||
PizzaIcon: Icons.PizzaIcon,
|
PizzaIcon: Icons.PizzaIcon,
|
||||||
|
SearchIcon: Icons.SearchIcon,
|
||||||
|
HomeIcon: Icons.HomeIcon,
|
||||||
|
MailIcon: Icons.MailIcon,
|
||||||
|
AngryIcon: Icons.AngryIcon,
|
||||||
|
AnnoyedIcon: Icons.AnnoyedIcon,
|
||||||
|
ArchiveIcon: Icons.ArchiveIcon,
|
||||||
|
AwardIcon: Icons.AwardIcon,
|
||||||
|
BadgeCheckIcon: Icons.BadgeCheckIcon,
|
||||||
|
BeerIcon: Icons.BeerIcon,
|
||||||
|
BluetoothIcon: Icons.BluetoothIcon,
|
||||||
|
BookIcon: Icons.BookIcon,
|
||||||
|
BookmarkIcon: Icons.BookmarkIcon,
|
||||||
|
BookCheckIcon: Icons.BookCheckIcon,
|
||||||
|
BookMinusIcon: Icons.BookMinusIcon,
|
||||||
|
BookPlusIcon: Icons.BookPlusIcon,
|
||||||
|
CalendarIcon: Icons.CalendarIcon,
|
||||||
|
ClockIcon: Icons.ClockIcon,
|
||||||
|
CogIcon: Icons.CogIcon,
|
||||||
|
LoaderIcon: Icons.LoaderIcon,
|
||||||
|
CrownIcon: Icons.CrownIcon,
|
||||||
|
FileIcon: Icons.FileIcon,
|
||||||
|
KeyRoundIcon: Icons.KeyRoundIcon,
|
||||||
|
GemIcon: Icons.GemIcon,
|
||||||
|
GlobeIcon: Icons.GlobeIcon,
|
||||||
|
LightbulbIcon: Icons.LightbulbIcon,
|
||||||
|
LightbulbOffIcon: Icons.LightbulbOffIcon,
|
||||||
|
LockIcon: Icons.LockIcon,
|
||||||
|
MessageCircleIcon: Icons.MessageCircleIcon,
|
||||||
|
RadioIcon: Icons.RadioIcon,
|
||||||
|
RepeatIcon: Icons.RepeatIcon,
|
||||||
|
ShareIcon: Icons.ShareIcon,
|
||||||
|
ExternalLinkIcon: Icons.ExternalLinkIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const colors = [
|
export const EventIconColors = [
|
||||||
'rose',
|
'rose',
|
||||||
'pink',
|
'pink',
|
||||||
'fuchsia',
|
'fuchsia',
|
||||||
@@ -103,152 +139,23 @@ const colors = [
|
|||||||
'slate',
|
'slate',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function EventIcon({
|
export function EventIcon({ className, name, size, meta }: EventIconProps) {
|
||||||
className,
|
|
||||||
name,
|
|
||||||
size,
|
|
||||||
meta,
|
|
||||||
projectId,
|
|
||||||
}: EventIconProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [selectedIcon, setIcon] = useState(
|
|
||||||
meta?.icon ?? records[name]?.icon ?? records.default?.icon ?? ''
|
|
||||||
);
|
|
||||||
const [selectedColor, setColor] = useState(
|
|
||||||
meta?.color ?? records[name]?.color ?? records.default?.color ?? ''
|
|
||||||
);
|
|
||||||
const [conversion, setConversion] = useState(!!meta?.conversion);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (meta?.icon) {
|
|
||||||
setIcon(meta.icon);
|
|
||||||
}
|
|
||||||
}, [meta?.icon]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (meta?.color) {
|
|
||||||
setColor(meta.color);
|
|
||||||
}
|
|
||||||
}, [meta?.color]);
|
|
||||||
useEffect(() => {
|
|
||||||
setConversion(meta?.conversion ?? false);
|
|
||||||
}, [meta?.conversion]);
|
|
||||||
|
|
||||||
const SelectedIcon = icons[selectedIcon]!;
|
|
||||||
const Icon =
|
const Icon =
|
||||||
icons[meta?.icon ?? records[name]?.icon ?? records.default?.icon ?? '']!;
|
EventIconMapper[
|
||||||
|
meta?.icon ??
|
||||||
|
EventIconRecords[name]?.icon ??
|
||||||
|
EventIconRecords.default?.icon ??
|
||||||
|
''
|
||||||
|
]!;
|
||||||
const color =
|
const color =
|
||||||
meta?.color ?? records[name]?.color ?? records.default?.color ?? '';
|
meta?.color ??
|
||||||
|
EventIconRecords[name]?.color ??
|
||||||
const mutation = api.event.updateEventMeta.useMutation({
|
EventIconRecords.default?.color ??
|
||||||
onSuccess() {
|
'';
|
||||||
// @ts-expect-error
|
|
||||||
document.querySelector('#close-sheet')?.click();
|
|
||||||
toast('Event updated');
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const getBg = (color: string) => `bg-${color}-200`;
|
|
||||||
const getText = (color: string) => `text-${color}-700`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet>
|
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
|
||||||
<SheetTrigger className={cn(getBg(color), variants({ size }), className)}>
|
<Icon size={20} className={`text-${color}-700`} />
|
||||||
<Icon size={20} className={getText(color)} />
|
</div>
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent>
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Edit "{name}"</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="flex flex-col gap-8 my-8">
|
|
||||||
<div>
|
|
||||||
<Label className="mb-4 block">Conversion</Label>
|
|
||||||
<label className="cursor-pointer flex items-center select-none border border-border rounded-md p-4 gap-4">
|
|
||||||
<Checkbox
|
|
||||||
checked={conversion}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
if (checked === 'indeterminate') return;
|
|
||||||
setConversion(checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span>Yes, this event is important!</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-4 block">Pick a icon</Label>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{Object.entries(icons).map(([name, Icon]) => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
onClick={() => {
|
|
||||||
setIcon(name);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer inline-flex transition-all bg-slate-100 flex items-center justify-center',
|
|
||||||
name === selectedIcon
|
|
||||||
? 'scale-110 ring-1 ring-black'
|
|
||||||
: '[&_svg]:opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-4 block">Pick a color</Label>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{colors.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
onClick={() => {
|
|
||||||
setColor(color);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer transition-all flex justify-center items-center',
|
|
||||||
color === selectedColor ? 'ring-1 ring-black' : '',
|
|
||||||
getBg(color)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{SelectedIcon ? (
|
|
||||||
<SelectedIcon size={16} />
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className={`${getText(color)} opacity-70`}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="12.1" cy="12.1" r="4" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
mutation.mutate({
|
|
||||||
projectId,
|
|
||||||
name,
|
|
||||||
icon: selectedIcon,
|
|
||||||
color: selectedColor,
|
|
||||||
conversion,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Update event
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
|
|
||||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
|
||||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
|
||||||
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
|
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
|
||||||
import {
|
|
||||||
useEventQueryFilters,
|
|
||||||
useEventQueryNamesFilter,
|
|
||||||
} from '@/hooks/useEventQueryFilters';
|
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { getProfileName } from '@/utils/getters';
|
|
||||||
import { round } from '@/utils/math';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { uniq } from 'ramda';
|
|
||||||
|
|
||||||
import type { IServiceCreateEventPayload } from '@mixan/db';
|
|
||||||
|
|
||||||
import { EventIcon } from './event-icon';
|
|
||||||
|
|
||||||
type EventListItemProps = IServiceCreateEventPayload;
|
|
||||||
|
|
||||||
export function EventListItem(props: EventListItemProps) {
|
|
||||||
const {
|
|
||||||
profile,
|
|
||||||
createdAt,
|
|
||||||
name,
|
|
||||||
properties,
|
|
||||||
path,
|
|
||||||
duration,
|
|
||||||
referrer,
|
|
||||||
referrerName,
|
|
||||||
referrerType,
|
|
||||||
brand,
|
|
||||||
model,
|
|
||||||
browser,
|
|
||||||
browserVersion,
|
|
||||||
os,
|
|
||||||
osVersion,
|
|
||||||
city,
|
|
||||||
region,
|
|
||||||
country,
|
|
||||||
continent,
|
|
||||||
device,
|
|
||||||
projectId,
|
|
||||||
meta,
|
|
||||||
} = props;
|
|
||||||
const params = useAppParams();
|
|
||||||
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
|
|
||||||
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
|
||||||
const keyValueList = [
|
|
||||||
{
|
|
||||||
name: 'Duration',
|
|
||||||
value: duration ? round(duration / 1000, 1) : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Referrer',
|
|
||||||
value: referrer,
|
|
||||||
onClick() {
|
|
||||||
setFilter('referrer', referrer ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Referrer name',
|
|
||||||
value: referrerName,
|
|
||||||
onClick() {
|
|
||||||
setFilter('referrer_name', referrerName ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Referrer type',
|
|
||||||
value: referrerType,
|
|
||||||
onClick() {
|
|
||||||
setFilter('referrer_type', referrerType ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Brand',
|
|
||||||
value: brand,
|
|
||||||
onClick() {
|
|
||||||
setFilter('brand', brand ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Model',
|
|
||||||
value: model,
|
|
||||||
onClick() {
|
|
||||||
setFilter('model', model ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Browser',
|
|
||||||
value: browser,
|
|
||||||
onClick() {
|
|
||||||
setFilter('browser', browser ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Browser version',
|
|
||||||
value: browserVersion,
|
|
||||||
onClick() {
|
|
||||||
setFilter('browser_version', browserVersion ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OS',
|
|
||||||
value: os,
|
|
||||||
onClick() {
|
|
||||||
setFilter('os', os ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OS version',
|
|
||||||
value: osVersion,
|
|
||||||
onClick() {
|
|
||||||
setFilter('os_version', osVersion ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'City',
|
|
||||||
value: city,
|
|
||||||
onClick() {
|
|
||||||
setFilter('city', city ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Region',
|
|
||||||
value: region,
|
|
||||||
onClick() {
|
|
||||||
setFilter('region', region ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Country',
|
|
||||||
value: country,
|
|
||||||
onClick() {
|
|
||||||
setFilter('country', country ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Continent',
|
|
||||||
value: continent,
|
|
||||||
onClick() {
|
|
||||||
setFilter('continent', continent ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Device',
|
|
||||||
value: device,
|
|
||||||
onClick() {
|
|
||||||
setFilter('device', device ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].filter((item) => typeof item.value === 'string' && item.value);
|
|
||||||
|
|
||||||
const propertiesList = Object.entries(properties)
|
|
||||||
.map(([name, value]) => ({
|
|
||||||
name,
|
|
||||||
value: value as string | number | undefined,
|
|
||||||
}))
|
|
||||||
.filter((item) => typeof item.value === 'string' && item.value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 flex gap-4">
|
|
||||||
<EventIcon name={name} meta={meta} projectId={projectId} />
|
|
||||||
<div>
|
|
||||||
{!!profile && (
|
|
||||||
<div className="flex gap-2 items-center mb-1">
|
|
||||||
<ProfileAvatar size="xs" {...profile} />
|
|
||||||
<Link
|
|
||||||
className="font-medium text-sm text-muted-foreground"
|
|
||||||
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
|
||||||
>
|
|
||||||
{getProfileName(profile)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="[&>span]:inline text-slate-700">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'font-medium bg-muted p-0.5 px-1 leading-none rounded-md',
|
|
||||||
meta?.conversion && `bg-${meta.color}-100 text-${meta.color}-800`
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{meta?.conversion && '⭐️ '}
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">{' at '}</span>
|
|
||||||
<span>{path}</span>
|
|
||||||
<span className="text-muted-foreground">{' from '}</span>
|
|
||||||
<span>
|
|
||||||
{city || 'Unknown'}, {country}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">{' using '}</span>
|
|
||||||
<span className="!inline-flex items-center gap-1">
|
|
||||||
{brand || device} <SerieIcon name={device} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
// return (
|
|
||||||
// <ExpandableListItem
|
|
||||||
// className={cn(meta?.conversion && `bg-${meta.color}-50`)}
|
|
||||||
// title={
|
|
||||||
// <button onClick={() => setEvents((p) => uniq([...p, name]))}>
|
|
||||||
// {name.split('_').join(' ')}
|
|
||||||
// </button>
|
|
||||||
// }
|
|
||||||
// content={
|
|
||||||
// <>
|
|
||||||
// <KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
|
|
||||||
// {profile?.id === props.deviceId && (
|
|
||||||
// <KeyValueSubtle name="Anonymous" value={'Yes'} />
|
|
||||||
// )}
|
|
||||||
// {profile && (
|
|
||||||
// <KeyValueSubtle
|
|
||||||
// name="Profile"
|
|
||||||
// value={getProfileName(profile)}
|
|
||||||
// href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// {path && (
|
|
||||||
// <KeyValueSubtle
|
|
||||||
// name="Path"
|
|
||||||
// value={path}
|
|
||||||
// onClick={() => {
|
|
||||||
// setFilter('path', path);
|
|
||||||
// }}
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// </>
|
|
||||||
// }
|
|
||||||
// image={<EventIcon name={name} meta={meta} projectId={projectId} />}
|
|
||||||
// >
|
|
||||||
// <div className="bg-white p-4">
|
|
||||||
// {propertiesList.length > 0 && (
|
|
||||||
// <div className="flex flex-col gap-4 mb-6">
|
|
||||||
// <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}
|
|
||||||
// onClick={() => {
|
|
||||||
// setFilter(
|
|
||||||
// `properties.${item.name}`,
|
|
||||||
// item.value ? String(item.value) : '',
|
|
||||||
// 'is'
|
|
||||||
// );
|
|
||||||
// }}
|
|
||||||
// />
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// <div className="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>
|
|
||||||
// </div>
|
|
||||||
// </ExpandableListItem>
|
|
||||||
// );
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
|
import { useState } from 'react';
|
||||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
||||||
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
|
import { KeyValueSubtle } from '@/components/ui/key-value';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import {
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
useEventQueryFilters,
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
useEventQueryNamesFilter,
|
|
||||||
} from '@/hooks/useEventQueryFilters';
|
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getProfileName } from '@/utils/getters';
|
import { getProfileName } from '@/utils/getters';
|
||||||
import { round } from '@/utils/math';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { uniq } from 'ramda';
|
|
||||||
|
|
||||||
import type { IServiceCreateEventPayload } from '@mixan/db';
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||||
|
|
||||||
|
import { EventDetails } from './event-details';
|
||||||
|
import { EventEdit } from './event-edit';
|
||||||
import { EventIcon } from './event-icon';
|
import { EventIcon } from './event-icon';
|
||||||
|
|
||||||
type EventListItemProps = IServiceCreateEventPayload;
|
type EventListItemProps = IServiceCreateEventPayload;
|
||||||
@@ -26,300 +23,111 @@ export function EventListItem(props: EventListItemProps) {
|
|||||||
profile,
|
profile,
|
||||||
createdAt,
|
createdAt,
|
||||||
name,
|
name,
|
||||||
properties,
|
|
||||||
path,
|
path,
|
||||||
duration,
|
duration,
|
||||||
referrer,
|
|
||||||
referrerName,
|
|
||||||
referrerType,
|
|
||||||
brand,
|
brand,
|
||||||
model,
|
|
||||||
browser,
|
browser,
|
||||||
browserVersion,
|
|
||||||
os,
|
|
||||||
osVersion,
|
|
||||||
city,
|
city,
|
||||||
region,
|
|
||||||
country,
|
country,
|
||||||
continent,
|
|
||||||
device,
|
device,
|
||||||
|
os,
|
||||||
projectId,
|
projectId,
|
||||||
meta,
|
meta,
|
||||||
} = props;
|
} = props;
|
||||||
const params = useAppParams();
|
const params = useAppParams();
|
||||||
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
|
|
||||||
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
||||||
const keyValueList = [
|
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||||
{
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
name: 'Duration',
|
const number = useNumber();
|
||||||
value: duration ? round(duration / 1000, 1) : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Referrer',
|
|
||||||
value: referrer,
|
|
||||||
onClick() {
|
|
||||||
setFilter('referrer', referrer ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Referrer name',
|
|
||||||
value: referrerName,
|
|
||||||
onClick() {
|
|
||||||
setFilter('referrer_name', referrerName ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Referrer type',
|
|
||||||
value: referrerType,
|
|
||||||
onClick() {
|
|
||||||
setFilter('referrer_type', referrerType ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Brand',
|
|
||||||
value: brand,
|
|
||||||
onClick() {
|
|
||||||
setFilter('brand', brand ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Model',
|
|
||||||
value: model,
|
|
||||||
onClick() {
|
|
||||||
setFilter('model', model ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Browser',
|
|
||||||
value: browser,
|
|
||||||
onClick() {
|
|
||||||
setFilter('browser', browser ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Browser version',
|
|
||||||
value: browserVersion,
|
|
||||||
onClick() {
|
|
||||||
setFilter('browser_version', browserVersion ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OS',
|
|
||||||
value: os,
|
|
||||||
onClick() {
|
|
||||||
setFilter('os', os ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OS version',
|
|
||||||
value: osVersion,
|
|
||||||
onClick() {
|
|
||||||
setFilter('os_version', osVersion ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'City',
|
|
||||||
value: city,
|
|
||||||
onClick() {
|
|
||||||
setFilter('city', city ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Region',
|
|
||||||
value: region,
|
|
||||||
onClick() {
|
|
||||||
setFilter('region', region ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Country',
|
|
||||||
value: country,
|
|
||||||
onClick() {
|
|
||||||
setFilter('country', country ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Continent',
|
|
||||||
value: continent,
|
|
||||||
onClick() {
|
|
||||||
setFilter('continent', continent ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Device',
|
|
||||||
value: device,
|
|
||||||
onClick() {
|
|
||||||
setFilter('device', device ?? '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].filter((item) => typeof item.value === 'string' && item.value);
|
|
||||||
|
|
||||||
const propertiesList = Object.entries(properties)
|
|
||||||
.map(([name, value]) => ({
|
|
||||||
name,
|
|
||||||
value: value as string | number | undefined,
|
|
||||||
}))
|
|
||||||
.filter((item) => typeof item.value === 'string' && item.value);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 flex flex-col gap-2 hover:bg-slate-50 rounded-lg transition-colors">
|
<>
|
||||||
<div className="flex justify-between items-center">
|
<EventDetails
|
||||||
<div className="flex gap-4 items-center">
|
event={props}
|
||||||
<EventIcon name={name} meta={meta} projectId={projectId} />
|
open={isDetailsOpen}
|
||||||
<div className="font-semibold">{name.replace(/_/g, ' ')}</div>
|
setOpen={setIsDetailsOpen}
|
||||||
</div>
|
/>
|
||||||
<div className="text-muted-foreground">
|
<EventEdit event={props} open={isEditOpen} setOpen={setIsEditOpen} />
|
||||||
{createdAt.toLocaleTimeString()}
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
</div>
|
'p-4 flex flex-col gap-2 hover:bg-slate-50 rounded-lg transition-colors',
|
||||||
<div className="flex flex-wrap gap-2">
|
meta?.conversion && `bg-${meta.color}-50 hover:bg-${meta.color}-100`
|
||||||
{path && <KeyValueSubtle name={'Path'} value={path} />}
|
|
||||||
{profile && (
|
|
||||||
<KeyValueSubtle
|
|
||||||
name={'Profile'}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{profile.avatar && <ProfileAvatar size="xs" {...profile} />}
|
|
||||||
{getProfileName(profile)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<KeyValueSubtle
|
>
|
||||||
name={'From'}
|
<div className="flex justify-between items-center">
|
||||||
value={
|
<div className="flex gap-4 items-center">
|
||||||
<>
|
<button onClick={() => setIsEditOpen(true)}>
|
||||||
<SerieIcon name={country} />
|
<EventIcon name={name} meta={meta} projectId={projectId} />
|
||||||
{city}
|
</button>
|
||||||
</>
|
<button
|
||||||
}
|
onClick={() => setIsDetailsOpen(true)}
|
||||||
/>
|
className="font-semibold hover:underline"
|
||||||
<KeyValueSubtle
|
|
||||||
name={'Device'}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
<SerieIcon name={device} />
|
|
||||||
{brand}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{browser !== 'WebKit' && browser !== '' && (
|
|
||||||
<KeyValueSubtle
|
|
||||||
name={'Browser'}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
<SerieIcon name={browser} />
|
|
||||||
{browser}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* {!!profile && (
|
|
||||||
<div className="flex gap-2 items-center mb-1">
|
|
||||||
<ProfileAvatar size="xs" {...profile} />
|
|
||||||
<Link
|
|
||||||
className="font-medium text-sm text-muted-foreground"
|
|
||||||
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
|
||||||
>
|
>
|
||||||
{getProfileName(profile)}
|
{name.replace(/_/g, ' ')}
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="text-muted-foreground text-sm">
|
||||||
<div className="[&>span]:inline text-slate-700">
|
{createdAt.toLocaleTimeString()}
|
||||||
<span
|
</div>
|
||||||
className={cn(
|
</div>
|
||||||
'font-medium bg-muted p-0.5 px-1 leading-none rounded-md',
|
<div className="flex flex-wrap gap-2">
|
||||||
meta?.conversion && `bg-${meta.color}-100 text-${meta.color}-800`
|
{path && (
|
||||||
)}
|
<KeyValueSubtle
|
||||||
>
|
name={'Path'}
|
||||||
{meta?.conversion && '⭐️ '}
|
value={
|
||||||
{name}
|
path +
|
||||||
</span>
|
(duration
|
||||||
<span className="text-muted-foreground">{' at '}</span>
|
? ` (${number.shortWithUnit(duration / 1000, 'min')})`
|
||||||
<span>{path}</span>
|
: '')
|
||||||
<span className="text-muted-foreground">{' from '}</span>
|
}
|
||||||
<span>
|
/>
|
||||||
{city || 'Unknown'}, {country}
|
)}
|
||||||
</span>
|
{profile && (
|
||||||
<span className="text-muted-foreground">{' using '}</span>
|
<KeyValueSubtle
|
||||||
<span className="!inline-flex items-center gap-1">
|
name={'Profile'}
|
||||||
{brand || device} <SerieIcon name={device} />
|
value={
|
||||||
</span>
|
<>
|
||||||
</div> */}
|
{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>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
// return (
|
|
||||||
// <ExpandableListItem
|
|
||||||
// className={cn(meta?.conversion && `bg-${meta.color}-50`)}
|
|
||||||
// title={
|
|
||||||
// <button onClick={() => setEvents((p) => uniq([...p, name]))}>
|
|
||||||
// {name.split('_').join(' ')}
|
|
||||||
// </button>
|
|
||||||
// }
|
|
||||||
// content={
|
|
||||||
// <>
|
|
||||||
// <KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
|
|
||||||
// {profile?.id === props.deviceId && (
|
|
||||||
// <KeyValueSubtle name="Anonymous" value={'Yes'} />
|
|
||||||
// )}
|
|
||||||
// {profile && (
|
|
||||||
// <KeyValueSubtle
|
|
||||||
// name="Profile"
|
|
||||||
// value={getProfileName(profile)}
|
|
||||||
// href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// {path && (
|
|
||||||
// <KeyValueSubtle
|
|
||||||
// name="Path"
|
|
||||||
// value={path}
|
|
||||||
// onClick={() => {
|
|
||||||
// setFilter('path', path);
|
|
||||||
// }}
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// </>
|
|
||||||
// }
|
|
||||||
// image={<EventIcon name={name} meta={meta} projectId={projectId} />}
|
|
||||||
// >
|
|
||||||
// <div className="bg-white p-4">
|
|
||||||
// {propertiesList.length > 0 && (
|
|
||||||
// <div className="flex flex-col gap-4 mb-6">
|
|
||||||
// <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}
|
|
||||||
// onClick={() => {
|
|
||||||
// setFilter(
|
|
||||||
// `properties.${item.name}`,
|
|
||||||
// item.value ? String(item.value) : '',
|
|
||||||
// 'is'
|
|
||||||
// );
|
|
||||||
// }}
|
|
||||||
// />
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// <div className="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>
|
|
||||||
// </div>
|
|
||||||
// </ExpandableListItem>
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { Fragment, Suspense } from 'react';
|
import { Fragment, Suspense } from 'react';
|
||||||
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
||||||
import { Pagination } from '@/components/Pagination';
|
import { Pagination } from '@/components/Pagination';
|
||||||
|
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { useCursor } from '@/hooks/useCursor';
|
import { useCursor } from '@/hooks/useCursor';
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { isSameDay } from 'date-fns';
|
import { isSameDay } from 'date-fns';
|
||||||
@@ -12,6 +14,7 @@ import { GanttChartIcon } from 'lucide-react';
|
|||||||
import type { IServiceCreateEventPayload } from '@mixan/db';
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||||
|
|
||||||
import { EventListItem } from './event-list-item';
|
import { EventListItem } from './event-list-item';
|
||||||
|
import EventListener from './event-listener';
|
||||||
|
|
||||||
function showDateHeader(a: Date, b?: Date) {
|
function showDateHeader(a: Date, b?: Date) {
|
||||||
if (!b) return true;
|
if (!b) return true;
|
||||||
@@ -26,64 +29,62 @@ export function EventList({ data, count }: EventListProps) {
|
|||||||
const { cursor, setCursor } = useCursor();
|
const { cursor, setCursor } = useCursor();
|
||||||
const [filters] = useEventQueryFilters();
|
const [filters] = useEventQueryFilters();
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<>
|
||||||
<div className="p-4">
|
{data.length === 0 ? (
|
||||||
{data.length === 0 ? (
|
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
|
||||||
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
|
{cursor !== 0 ? (
|
||||||
{cursor !== 0 ? (
|
<>
|
||||||
<>
|
<p>Looks like you have reached the end of the list</p>
|
||||||
<p>Looks like you have reached the end of the list</p>
|
<Button
|
||||||
<Button
|
className="mt-4"
|
||||||
className="mt-4"
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setCursor((p) => Math.max(0, p - 1))}
|
||||||
onClick={() => setCursor((p) => Math.max(0, p - 1))}
|
>
|
||||||
>
|
Go back
|
||||||
Go back
|
</Button>
|
||||||
</Button>
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
{filters.length ? (
|
||||||
{filters.length ? (
|
<p>Could not find any events with your filter</p>
|
||||||
<p>Could not find any events with your filter</p>
|
) : (
|
||||||
) : (
|
<p>We have not recieved any events yet</p>
|
||||||
<p>We have not recieved any events yet</p>
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FullPageEmptyState>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<EventListener />
|
||||||
|
<Pagination
|
||||||
|
cursor={cursor}
|
||||||
|
setCursor={setCursor}
|
||||||
|
count={count}
|
||||||
|
take={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col my-4 card p-4">
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
</>
|
<EventListItem {...item} />
|
||||||
)}
|
</Fragment>
|
||||||
</FullPageEmptyState>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<>
|
<Pagination
|
||||||
<Pagination
|
cursor={cursor}
|
||||||
cursor={cursor}
|
setCursor={setCursor}
|
||||||
setCursor={setCursor}
|
count={count}
|
||||||
count={count}
|
take={50}
|
||||||
take={50}
|
/>
|
||||||
/>
|
</>
|
||||||
<div className="flex flex-col my-4 card p-4">
|
)}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<EventListItem {...item} />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Pagination
|
|
||||||
cursor={cursor}
|
|
||||||
setCursor={setCursor}
|
|
||||||
count={count}
|
|
||||||
take={50}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import useWebSocket from 'react-use-websocket';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||||
|
|
||||||
|
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div>0</div>,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function EventListener() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { projectId } = useAppParams();
|
||||||
|
const ws = String(process.env.NEXT_PUBLIC_API_URL)
|
||||||
|
.replace(/^https/, 'wss')
|
||||||
|
.replace(/^http/, 'ws');
|
||||||
|
const [counter, setCounter] = useState(0);
|
||||||
|
const [socketUrl] = useState(`${ws}/live/events/${projectId}`);
|
||||||
|
|
||||||
|
useWebSocket(socketUrl, {
|
||||||
|
shouldReconnect: () => true,
|
||||||
|
onMessage(payload) {
|
||||||
|
const event = JSON.parse(payload.data) as IServiceCreateEventPayload;
|
||||||
|
if (event?.name) {
|
||||||
|
setCounter((prev) => prev + 1);
|
||||||
|
toast(`New event ${event.name} from ${event.country}!`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCounter(0);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
className="bg-white border border-border rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{counter === 0 ? (
|
||||||
|
'Listening to events'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AnimatedNumbers
|
||||||
|
includeComma
|
||||||
|
transitions={(index) => ({
|
||||||
|
type: 'spring',
|
||||||
|
duration: index + 0.3,
|
||||||
|
|
||||||
|
damping: 10,
|
||||||
|
stiffness: 200,
|
||||||
|
})}
|
||||||
|
animateToNumber={counter}
|
||||||
|
locale="en"
|
||||||
|
/>
|
||||||
|
new events
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{counter === 0 ? 'Listening to new events' : 'Click to refresh'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { parseAsInteger } from 'nuqs';
|
|||||||
import { getEventList, getEventsCount } from '@mixan/db';
|
import { getEventList, getEventsCount } from '@mixan/db';
|
||||||
|
|
||||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||||
|
import { EventChart } from './event-chart';
|
||||||
import { EventList } from './event-list';
|
import { EventList } from './event-list';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -33,18 +34,24 @@ export default async function Page({
|
|||||||
params: { projectId, organizationId },
|
params: { projectId, organizationId },
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
|
const filters =
|
||||||
|
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined;
|
||||||
|
const eventsFilter = eventQueryNamesFilter.parseServerSide(
|
||||||
|
searchParams.events ?? ''
|
||||||
|
);
|
||||||
const [events, count] = await Promise.all([
|
const [events, count] = await Promise.all([
|
||||||
getEventList({
|
getEventList({
|
||||||
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
|
cursor:
|
||||||
|
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
|
||||||
projectId,
|
projectId,
|
||||||
take: 50,
|
take: 50,
|
||||||
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
|
events: eventsFilter,
|
||||||
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
|
filters,
|
||||||
}),
|
}),
|
||||||
getEventsCount({
|
getEventsCount({
|
||||||
projectId,
|
projectId,
|
||||||
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
|
events: eventsFilter,
|
||||||
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
|
filters,
|
||||||
}),
|
}),
|
||||||
getExists(organizationId, projectId),
|
getExists(organizationId, projectId),
|
||||||
]);
|
]);
|
||||||
@@ -63,7 +70,14 @@ export default async function Page({
|
|||||||
nuqsOptions={nuqsOptions}
|
nuqsOptions={nuqsOptions}
|
||||||
/>
|
/>
|
||||||
</StickyBelowHeader>
|
</StickyBelowHeader>
|
||||||
<EventList data={events} count={count} />
|
<div className="p-4">
|
||||||
|
<EventChart
|
||||||
|
projectId={projectId}
|
||||||
|
events={eventsFilter}
|
||||||
|
filters={filters}
|
||||||
|
/>
|
||||||
|
<EventList data={events} count={count} />
|
||||||
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,12 +50,15 @@ export default async function Page({
|
|||||||
projectId,
|
projectId,
|
||||||
profileId,
|
profileId,
|
||||||
take: 50,
|
take: 50,
|
||||||
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
|
cursor:
|
||||||
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
|
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
|
||||||
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
|
events: eventQueryNamesFilter.parseServerSide(searchParams.events ?? ''),
|
||||||
|
filters:
|
||||||
|
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ??
|
||||||
|
undefined,
|
||||||
};
|
};
|
||||||
const startDate = parseAsString.parse(searchParams.startDate);
|
const startDate = parseAsString.parseServerSide(searchParams.startDate);
|
||||||
const endDate = parseAsString.parse(searchParams.endDate);
|
const endDate = parseAsString.parseServerSide(searchParams.endDate);
|
||||||
const [profile, events, count, conversions] = await Promise.all([
|
const [profile, events, count, conversions] = await Promise.all([
|
||||||
getProfileById(profileId),
|
getProfileById(profileId),
|
||||||
getEventList(eventListOptions),
|
getEventList(eventListOptions),
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ export default async function Page({
|
|||||||
getProfileList({
|
getProfileList({
|
||||||
projectId,
|
projectId,
|
||||||
take: 50,
|
take: 50,
|
||||||
cursor: parseAsInteger.parse(cursor ?? '') ?? undefined,
|
cursor: parseAsInteger.parseServerSide(cursor ?? '') ?? undefined,
|
||||||
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
|
filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined,
|
||||||
}),
|
}),
|
||||||
getProfileListCount({
|
getProfileListCount({
|
||||||
projectId,
|
projectId,
|
||||||
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
|
filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined,
|
||||||
}),
|
}),
|
||||||
getExists(organizationId, projectId),
|
getExists(organizationId, projectId),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -17,3 +17,36 @@ export const ChartSwitch = withChartProivder(function ChartSwitch(
|
|||||||
|
|
||||||
return <Chart {...props} />;
|
return <Chart {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface ChartSwitchShortcutProps {
|
||||||
|
projectId: ReportChartProps['projectId'];
|
||||||
|
range?: ReportChartProps['range'];
|
||||||
|
previous?: ReportChartProps['previous'];
|
||||||
|
chartType?: ReportChartProps['chartType'];
|
||||||
|
interval?: ReportChartProps['interval'];
|
||||||
|
events: ReportChartProps['events'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartSwitchShortcut = ({
|
||||||
|
projectId,
|
||||||
|
range = '7d',
|
||||||
|
previous = false,
|
||||||
|
chartType = 'linear',
|
||||||
|
interval = 'day',
|
||||||
|
events,
|
||||||
|
}: ChartSwitchShortcutProps) => {
|
||||||
|
return (
|
||||||
|
<ChartSwitch
|
||||||
|
projectId={projectId}
|
||||||
|
range={range}
|
||||||
|
breakdowns={[]}
|
||||||
|
previous={previous}
|
||||||
|
chartType={chartType}
|
||||||
|
interval={interval}
|
||||||
|
name="Random"
|
||||||
|
lineType="bump"
|
||||||
|
metric="sum"
|
||||||
|
events={events}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -31,16 +31,16 @@ const SheetOverlay = React.forwardRef<
|
|||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
const sheetVariants = cva(
|
||||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
'overflow-y-auto fixed z-50 gap-4 bg-background p-6 rounded-lg shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
top: 'inset-x-4 top-4 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
bottom:
|
bottom:
|
||||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
'inset-x-4 bottom-4 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
left: 'top-4 bottom-4 left-4 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||||
right:
|
right:
|
||||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
'top-4 bottom-4 right-4 w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -55,10 +55,12 @@ interface SheetContentProps
|
|||||||
|
|
||||||
const SheetContent = React.forwardRef<
|
const SheetContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
SheetContentProps
|
SheetContentProps & {
|
||||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
>(({ side = 'right', className, children, onClose, ...props }, ref) => (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay onClick={onClose} />
|
||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(sheetVariants({ side }), className)}
|
className={cn(sheetVariants({ side }), className)}
|
||||||
@@ -66,7 +68,10 @@ const SheetContent = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SheetPrimitive.Close id="close-sheet" className="hidden" />
|
<SheetPrimitive.Close id="close-sheet" className="hidden" />
|
||||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
<SheetPrimitive.Close
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||||
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isNil } from 'ramda';
|
|||||||
export function fancyMinutes(time: number) {
|
export function fancyMinutes(time: number) {
|
||||||
const minutes = Math.floor(time / 60);
|
const minutes = Math.floor(time / 60);
|
||||||
const seconds = round(time - minutes * 60, 0);
|
const seconds = round(time - minutes * 60, 0);
|
||||||
|
if (minutes === 0) return `${seconds}s`;
|
||||||
return `${minutes}m ${seconds}s`;
|
return `${minutes}m ${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const config = {
|
|||||||
return [
|
return [
|
||||||
`text-${color}-${variant}`,
|
`text-${color}-${variant}`,
|
||||||
`bg-${color}-${variant}`,
|
`bg-${color}-${variant}`,
|
||||||
|
`hover:bg-${color}-${variant}`,
|
||||||
`border-${color}-${variant}`,
|
`border-${color}-${variant}`,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ const config = {
|
|||||||
trailingComma: 'es5',
|
trailingComma: 'es5',
|
||||||
printWidth: 80,
|
printWidth: 80,
|
||||||
tabWidth: 2,
|
tabWidth: 2,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default config
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user