diff --git a/apps/sdk-api/src/controllers/event.controller.ts b/apps/sdk-api/src/controllers/event.controller.ts index a1aa9c18..d1fc64fc 100644 --- a/apps/sdk-api/src/controllers/event.controller.ts +++ b/apps/sdk-api/src/controllers/event.controller.ts @@ -249,6 +249,7 @@ export async function postEvent( if (duration < 0) { contextLogger.send('duration is wrong', { payload, + duration, }); } else { // Skip update duration if it's wrong @@ -270,6 +271,7 @@ export async function postEvent( } else if (payload.name !== 'screen_view') { contextLogger.send('no previous job', { prevEventJob, + payload, }); } diff --git a/apps/sdk-api/src/controllers/live.controller.ts b/apps/sdk-api/src/controllers/live.controller.ts index ad8ef978..3949218b 100644 --- a/apps/sdk-api/src/controllers/live.controller.ts +++ b/apps/sdk-api/src/controllers/live.controller.ts @@ -78,3 +78,32 @@ export function wsVisitors( 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(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); + }); +} diff --git a/apps/sdk-api/src/routes/live.router.ts b/apps/sdk-api/src/routes/live.router.ts index bb96b94f..b8318688 100644 --- a/apps/sdk-api/src/routes/live.router.ts +++ b/apps/sdk-api/src/routes/live.router.ts @@ -17,6 +17,7 @@ const liveRouter: FastifyPluginCallback = (fastify, opts, done) => { { websocket: true }, controller.wsVisitors ); + fastify.get('/events/:projectId', { websocket: true }, controller.wsEvents); done(); }); diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-chart.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-chart.tsx new file mode 100644 index 00000000..59f9c5c4 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-chart.tsx @@ -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 ( +
+ 0 + ? events.map((name) => ({ + id: name, + name, + displayName: name, + segment: 'event', + filters: filters ?? [], + })) + : fallback + } + /> +
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-details.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-details.tsx new file mode 100644 index 00000000..67709043 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-details.tsx @@ -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>; +} +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 ( + + +
+
+ + {name.replace('_', ' ')} + + + {properties.length > 0 && ( +
+
Params
+
+ {properties.map((item) => ( + { + setFilter( + `properties.${item.name}`, + item.value ? String(item.value) : '', + 'is' + ); + }} + /> + ))} +
+
+ )} +
+
Common
+
+ {common.map((item) => ( + + ))} +
+
+ +
+
Similar events
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-edit.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-edit.tsx new file mode 100644 index 00000000..a6663e15 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-edit.tsx @@ -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>; +} + +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 ( + + + + Edit "{name}" + +
+
+ + +
+
+ +
+ {Object.entries(EventIconMapper).map(([name, Icon]) => ( + + ))} +
+
+
+ +
+ {EventIconColors.map((color) => ( + + ))} +
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx index 99b90806..8cf7a438 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx @@ -40,7 +40,7 @@ type EventIconProps = VariantProps & { className?: string; }; -const records: Record< +export const EventIconRecords: Record< string, { icon: string; @@ -59,9 +59,13 @@ const records: Record< icon: 'ActivityIcon', color: 'teal', }, + link_out: { + icon: 'ExternalLinkIcon', + color: 'indigo', + }, }; -const icons: Record = { +export const EventIconMapper: Record = { DownloadIcon: Icons.DownloadIcon, BotIcon: Icons.BotIcon, BoxIcon: Icons.BoxIcon, @@ -76,9 +80,41 @@ const icons: Record = { ConeIcon: Icons.ConeIcon, MonitorPlayIcon: Icons.MonitorPlayIcon, 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', 'pink', 'fuchsia', @@ -103,152 +139,23 @@ const colors = [ 'slate', ]; -export function EventIcon({ - 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]!; +export function EventIcon({ className, name, size, meta }: EventIconProps) { const Icon = - icons[meta?.icon ?? records[name]?.icon ?? records.default?.icon ?? '']!; + EventIconMapper[ + meta?.icon ?? + EventIconRecords[name]?.icon ?? + EventIconRecords.default?.icon ?? + '' + ]!; const color = - meta?.color ?? records[name]?.color ?? records.default?.color ?? ''; - - 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`; + meta?.color ?? + EventIconRecords[name]?.color ?? + EventIconRecords.default?.color ?? + ''; return ( - - - - - - - Edit "{name}" - -
-
- - -
-
- -
- {Object.entries(icons).map(([name, Icon]) => ( - - ))} -
-
-
- -
- {colors.map((color) => ( - - ))} -
-
-
- - - - -
-
+
+ +
); } diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item copy.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item copy.tsx deleted file mode 100644 index 9fac09a9..00000000 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item copy.tsx +++ /dev/null @@ -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 ( -
- -
- {!!profile && ( -
- - - {getProfileName(profile)} - -
- )} -
- - {meta?.conversion && '⭐️ '} - {name} - - {' at '} - {path} - {' from '} - - {city || 'Unknown'}, {country} - - {' using '} - - {brand || device} - -
-
-
- ); - // return ( - // setEvents((p) => uniq([...p, name]))}> - // {name.split('_').join(' ')} - // - // } - // content={ - // <> - // - // {profile?.id === props.deviceId && ( - // - // )} - // {profile && ( - // - // )} - // {path && ( - // { - // setFilter('path', path); - // }} - // /> - // )} - // - // } - // image={} - // > - //
- // {propertiesList.length > 0 && ( - //
- //
Your properties
- //
- // {propertiesList.map((item) => ( - // { - // setFilter( - // `properties.${item.name}`, - // item.value ? String(item.value) : '', - // 'is' - // ); - // }} - // /> - // ))} - //
- //
- // )} - //
- //
Properties
- //
- // {keyValueList.map((item) => ( - // item.onClick?.()} - // key={item.name} - // name={item.name} - // value={item.value} - // /> - // ))} - //
- //
- //
- //
- // ); -} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx index 17964aac..6f94c072 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx @@ -1,22 +1,19 @@ 'use client'; -import { ExpandableListItem } from '@/components/general/ExpandableListItem'; +import { useState } from 'react'; import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; 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 { - useEventQueryFilters, - useEventQueryNamesFilter, -} from '@/hooks/useEventQueryFilters'; +import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; +import { useNumber } from '@/hooks/useNumerFormatter'; 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 { EventDetails } from './event-details'; +import { EventEdit } from './event-edit'; import { EventIcon } from './event-icon'; type EventListItemProps = IServiceCreateEventPayload; @@ -26,300 +23,111 @@ export function EventListItem(props: EventListItemProps) { profile, createdAt, name, - properties, path, duration, - referrer, - referrerName, - referrerType, brand, - model, browser, - browserVersion, - os, - osVersion, city, - region, country, - continent, device, + os, 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); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const number = useNumber(); return ( -
-
-
- -
{name.replace(/_/g, ' ')}
-
-
- {createdAt.toLocaleTimeString()} -
-
-
- {path && } - {profile && ( - - {profile.avatar && } - {getProfileName(profile)} - - } - href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`} - /> + <> + + +
- - {city} - - } - /> - - - {brand} - - } - /> - {browser !== 'WebKit' && browser !== '' && ( - - - {browser} - - } - /> - )} - {/* {!!profile && ( -
- - +
+
+ +
- )} -
- - {meta?.conversion && '⭐️ '} - {name} - - {' at '} - {path} - {' from '} - - {city || 'Unknown'}, {country} - - {' using '} - - {brand || device} - -
*/} +
+ {createdAt.toLocaleTimeString()} +
+
+
+ {path && ( + + )} + {profile && ( + + {profile.avatar && } + {getProfileName(profile)} + + } + href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`} + /> + )} + setFilter('city', city)} + value={ + <> + {country && } + {city} + + } + /> + setFilter('device', device)} + value={ + <> + {device && } + {brand || os} + + } + /> + {browser !== 'WebKit' && browser !== '' && ( + setFilter('browser', browser)} + value={ + <> + {browser && } + {browser} + + } + /> + )} +
-
+ ); - // return ( - // setEvents((p) => uniq([...p, name]))}> - // {name.split('_').join(' ')} - // - // } - // content={ - // <> - // - // {profile?.id === props.deviceId && ( - // - // )} - // {profile && ( - // - // )} - // {path && ( - // { - // setFilter('path', path); - // }} - // /> - // )} - // - // } - // image={} - // > - //
- // {propertiesList.length > 0 && ( - //
- //
Your properties
- //
- // {propertiesList.map((item) => ( - // { - // setFilter( - // `properties.${item.name}`, - // item.value ? String(item.value) : '', - // 'is' - // ); - // }} - // /> - // ))} - //
- //
- // )} - //
- //
Properties
- //
- // {keyValueList.map((item) => ( - // item.onClick?.()} - // key={item.name} - // name={item.name} - // value={item.value} - // /> - // ))} - //
- //
- //
- //
- // ); } diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx index 1f01a0cf..de643096 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx @@ -3,7 +3,9 @@ import { Fragment, Suspense } from 'react'; import { FullPageEmptyState } from '@/components/FullPageEmptyState'; import { Pagination } from '@/components/Pagination'; +import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart'; import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/useAppParams'; import { useCursor } from '@/hooks/useCursor'; import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; import { isSameDay } from 'date-fns'; @@ -12,6 +14,7 @@ import { GanttChartIcon } from 'lucide-react'; import type { IServiceCreateEventPayload } from '@mixan/db'; import { EventListItem } from './event-list-item'; +import EventListener from './event-listener'; function showDateHeader(a: Date, b?: Date) { if (!b) return true; @@ -26,64 +29,62 @@ export function EventList({ data, count }: EventListProps) { const { cursor, setCursor } = useCursor(); const [filters] = useEventQueryFilters(); return ( - -
- {data.length === 0 ? ( - - {cursor !== 0 ? ( - <> -

Looks like you have reached the end of the list

- - - ) : ( - <> - {filters.length ? ( -

Could not find any events with your filter

- ) : ( -

We have not recieved any events yet

+ <> + {data.length === 0 ? ( + + {cursor !== 0 ? ( + <> +

Looks like you have reached the end of the list

+ + + ) : ( + <> + {filters.length ? ( +

Could not find any events with your filter

+ ) : ( +

We have not recieved any events yet

+ )} + + )} +
+ ) : ( + <> +
+ + +
+
+ {data.map((item, index, list) => ( + + {showDateHeader(item.createdAt, list[index - 1]?.createdAt) && ( +
+ {item.createdAt.toLocaleDateString()} +
)} - - )} - - ) : ( - <> - -
- {data.map((item, index, list) => ( - - {showDateHeader( - item.createdAt, - list[index - 1]?.createdAt - ) && ( -
- {item.createdAt.toLocaleDateString()} -
- )} - -
- ))} -
- - - )} -
- + + + ))} +
+ + + )} + ); } diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-listener.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-listener.tsx new file mode 100644 index 00000000..09004ab8 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-listener.tsx @@ -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: () =>
0
, +}); + +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 ( + + + + + + {counter === 0 ? 'Listening to new events' : 'Click to refresh'} + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx index e68e4d5c..6e1802cd 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx @@ -11,6 +11,7 @@ import { parseAsInteger } from 'nuqs'; import { getEventList, getEventsCount } from '@mixan/db'; import { StickyBelowHeader } from '../layout-sticky-below-header'; +import { EventChart } from './event-chart'; import { EventList } from './event-list'; interface PageProps { @@ -33,18 +34,24 @@ export default async function Page({ params: { projectId, organizationId }, searchParams, }: PageProps) { + const filters = + eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined; + const eventsFilter = eventQueryNamesFilter.parseServerSide( + searchParams.events ?? '' + ); const [events, count] = await Promise.all([ getEventList({ - cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined, + cursor: + parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined, projectId, take: 50, - events: eventQueryNamesFilter.parse(searchParams.events ?? ''), - filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined, + events: eventsFilter, + filters, }), getEventsCount({ projectId, - events: eventQueryNamesFilter.parse(searchParams.events ?? ''), - filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined, + events: eventsFilter, + filters, }), getExists(organizationId, projectId), ]); @@ -63,7 +70,14 @@ export default async function Page({ nuqsOptions={nuqsOptions} /> - +
+ + +
); } diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx index 4b788c46..85dd4946 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx @@ -50,12 +50,15 @@ export default async function Page({ projectId, profileId, take: 50, - cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined, - events: eventQueryNamesFilter.parse(searchParams.events ?? ''), - filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined, + cursor: + parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined, + events: eventQueryNamesFilter.parseServerSide(searchParams.events ?? ''), + filters: + eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? + undefined, }; - const startDate = parseAsString.parse(searchParams.startDate); - const endDate = parseAsString.parse(searchParams.endDate); + const startDate = parseAsString.parseServerSide(searchParams.startDate); + const endDate = parseAsString.parseServerSide(searchParams.endDate); const [profile, events, count, conversions] = await Promise.all([ getProfileById(profileId), getEventList(eventListOptions), diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx index 8ca4b484..6886730a 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx @@ -33,12 +33,12 @@ export default async function Page({ getProfileList({ projectId, take: 50, - cursor: parseAsInteger.parse(cursor ?? '') ?? undefined, - filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined, + cursor: parseAsInteger.parseServerSide(cursor ?? '') ?? undefined, + filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined, }), getProfileListCount({ projectId, - filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined, + filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined, }), getExists(organizationId, projectId), ]); diff --git a/apps/web/src/components/report/chart/index.tsx b/apps/web/src/components/report/chart/index.tsx index 8dcecfcf..bb5fbbab 100644 --- a/apps/web/src/components/report/chart/index.tsx +++ b/apps/web/src/components/report/chart/index.tsx @@ -17,3 +17,36 @@ export const ChartSwitch = withChartProivder(function ChartSwitch( return ; }); + +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 ( + + ); +}; diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 4bd87934..0b51d951 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -31,16 +31,16 @@ const SheetOverlay = React.forwardRef< SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; 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: { 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: - 'inset-x-0 bottom-0 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', + 'inset-x-4 bottom-4 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + 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: - '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: { @@ -55,10 +55,12 @@ interface SheetContentProps const SheetContent = React.forwardRef< React.ElementRef, - SheetContentProps ->(({ side = 'right', className, children, ...props }, ref) => ( + SheetContentProps & { + onClose?: () => void; + } +>(({ side = 'right', className, children, onClose, ...props }, ref) => ( - + {children} - + Close diff --git a/apps/web/src/hooks/useNumerFormatter.ts b/apps/web/src/hooks/useNumerFormatter.ts index bd9c392b..acc4c491 100644 --- a/apps/web/src/hooks/useNumerFormatter.ts +++ b/apps/web/src/hooks/useNumerFormatter.ts @@ -4,6 +4,7 @@ import { isNil } from 'ramda'; export function fancyMinutes(time: number) { const minutes = Math.floor(time / 60); const seconds = round(time - minutes * 60, 0); + if (minutes === 0) return `${seconds}s`; return `${minutes}m ${seconds}s`; } diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index f4ceb134..311eb4ef 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -51,6 +51,7 @@ const config = { return [ `text-${color}-${variant}`, `bg-${color}-${variant}`, + `hover:bg-${color}-${variant}`, `border-${color}-${variant}`, ]; }); diff --git a/tooling/prettier/index.mjs b/tooling/prettier/index.mjs index fa211224..9d339fa3 100644 --- a/tooling/prettier/index.mjs +++ b/tooling/prettier/index.mjs @@ -21,6 +21,6 @@ const config = { trailingComma: 'es5', printWidth: 80, tabWidth: 2, -} +}; -export default config +export default config;