diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.module.css b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.module.css new file mode 100644 index 00000000..140ad0bb --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.module.css @@ -0,0 +1,5 @@ +.link { + display: flex; + align-items: center; + gap: 20px; +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx index d4cf827a..73148b83 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx @@ -1,21 +1,27 @@ import Link from 'next/link'; import { GridColumn, GridTable, useBreakpoint } from 'react-basics'; -import { useFormat, useMessages } from 'components/hooks'; +import { useFormat, useLocale, useMessages } from 'components/hooks'; import Profile from 'components/common/Profile'; +import styles from './SessionsTable.module.css'; +import { formatDate } from 'lib/date'; export function SessionsTable({ data = [] }: { data: any[]; showDomain?: boolean }) { + const { locale } = useLocale(); const { formatMessage, labels } = useMessages(); const breakpoint = useBreakpoint(); const { formatValue } = useFormat(); return ( - - {row => } - - - {row => {row.id}} + + {row => ( + + + {row.id} + + )} + {row => formatValue(row.country, 'country')} @@ -28,7 +34,7 @@ export function SessionsTable({ data = [] }: { data: any[]; showDomain?: boolean {row => formatValue(row.device, 'device')} - {row => row.lastAt} + {row => formatDate(new Date(row.lastAt), 'PPPpp', locale)} ); diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx index c644fb89..0a15430c 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx @@ -23,7 +23,6 @@ export function SessionActivity({ return (
-

Activity log

{data.map(({ eventId, createdAt, urlPath, eventName, visitId }) => { const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); lastDay = createdAt; diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.module.css b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.module.css new file mode 100644 index 00000000..2057622b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.module.css @@ -0,0 +1,39 @@ +.data { + display: flex; + flex-direction: column; + gap: 20px; + width: 200px; + position: relative; +} + +.header { + font-weight: bold; +} + +.empty { + color: var(--font-color300); + text-align: center; +} + +.label { + display: flex; + align-items: center; + justify-content: space-between; +} + +.type { + font-size: 10px; + text-transform: uppercase; + padding: 0 6px; + border-radius: 4px; + border: 1px solid var(--base300); +} + +.name { + color: var(--font-color200); + font-weight: bold; +} + +.value { + margin: 5px 0; +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx new file mode 100644 index 00000000..2d99f8b3 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx @@ -0,0 +1,34 @@ +import { Loading, TextOverflow } from 'react-basics'; +import { useMessages, useSessionData } from 'components/hooks'; +import Empty from 'components/common/Empty'; +import styles from './SessionData.module.css'; +import { DATA_TYPES } from 'lib/constants'; + +export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) { + const { formatMessage, labels } = useMessages(); + const { data, isLoading } = useSessionData(websiteId, sessionId); + + if (isLoading) { + return ; + } + + return ( +
+
{formatMessage(labels.properties)}
+ {!data?.length && } + {data?.map(({ dataKey, dataType, stringValue }) => { + return ( +
+
+
+ {dataKey} +
+
{DATA_TYPES[dataType]}
+
+
{stringValue}
+
+ ); + })} +
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.module.css b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.module.css index af5ed201..8e2667f4 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.module.css +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.module.css @@ -2,6 +2,7 @@ display: grid; grid-template-columns: 300px 1fr max-content; margin-bottom: 40px; + position: relative; } .sidebar { @@ -12,16 +13,32 @@ gap: 20px; padding-right: 20px; border-right: 1px solid var(--base300); + position: relative; } .content { - padding: 0 20px; -} - -.stats { display: flex; flex-direction: column; - gap: 20px; + gap: 30px; + padding: 0 20px; + position: relative; +} + +.data { border-left: 1px solid var(--base300); padding-left: 20px; + position: relative; + transition: width 200ms ease-in-out; +} + +@media screen and (max-width: 992px) { + .page { + grid-template-columns: 1fr; + gap: 30px; + } + + .sidebar, + .data { + border: 0; + } } diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx index 723e6ff7..d4ed503e 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx @@ -4,9 +4,10 @@ import SessionInfo from './SessionInfo'; import { useWebsiteSession } from 'components/hooks'; import { Loading } from 'react-basics'; import Profile from 'components/common/Profile'; -import styles from './SessionDetailsPage.module.css'; import { SessionActivity } from './SessionActivity'; -import SessionStats from './SessionStats'; +import { SessionStats } from './SessionStats'; +import { SessionData } from './SessionData'; +import styles from './SessionDetailsPage.module.css'; export default function SessionDetailsPage({ websiteId, @@ -30,10 +31,11 @@ export default function SessionDetailsPage({
+
-
- +
+
diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx index 574152ec..a25f7af3 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx @@ -1,14 +1,15 @@ import MetricCard from 'components/metrics/MetricCard'; import { useMessages } from 'components/hooks'; +import MetricsBar from 'components/metrics/MetricsBar'; -export default function SessionStats({ data }) { +export function SessionStats({ data }) { const { formatMessage, labels } = useMessages(); return ( - <> + - + ); } diff --git a/src/components/common/Profile.tsx b/src/components/common/Profile.tsx index 63a59c16..40544994 100644 --- a/src/components/common/Profile.tsx +++ b/src/components/common/Profile.tsx @@ -10,9 +10,9 @@ function convertToPastel(hexColor: string, pastelFactor: number = 0.5) { hexColor = hexColor.replace(/^#/, ''); // Convert hex to RGB - let r = parseInt(hexColor.substr(0, 2), 16); - let g = parseInt(hexColor.substr(2, 2), 16); - let b = parseInt(hexColor.substr(4, 2), 16); + let r = parseInt(hexColor.substring(0, 2), 16); + let g = parseInt(hexColor.substring(2, 4), 16); + let b = parseInt(hexColor.substring(4, 6), 16); // Calculate pastel version (mix with white) //const pastelFactor = 0.5; // Adjust this value to control pastel intensity diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 4ef89251..e06a0414 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -6,6 +6,7 @@ export * from './queries/useRealtime'; export * from './queries/useReport'; export * from './queries/useReports'; export * from './queries/useSessionActivity'; +export * from './queries/useSessionData'; export * from './queries/useWebsiteSession'; export * from './queries/useWebsiteSessions'; export * from './queries/useShareToken'; diff --git a/src/components/hooks/queries/useSessionData.ts b/src/components/hooks/queries/useSessionData.ts new file mode 100644 index 00000000..f2e8f524 --- /dev/null +++ b/src/components/hooks/queries/useSessionData.ts @@ -0,0 +1,12 @@ +import { useApi } from './useApi'; + +export function useSessionData(websiteId: string, sessionId: string) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['session:data', { websiteId, sessionId }], + queryFn: () => { + return get(`/sessions/${sessionId}/data`, { websiteId }); + }, + }); +} diff --git a/src/components/messages.ts b/src/components/messages.ts index 764348fa..2dfe61ee 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -274,6 +274,7 @@ export const labels = defineMessages({ previousYear: { id: 'label.previous-year', defaultMessage: 'Previous year' }, lastSeen: { id: 'label.last-seen', defaultMessage: 'Last seen' }, firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' }, + properties: { id: 'label.properties', defaultMessage: 'Properties' }, }); export const messages = defineMessages({ diff --git a/src/pages/api/sessions/[sessionId]/data.ts b/src/pages/api/sessions/[sessionId]/data.ts new file mode 100644 index 00000000..c0c20064 --- /dev/null +++ b/src/pages/api/sessions/[sessionId]/data.ts @@ -0,0 +1,42 @@ +import { canViewWebsite } from 'lib/auth'; +import { useAuth, useCors, useValidate } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getSessionData } from 'queries'; +import * as yup from 'yup'; + +export interface SessionDataRequestQuery { + sessionId: string; + websiteId: string; +} + +const schema = { + GET: yup.object().shape({ + sessionId: yup.string().uuid().required(), + websiteId: yup.string().uuid().required(), + }), +}; + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + await useValidate(schema, req, res); + + if (req.method === 'GET') { + const { websiteId, sessionId } = req.query; + + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const data = await getSessionData(websiteId, sessionId); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/src/queries/analytics/eventData/getEventDataEvents.ts b/src/queries/analytics/events/getEventDataEvents.ts similarity index 100% rename from src/queries/analytics/eventData/getEventDataEvents.ts rename to src/queries/analytics/events/getEventDataEvents.ts diff --git a/src/queries/analytics/eventData/getEventDataFields.ts b/src/queries/analytics/events/getEventDataFields.ts similarity index 100% rename from src/queries/analytics/eventData/getEventDataFields.ts rename to src/queries/analytics/events/getEventDataFields.ts diff --git a/src/queries/analytics/eventData/getEventDataStats.ts b/src/queries/analytics/events/getEventDataStats.ts similarity index 100% rename from src/queries/analytics/eventData/getEventDataStats.ts rename to src/queries/analytics/events/getEventDataStats.ts diff --git a/src/queries/analytics/eventData/getEventDataUsage.ts b/src/queries/analytics/events/getEventDataUsage.ts similarity index 100% rename from src/queries/analytics/eventData/getEventDataUsage.ts rename to src/queries/analytics/events/getEventDataUsage.ts diff --git a/src/queries/analytics/events/saveEvent.ts b/src/queries/analytics/events/saveEvent.ts index cd41b7a3..fa4805df 100644 --- a/src/queries/analytics/events/saveEvent.ts +++ b/src/queries/analytics/events/saveEvent.ts @@ -4,7 +4,7 @@ import clickhouse from 'lib/clickhouse'; import kafka from 'lib/kafka'; import prisma from 'lib/prisma'; import { uuid } from 'lib/crypto'; -import { saveEventData } from 'queries/analytics/eventData/saveEventData'; +import { saveEventData } from './saveEventData'; export async function saveEvent(args: { websiteId: string; diff --git a/src/queries/analytics/eventData/saveEventData.ts b/src/queries/analytics/events/saveEventData.ts similarity index 100% rename from src/queries/analytics/eventData/saveEventData.ts rename to src/queries/analytics/events/saveEventData.ts diff --git a/src/queries/analytics/sessions/getSessionData.ts b/src/queries/analytics/sessions/getSessionData.ts new file mode 100644 index 00000000..e3106b27 --- /dev/null +++ b/src/queries/analytics/sessions/getSessionData.ts @@ -0,0 +1,41 @@ +import prisma from 'lib/prisma'; +import clickhouse from 'lib/clickhouse'; +import { runQuery, PRISMA, CLICKHOUSE } from 'lib/db'; + +export async function getSessionData(...args: [websiteId: string, sessionId: string]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, sessionId: string) { + return prisma.client.sessionData.findMany({ + where: { + id: sessionId, + websiteId, + }, + }); +} + +async function clickhouseQuery(websiteId: string, sessionId: string) { + const { rawQuery } = clickhouse; + + return rawQuery( + ` + select + website_id as websiteId, + session_id as sessionId, + data_key as dataKey, + data_type as dataType, + string_value as stringValue, + number_value as numberValue, + date_value as dateValue, + created_at as createdAt + from session_data + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + `, + { websiteId, sessionId }, + ); +} diff --git a/src/queries/analytics/sessions/getWebsiteSession.ts b/src/queries/analytics/sessions/getWebsiteSession.ts index 2e6e8eca..df3e8a04 100644 --- a/src/queries/analytics/sessions/getWebsiteSession.ts +++ b/src/queries/analytics/sessions/getWebsiteSession.ts @@ -37,9 +37,9 @@ async function clickhouseQuery(websiteId: string, sessionId: string) { city, min(created_at) as firstAt, max(created_at) as lastAt, - uniq(visit_id) as "visits", - sumIf(1, event_type = 1) as "views", - sumIf(1, event_type = 2) as "events" + uniq(visit_id) as visits, + sumIf(1, event_type = 1) as views, + sumIf(1, event_type = 2) as events from website_event where website_id = {websiteId:UUID} and session_id = {sessionId:UUID} diff --git a/src/queries/analytics/sessions/getWebsiteSessions.ts b/src/queries/analytics/sessions/getWebsiteSessions.ts index 2094c8a4..c5fe6cec 100644 --- a/src/queries/analytics/sessions/getWebsiteSessions.ts +++ b/src/queries/analytics/sessions/getWebsiteSessions.ts @@ -42,7 +42,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar subdivision1, city, min(created_at) as firstAt, - max(created_at) as lastAt + max(created_at) as lastAt, + uniq(visit_id) as visits from website_event where website_id = {websiteId:UUID} ${dateQuery} @@ -52,5 +53,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar `, params, pageParams, - ); + ).then((result: any) => ({ + ...result, + visits: Number(result.visits), + })); } diff --git a/src/queries/index.ts b/src/queries/index.ts index cd7ea280..82d40ee5 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -3,13 +3,13 @@ export * from 'queries/prisma/team'; export * from 'queries/prisma/teamUser'; export * from 'queries/prisma/user'; export * from 'queries/prisma/website'; +export * from './analytics/events/getEventDataEvents'; +export * from './analytics/events/getEventDataFields'; +export * from './analytics/events/getEventDataStats'; +export * from './analytics/events/getEventDataUsage'; export * from './analytics/events/getEventMetrics'; -export * from './analytics/events/getEventUsage'; export * from './analytics/events/getEvents'; -export * from './analytics/eventData/getEventDataEvents'; -export * from './analytics/eventData/getEventDataFields'; -export * from './analytics/eventData/getEventDataStats'; -export * from './analytics/eventData/getEventDataUsage'; +export * from './analytics/events/getEventUsage'; export * from './analytics/events/saveEvent'; export * from './analytics/reports/getFunnel'; export * from './analytics/reports/getJourney'; @@ -20,6 +20,7 @@ export * from './analytics/pageviews/getPageviewMetrics'; export * from './analytics/pageviews/getPageviewStats'; export * from './analytics/sessions/createSession'; export * from './analytics/sessions/getWebsiteSession'; +export * from './analytics/sessions/getSessionData'; export * from './analytics/sessions/getSessionMetrics'; export * from './analytics/sessions/getWebsiteSessions'; export * from './analytics/sessions/getSessionActivity';