Feature/move list to client (#50)
This commit is contained in:
committed by
GitHub
parent
c2abdaadf2
commit
668434d246
@@ -9,6 +9,7 @@ import {
|
||||
getEvents,
|
||||
getLiveVisitors,
|
||||
getProfileById,
|
||||
getProfileByIdCached,
|
||||
TABLE_NAMES,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
@@ -144,7 +145,10 @@ export async function wsProjectEvents(
|
||||
const message = async (channel: string, message: string) => {
|
||||
const event = getSuperJson<IServiceEvent>(message);
|
||||
if (event?.projectId === params.projectId) {
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
const profile = await getProfileByIdCached(
|
||||
event.profileId,
|
||||
event.projectId
|
||||
);
|
||||
connection.socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"embla-carousel-react": "8.0.0-rc22",
|
||||
"flag-icons": "^7.1.0",
|
||||
"framer-motion": "^11.0.28",
|
||||
"geist": "^1.3.1",
|
||||
"hamburger-react": "^2.5.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"javascript-time-ago": "^2.5.9",
|
||||
|
||||
@@ -31,15 +31,16 @@ import {
|
||||
getDefaultIntervalByRange,
|
||||
timeWindows,
|
||||
} from '@openpanel/constants';
|
||||
import type { getReportsByDashboardId } from '@openpanel/db';
|
||||
import type { getReportsByDashboardId, IServiceDashboard } from '@openpanel/db';
|
||||
|
||||
import { OverviewReportRange } from '../../overview-sticky-header';
|
||||
|
||||
interface ListReportsProps {
|
||||
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
|
||||
dashboard: IServiceDashboard;
|
||||
}
|
||||
|
||||
export function ListReports({ reports }: ListReportsProps) {
|
||||
export function ListReports({ reports, dashboard }: ListReportsProps) {
|
||||
const router = useRouter();
|
||||
const params = useAppParams<{ dashboardId: string }>();
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
@@ -52,25 +53,28 @@ export function ListReports({ reports }: ListReportsProps) {
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader className="flex items-center justify-between p-4">
|
||||
<OverviewReportRange />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/${params.organizationSlug}/${
|
||||
params.projectId
|
||||
}/reports?${new URLSearchParams({
|
||||
dashboardId: params.dashboardId,
|
||||
}).toString()}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="max-sm:hidden">Create report</span>
|
||||
<span className="sm:hidden">Report</span>
|
||||
</Button>
|
||||
</StickyBelowHeader>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-8 p-4 md:p-8">
|
||||
<div className="row mb-4 items-center justify-between">
|
||||
<h1 className="text-3xl font-semibold">{dashboard.name}</h1>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<OverviewReportRange />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/${params.organizationSlug}/${
|
||||
params.projectId
|
||||
}/reports?${new URLSearchParams({
|
||||
dashboardId: params.dashboardId,
|
||||
}).toString()}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="max-sm:hidden">Create report</span>
|
||||
<span className="sm:hidden">Report</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-w-6xl flex-col gap-8">
|
||||
{reports.map((report) => {
|
||||
const chartRange = report.range;
|
||||
return (
|
||||
@@ -83,7 +87,7 @@ export function ListReports({ reports }: ListReportsProps) {
|
||||
<div>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 flex gap-2 text-sm">
|
||||
<div className="mt-2 flex gap-2 ">
|
||||
<span
|
||||
className={
|
||||
range !== null || (startDate && endDate)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
|
||||
@@ -14,7 +14,7 @@ interface PageProps {
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationSlug, projectId, dashboardId },
|
||||
params: { projectId, dashboardId },
|
||||
}: PageProps) {
|
||||
const [dashboard, reports] = await Promise.all([
|
||||
getDashboardById(dashboardId, projectId),
|
||||
@@ -26,9 +26,8 @@ export default async function Page({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout title={dashboard.name} organizationSlug={organizationSlug} />
|
||||
<ListReports reports={reports} />
|
||||
</>
|
||||
<Padding>
|
||||
<ListReports reports={reports} dashboard={dashboard} />
|
||||
</Padding>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,19 @@ import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
||||
|
||||
export function HeaderDashboards() {
|
||||
return (
|
||||
<StickyBelowHeader>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
pushModal('AddDashboard');
|
||||
}}
|
||||
>
|
||||
<span className="max-sm:hidden">Create dashboard</span>
|
||||
<span className="sm:hidden">Dashboard</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-3xl font-semibold">Dashboards</h1>
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
pushModal('AddDashboard');
|
||||
}}
|
||||
>
|
||||
<span className="max-sm:hidden">Create dashboard</span>
|
||||
<span className="sm:hidden">Dashboard</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import withSuspense from '@/hocs/with-suspense';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
import { getDashboardsByProjectId } from '@openpanel/db';
|
||||
|
||||
@@ -16,7 +14,12 @@ interface Props {
|
||||
const ListDashboardsServer = async ({ projectId }: Props) => {
|
||||
const dashboards = await getDashboardsByProjectId(projectId);
|
||||
|
||||
return <ListDashboards dashboards={dashboards} />;
|
||||
return (
|
||||
<Padding>
|
||||
<HeaderDashboards />
|
||||
<ListDashboards dashboards={dashboards} />;
|
||||
</Padding>
|
||||
);
|
||||
};
|
||||
|
||||
export default withSuspense(ListDashboardsServer, FullPageLoadingState);
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{dashboards.map((item) => {
|
||||
const visibleReports = item.reports.slice(
|
||||
0,
|
||||
@@ -88,16 +88,16 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
||||
href={`/${organizationSlug}/${projectId}/dashboards/${item.id}`}
|
||||
className="flex flex-col p-4 @container"
|
||||
>
|
||||
<div>
|
||||
<div className="col gap-2">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{format(item.updatedAt, 'HH:mm · MMM d')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-4 grid gap-4',
|
||||
'grid-cols-2 @xs:grid-cols-3 @lg:grid-cols-4'
|
||||
'mt-4 grid gap-2',
|
||||
'grid-cols-1 @sm:grid-cols-2'
|
||||
)}
|
||||
>
|
||||
{visibleReports.map((report) => {
|
||||
@@ -114,26 +114,26 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-def-200 flex flex-col rounded-xl p-4"
|
||||
className="row items-center gap-2 rounded-md bg-def-200 p-4 py-2"
|
||||
key={report.id}
|
||||
>
|
||||
<Icon size={24} className="text-highlight" />
|
||||
<div className="mt-2 w-full overflow-hidden text-ellipsis whitespace-nowrap text-xs">
|
||||
<Icon size={24} />
|
||||
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{report.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{item.reports.length > 6 && (
|
||||
<div className="bg-def-200 flex flex-col rounded-xl p-4">
|
||||
<PlusIcon size={24} className="text-highlight" />
|
||||
<div className="mt-2 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs">
|
||||
<div className="row items-center gap-2 rounded-md bg-def-100 p-4 py-2">
|
||||
<PlusIcon size={24} />
|
||||
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{item.reports.length - 5} more
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* <span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
|
||||
{/* <span className="overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground">
|
||||
<span className="mr-2 font-medium">
|
||||
{item.reports.length} reports
|
||||
</span>
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
|
||||
import ListDashboardsServer from './list-dashboards';
|
||||
import { HeaderDashboards } from './list-dashboards/header';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
projectId: string;
|
||||
organizationSlug: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
params: { projectId, organizationSlug },
|
||||
}: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<PageLayout title="Dashboards" organizationSlug={organizationSlug} />
|
||||
<HeaderDashboards />
|
||||
<ListDashboardsServer projectId={projectId} />
|
||||
</>
|
||||
);
|
||||
export default function Page({ params: { projectId } }: PageProps) {
|
||||
return <ListDashboardsServer projectId={projectId} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { ChartRootShortcut } from '@/components/report/chart';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function Charts({ projectId }: Props) {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [events] = useEventQueryNamesFilter();
|
||||
const fallback: IChartEvent[] = [
|
||||
{
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'All events',
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<OverviewFiltersDrawer
|
||||
mode="events"
|
||||
projectId={projectId}
|
||||
enableEventsFilter
|
||||
/>
|
||||
<OverviewFiltersButtons className="justify-end p-0" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ChartRootShortcut
|
||||
projectId={projectId}
|
||||
range="30d"
|
||||
chartType="histogram"
|
||||
events={
|
||||
events && events.length > 0
|
||||
? events.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
displayName: name,
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
}))
|
||||
: fallback
|
||||
}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Event distribution</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ChartRootShortcut
|
||||
projectId={projectId}
|
||||
range="30d"
|
||||
chartType="pie"
|
||||
breakdowns={[
|
||||
{
|
||||
id: 'A',
|
||||
name: 'name',
|
||||
},
|
||||
]}
|
||||
events={
|
||||
events && events.length > 0
|
||||
? events.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
displayName: name,
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'All events',
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Event distribution</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ChartRootShortcut
|
||||
projectId={projectId}
|
||||
range="30d"
|
||||
chartType="bar"
|
||||
breakdowns={[
|
||||
{
|
||||
id: 'A',
|
||||
name: 'name',
|
||||
},
|
||||
]}
|
||||
events={
|
||||
events && events.length > 0
|
||||
? events.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
displayName: name,
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'All events',
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Event distribution</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ChartRootShortcut
|
||||
projectId={projectId}
|
||||
range="30d"
|
||||
chartType="linear"
|
||||
breakdowns={[
|
||||
{
|
||||
id: 'A',
|
||||
name: 'name',
|
||||
},
|
||||
]}
|
||||
events={
|
||||
events && events.length > 0
|
||||
? events.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
displayName: name,
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'All events',
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Charts;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { ChartRootShortcut } from '@/components/report/chart';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
events?: string[];
|
||||
filters?: any[];
|
||||
}
|
||||
|
||||
export function EventsPerDayChart({ projectId, filters, events }: Props) {
|
||||
const fallback: IChartEvent[] = [
|
||||
{
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'All events',
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ChartRootShortcut
|
||||
projectId={projectId}
|
||||
range="30d"
|
||||
chartType="histogram"
|
||||
events={
|
||||
events && events.length > 0
|
||||
? events.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
displayName: name,
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
}))
|
||||
: fallback
|
||||
}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { EventsTable } from '@/components/events/table';
|
||||
import { api } from '@/trpc/client';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId?: string;
|
||||
};
|
||||
|
||||
const Conversions = ({ projectId }: Props) => {
|
||||
const query = api.event.conversions.useQuery(
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
return <EventsTable query={query} />;
|
||||
};
|
||||
|
||||
export default Conversions;
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Widget, WidgetHead } from '@/components/widget';
|
||||
import { isSameDay } from 'date-fns';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
import { EventListItem } from '../event-list/event-list-item';
|
||||
|
||||
function showDateHeader(a: Date, b?: Date) {
|
||||
if (!b) return true;
|
||||
return !isSameDay(a, b);
|
||||
}
|
||||
|
||||
interface EventListProps {
|
||||
data: IServiceEvent[];
|
||||
}
|
||||
export function EventConversionsList({ data }: EventListProps) {
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<div className="title">Conversions</div>
|
||||
</WidgetHead>
|
||||
<div className="flex max-h-80 flex-col gap-2 overflow-y-auto p-4">
|
||||
{data.map((item, index, list) => (
|
||||
<Fragment key={item.id}>
|
||||
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
|
||||
<div className="flex flex-row justify-between gap-2 [&:not(:first-child)]:mt-12">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-8 items-center gap-2 rounded border border-def-200 bg-def-200 px-3 text-sm font-medium leading-none">
|
||||
{item.createdAt.toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EventListItem {...item} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
import { db, getEvents, TABLE_NAMES } from '@openpanel/db';
|
||||
|
||||
import { EventConversionsList } from './event-conversions-list';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
async function EventConversionsListServer({ projectId }: Props) {
|
||||
const conversions = await db.eventMeta.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
conversion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (conversions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const events = await getEvents(
|
||||
`SELECT * FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} AND name IN (${conversions.map((c) => escape(c.name)).join(', ')}) ORDER BY created_at DESC LIMIT 20;`,
|
||||
{
|
||||
profile: true,
|
||||
meta: true,
|
||||
}
|
||||
);
|
||||
|
||||
return <EventConversionsList data={events} />;
|
||||
}
|
||||
|
||||
export default withLoadingWidget(EventConversionsListServer);
|
||||
@@ -1,232 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ChartRootShortcut } from '@/components/report/chart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KeyValue } from '@/components/ui/key-value';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { round } from 'mathjs';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
import { EventEdit } from './event-edit';
|
||||
|
||||
interface Props {
|
||||
event: IServiceEvent;
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function EventDetails({ event, open, setOpen }: Props) {
|
||||
const { name } = event;
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
||||
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
|
||||
|
||||
const common = [
|
||||
{
|
||||
name: 'Origin',
|
||||
value: event.origin,
|
||||
},
|
||||
{
|
||||
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: '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>
|
||||
<div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{properties.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium">Params</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{properties.map((item) => (
|
||||
<KeyValue
|
||||
key={item.name}
|
||||
name={item.name.replace(/^__/, '')}
|
||||
value={item.value}
|
||||
onClick={() => {
|
||||
setFilter(
|
||||
`properties.${item.name}`,
|
||||
item.value ? String(item.value) : '',
|
||||
'is'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium">Common</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{common.map((item) => (
|
||||
<KeyValue
|
||||
key={item.name}
|
||||
name={item.name}
|
||||
value={item.value}
|
||||
onClick={() => item.onClick?.()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex justify-between text-sm font-medium">
|
||||
<div>Similar events</div>
|
||||
<button
|
||||
className="text-muted-foreground hover:underline"
|
||||
onClick={() => {
|
||||
setEvents([event.name]);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
<ChartRootShortcut
|
||||
projectId={event.projectId}
|
||||
chartType="histogram"
|
||||
events={[
|
||||
{
|
||||
id: 'A',
|
||||
name: event.name,
|
||||
displayName: 'Similar events',
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
className="w-full"
|
||||
onClick={() => setIsEditOpen(true)}
|
||||
>
|
||||
Customize "{name}"
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<EventEdit event={event} open={isEditOpen} setOpen={setIsEditOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ const variants = cva('flex shrink-0 items-center justify-center rounded-full', {
|
||||
type EventIconProps = VariantProps<typeof variants> & {
|
||||
name: string;
|
||||
meta?: EventMeta;
|
||||
projectId: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
||||
|
||||
import { EventDetails } from './event-details';
|
||||
import { EventIcon } from './event-icon';
|
||||
|
||||
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
|
||||
@@ -20,7 +19,6 @@ export function EventListItem(props: EventListItemProps) {
|
||||
const { organizationSlug, projectId } = useAppParams();
|
||||
const { createdAt, name, path, duration, meta } = props;
|
||||
const profile = 'profile' in props ? props.profile : null;
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
|
||||
const number = useNumber();
|
||||
|
||||
@@ -52,17 +50,12 @@ export function EventListItem(props: EventListItemProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isMinimal && (
|
||||
<EventDetails
|
||||
event={props}
|
||||
open={isDetailsOpen}
|
||||
setOpen={setIsDetailsOpen}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isMinimal) {
|
||||
setIsDetailsOpen(true);
|
||||
pushModal('EventDetails', {
|
||||
id: props.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -72,13 +65,8 @@ export function EventListItem(props: EventListItemProps) {
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-left text-sm">
|
||||
<EventIcon
|
||||
size="sm"
|
||||
name={name}
|
||||
meta={meta}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<div className="flex items-center gap-4 text-left ">
|
||||
<EventIcon size="sm" name={name} meta={meta} />
|
||||
<span>
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
{' '}
|
||||
@@ -100,14 +88,14 @@ export function EventListItem(props: EventListItemProps) {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
href={`/${organizationSlug}/${projectId}/profiles/${profile?.id}`}
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground hover:underline"
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className=" text-muted-foreground">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCursor } from '@/hooks/useCursor';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { isSameDay } from 'date-fns';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
import { EventListItem } from './event-list-item';
|
||||
import EventListener from './event-listener';
|
||||
|
||||
function showDateHeader(a: Date, b?: Date) {
|
||||
if (!b) return true;
|
||||
return !isSameDay(a, b);
|
||||
}
|
||||
|
||||
interface EventListProps {
|
||||
data: IServiceEvent[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
function EventList({ data, count }: EventListProps) {
|
||||
const { cursor, setCursor, loading } = useCursor();
|
||||
const [filters] = useEventQueryFilters();
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.length === 0 ? (
|
||||
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
|
||||
{cursor !== 0 ? (
|
||||
<>
|
||||
<p>Looks like you have reached the end of the list</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCursor((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{filters.length ? (
|
||||
<p>Could not find any events with your filter</p>
|
||||
) : (
|
||||
<p>We have not received any events yet</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.map((item, index, list) => (
|
||||
<Fragment key={item.id}>
|
||||
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
|
||||
<div className="flex flex-row justify-between gap-2 [&:not(:first-child)]:mt-12">
|
||||
{index === 0 ? <EventListener /> : <div />}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-8 items-center gap-2 rounded border border-def-200 bg-def-200 px-3 text-sm font-medium leading-none">
|
||||
{item.createdAt.toLocaleDateString()}
|
||||
</div>
|
||||
{index === 0 && (
|
||||
<Pagination
|
||||
size="sm"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EventListItem {...item} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventList;
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDebounceVal } from '@/hooks/useDebounceVal';
|
||||
import { useDebounceState } from '@/hooks/useDebounceState';
|
||||
import useWS from '@/hooks/useWS';
|
||||
import { cn } from '@/utils/cn';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { IServiceEventMinimal } from '@openpanel/db';
|
||||
|
||||
@@ -19,10 +18,13 @@ const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
|
||||
loading: () => <div>0</div>,
|
||||
});
|
||||
|
||||
export default function EventListener() {
|
||||
const router = useRouter();
|
||||
export default function EventListener({
|
||||
onRefresh,
|
||||
}: {
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { projectId } = useAppParams();
|
||||
const counter = useDebounceVal(0, 1000, {
|
||||
const counter = useDebounceState(0, 1000, {
|
||||
maxWait: 5000,
|
||||
});
|
||||
|
||||
@@ -38,9 +40,9 @@ export default function EventListener() {
|
||||
<button
|
||||
onClick={() => {
|
||||
counter.set(0);
|
||||
router.refresh();
|
||||
onRefresh();
|
||||
}}
|
||||
className="flex h-8 items-center gap-2 rounded border border-border bg-card px-3 text-sm font-medium leading-none"
|
||||
className="flex h-8 items-center gap-2 rounded border border-border bg-card px-3 font-medium leading-none"
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import withSuspense from '@/hocs/with-suspense';
|
||||
|
||||
import { getEventList, getEventsCount } from '@openpanel/db';
|
||||
import type { IChartEventFilter } from '@openpanel/validation';
|
||||
|
||||
import EventList from './event-list';
|
||||
|
||||
type Props = {
|
||||
cursor?: number;
|
||||
projectId: string;
|
||||
filters?: IChartEventFilter[];
|
||||
eventNames?: string[];
|
||||
profileId?: string;
|
||||
};
|
||||
|
||||
const EventListServer = async ({
|
||||
cursor,
|
||||
projectId,
|
||||
eventNames,
|
||||
filters,
|
||||
profileId,
|
||||
}: Props) => {
|
||||
const count = Infinity;
|
||||
const [events] = await Promise.all([
|
||||
getEventList({
|
||||
cursor,
|
||||
projectId,
|
||||
take: 50,
|
||||
events: eventNames,
|
||||
filters,
|
||||
profileId,
|
||||
}),
|
||||
]);
|
||||
|
||||
return <EventList data={events} count={count} />;
|
||||
};
|
||||
|
||||
export default withSuspense(EventListServer, () => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
</div>
|
||||
));
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { TableButtons } from '@/components/data-table';
|
||||
import { EventsTable } from '@/components/events/table';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { api } from '@/trpc/client';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
import EventListener from './event-list/event-listener';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId?: string;
|
||||
};
|
||||
|
||||
const Events = ({ projectId, profileId }: Props) => {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [eventNames] = useEventQueryNamesFilter();
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(0)
|
||||
);
|
||||
const query = api.event.events.useQuery(
|
||||
{
|
||||
cursor,
|
||||
projectId,
|
||||
take: 50,
|
||||
events: eventNames,
|
||||
filters,
|
||||
profileId,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableButtons>
|
||||
<EventListener onRefresh={() => query.refetch()} />
|
||||
<OverviewFiltersDrawer
|
||||
mode="events"
|
||||
projectId={projectId}
|
||||
enableEventsFilter
|
||||
/>
|
||||
<OverviewFiltersButtons className="justify-end p-0" />
|
||||
{query.isRefetching && (
|
||||
<div className="center-center size-8 rounded border bg-background">
|
||||
<Loader2Icon
|
||||
size={12}
|
||||
className="size-4 shrink-0 animate-spin text-black text-highlight"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableButtons>
|
||||
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
||||
@@ -1,77 +1,50 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import {
|
||||
eventQueryFiltersParser,
|
||||
eventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { parseAsStringEnum } from 'nuqs';
|
||||
|
||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||
import { EventsPerDayChart } from './charts/events-per-day-chart';
|
||||
import EventConversionsListServer from './event-conversions-list';
|
||||
import EventListServer from './event-list';
|
||||
import Charts from './charts';
|
||||
import Conversions from './conversions';
|
||||
import Events from './events';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
projectId: string;
|
||||
organizationSlug: string;
|
||||
};
|
||||
searchParams: {
|
||||
events?: string;
|
||||
cursor?: string;
|
||||
f?: string;
|
||||
};
|
||||
searchParams: Record<string, string>;
|
||||
}
|
||||
|
||||
const nuqsOptions = {
|
||||
shallow: false,
|
||||
};
|
||||
|
||||
export default function Page({
|
||||
params: { projectId, organizationSlug },
|
||||
params: { projectId },
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const cursor =
|
||||
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined;
|
||||
const filters =
|
||||
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined;
|
||||
const eventNames =
|
||||
eventQueryNamesFilter.parseServerSide(searchParams.events) ?? undefined;
|
||||
const tab = parseAsStringEnum(['events', 'conversions', 'charts'])
|
||||
.withDefault('events')
|
||||
.parseServerSide(searchParams.tab);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout title="Events" organizationSlug={organizationSlug} />
|
||||
<StickyBelowHeader className="flex justify-between p-4">
|
||||
<OverviewFiltersDrawer
|
||||
mode="events"
|
||||
projectId={projectId}
|
||||
nuqsOptions={nuqsOptions}
|
||||
enableEventsFilter
|
||||
/>
|
||||
<OverviewFiltersButtons
|
||||
className="justify-end p-0"
|
||||
nuqsOptions={nuqsOptions}
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<div className="grid gap-4 p-4 md:grid-cols-2">
|
||||
<div>
|
||||
<EventListServer
|
||||
projectId={projectId}
|
||||
cursor={cursor}
|
||||
filters={filters}
|
||||
eventNames={eventNames}
|
||||
/>
|
||||
<Padding>
|
||||
<div className="mb-4">
|
||||
<PageTabs>
|
||||
<PageTabsLink href={`?tab=events`} isActive={tab === 'events'}>
|
||||
Events
|
||||
</PageTabsLink>
|
||||
<PageTabsLink
|
||||
href={`?tab=conversions`}
|
||||
isActive={tab === 'conversions'}
|
||||
>
|
||||
Conversions
|
||||
</PageTabsLink>
|
||||
<PageTabsLink href={`?tab=charts`} isActive={tab === 'charts'}>
|
||||
Charts
|
||||
</PageTabsLink>
|
||||
</PageTabs>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<EventsPerDayChart
|
||||
projectId={projectId}
|
||||
events={eventNames}
|
||||
filters={filters}
|
||||
/>
|
||||
<EventConversionsListServer projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'events' && <Events projectId={projectId} />}
|
||||
{tab === 'conversions' && <Conversions projectId={projectId} />}
|
||||
{tab === 'charts' && <Charts projectId={projectId} />}
|
||||
</Padding>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useSelectedLayoutSegments } from 'next/navigation';
|
||||
|
||||
const NOT_MIGRATED_PAGES = ['reports'];
|
||||
|
||||
export default function LayoutContent({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const segments = useSelectedLayoutSegments();
|
||||
|
||||
if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) {
|
||||
return <div className="transition-all lg:pl-72">{children}</div>;
|
||||
}
|
||||
|
||||
return <div className="transition-all max-lg:mt-12 lg:pl-72">{children}</div>;
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useUser } from '@clerk/nextjs';
|
||||
import {
|
||||
BookmarkIcon,
|
||||
BuildingIcon,
|
||||
CogIcon,
|
||||
GanttChartIcon,
|
||||
Globe2Icon,
|
||||
KeySquareIcon,
|
||||
LayoutPanelTopIcon,
|
||||
UserIcon,
|
||||
UserSearchIcon,
|
||||
PlusIcon,
|
||||
ScanEyeIcon,
|
||||
UsersIcon,
|
||||
WallpaperIcon,
|
||||
WarehouseIcon,
|
||||
} from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
@@ -42,7 +39,7 @@ function LinkWithIcon({
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
'text-text hover:bg-def-200 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-all transition-colors',
|
||||
'text-text flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200',
|
||||
active && 'bg-def-200',
|
||||
className
|
||||
)}
|
||||
@@ -60,7 +57,6 @@ interface LayoutMenuProps {
|
||||
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
||||
const { user } = useUser();
|
||||
|
||||
const pathname = usePathname();
|
||||
const params = useAppParams();
|
||||
const hasProjectId =
|
||||
params.projectId &&
|
||||
@@ -103,60 +99,40 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
||||
label="Events"
|
||||
href={`/${params.organizationSlug}/${projectId}/events`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={UserSearchIcon}
|
||||
label="Retention"
|
||||
href={`/${params.organizationSlug}/${projectId}/retention`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={UsersIcon}
|
||||
label="Profiles"
|
||||
href={`/${params.organizationSlug}/${projectId}/profiles`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={CogIcon}
|
||||
label="Settings"
|
||||
href={`/${params.organizationSlug}/${projectId}/settings/organization`}
|
||||
icon={ScanEyeIcon}
|
||||
label="Retention"
|
||||
href={`/${params.organizationSlug}/${projectId}/retention`}
|
||||
/>
|
||||
{pathname?.includes('/settings/') && (
|
||||
<div className="flex flex-col gap-1 pl-7">
|
||||
<LinkWithIcon
|
||||
icon={BuildingIcon}
|
||||
label="Organization"
|
||||
href={`/${params.organizationSlug}/${projectId}/settings/organization`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={WarehouseIcon}
|
||||
label="Projects"
|
||||
href={`/${params.organizationSlug}/${projectId}/settings/projects`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={UserIcon}
|
||||
label="Profile (yours)"
|
||||
href={`/${params.organizationSlug}/${projectId}/settings/profile`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={BookmarkIcon}
|
||||
label="References"
|
||||
href={`/${params.organizationSlug}/${projectId}/settings/references`}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-muted-foreground">Your dashboards</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => pushModal('AddDashboard')}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{dashboards.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="mb-2 text-sm font-medium">Your dashboards</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dashboards.map((item) => (
|
||||
<LinkWithIcon
|
||||
key={item.id}
|
||||
icon={LayoutPanelTopIcon}
|
||||
label={item.name}
|
||||
href={`/${item.organizationSlug}/${item.projectId}/dashboards/${item.id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dashboards.map((item) => (
|
||||
<LinkWithIcon
|
||||
key={item.id}
|
||||
icon={LayoutPanelTopIcon}
|
||||
label={item.name}
|
||||
href={`/${item.organizationSlug}/${item.projectId}/dashboards/${item.id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
Building2Icon,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
ChevronsUpDownIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import type { getProjectsByOrganizationSlug } from '@openpanel/db';
|
||||
import type {
|
||||
getCurrentOrganizations,
|
||||
getProjectsByOrganizationSlug,
|
||||
} from '@openpanel/db';
|
||||
|
||||
interface LayoutProjectSelectorProps {
|
||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
|
||||
organizations?: Awaited<ReturnType<typeof getCurrentOrganizations>>;
|
||||
align?: 'start' | 'end';
|
||||
}
|
||||
export default function LayoutProjectSelector({
|
||||
projects,
|
||||
organizations,
|
||||
align = 'start',
|
||||
}: LayoutProjectSelectorProps) {
|
||||
const router = useRouter();
|
||||
const { organizationSlug, projectId } = useAppParams();
|
||||
const pathname = usePathname() || '';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const changeProject = (newProjectId: string) => {
|
||||
if (organizationSlug && projectId) {
|
||||
const split = pathname
|
||||
.replace(
|
||||
`/${organizationSlug}/${projectId}`,
|
||||
`/${organizationSlug}/${newProjectId}`
|
||||
)
|
||||
.split('/');
|
||||
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
|
||||
router.push(split.slice(0, 4).join('/'));
|
||||
} else {
|
||||
router.push(`/${organizationSlug}/${newProjectId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const changeOrganization = (newOrganizationId: string) => {
|
||||
router.push(`/${newOrganizationId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Combobox
|
||||
portal
|
||||
align="end"
|
||||
className="w-auto min-w-0 max-sm:max-w-[100px]"
|
||||
placeholder={'Select project'}
|
||||
onChange={(value) => {
|
||||
if (organizationSlug && projectId) {
|
||||
const split = pathname.replace(projectId, value).split('/');
|
||||
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
|
||||
router.push(split.slice(0, 4).join('/'));
|
||||
} else {
|
||||
router.push(`/${organizationSlug}/${value}`);
|
||||
}
|
||||
}}
|
||||
value={projectId}
|
||||
items={
|
||||
projects.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex min-w-0 flex-1 items-center justify-start"
|
||||
>
|
||||
<Building2Icon size={16} className="shrink-0" />
|
||||
<span className="mx-2 truncate">
|
||||
{projectId
|
||||
? projects.find((p) => p.id === projectId)?.name
|
||||
: 'Select project'}
|
||||
</span>
|
||||
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} className="w-[200px]">
|
||||
<DropdownMenuLabel>Projects</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((project) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => changeProject(project.id)}
|
||||
>
|
||||
{project.name}
|
||||
{project.id === projectId && (
|
||||
<DropdownMenuShortcut>
|
||||
<CheckIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-emerald-600"
|
||||
onClick={() => pushModal('AddProject')}
|
||||
>
|
||||
Create new project
|
||||
<DropdownMenuShortcut>
|
||||
<PlusIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
{!!organizations && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((organization) => (
|
||||
<DropdownMenuItem
|
||||
key={organization.id}
|
||||
onClick={() => changeOrganization(organization.id)}
|
||||
>
|
||||
{organization.name}
|
||||
{organization.id === organizationSlug && (
|
||||
<DropdownMenuShortcut>
|
||||
<CheckIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled>
|
||||
New organization
|
||||
<DropdownMenuShortcut>
|
||||
<PlusIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { LogoSquare } from '@/components/logo';
|
||||
import SettingsToggle from '@/components/settings-toggle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Rotate as Hamburger } from 'hamburger-react';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { MenuIcon, XIcon } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
|
||||
import type {
|
||||
getProjectsByOrganizationSlug,
|
||||
IServiceDashboards,
|
||||
IServiceOrganization,
|
||||
} from '@openpanel/db';
|
||||
|
||||
import LayoutMenu from './layout-menu';
|
||||
import LayoutOrganizationSelector from './layout-organization-selector';
|
||||
import LayoutProjectSelector from './layout-project-selector';
|
||||
|
||||
interface LayoutSidebarProps {
|
||||
organizations: IServiceOrganization[];
|
||||
dashboards: IServiceDashboards;
|
||||
organizationSlug: string;
|
||||
projectId: string;
|
||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
|
||||
}
|
||||
export function LayoutSidebar({
|
||||
organizations,
|
||||
dashboards,
|
||||
organizationSlug,
|
||||
projectId,
|
||||
projects,
|
||||
}: LayoutSidebarProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const pathname = usePathname();
|
||||
@@ -52,30 +56,28 @@ export function LayoutSidebar({
|
||||
)}
|
||||
>
|
||||
<div className="absolute -right-12 flex h-16 items-center lg:hidden">
|
||||
<Hamburger toggled={active} onToggle={setActive} size={20} />
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => setActive((p) => !p)}
|
||||
variant={'outline'}
|
||||
>
|
||||
{active ? <XIcon size={16} /> : <MenuIcon size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-16 shrink-0 items-center border-b border-border px-4">
|
||||
<Link href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
<div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4">
|
||||
<LogoSquare className="max-h-8" />
|
||||
<LayoutProjectSelector
|
||||
align="start"
|
||||
projects={projects}
|
||||
organizations={organizations}
|
||||
/>
|
||||
<SettingsToggle />
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col gap-2 overflow-auto p-4">
|
||||
<LayoutMenu dashboards={dashboards} />
|
||||
{/* Placeholder for LayoutOrganizationSelector */}
|
||||
<div className="block h-32 shrink-0"></div>
|
||||
</div>
|
||||
<div className="fixed bottom-0 left-0 right-0">
|
||||
<div className="h-8 w-full bg-gradient-to-t from-card to-card/0"></div>
|
||||
<div className="flex flex-col gap-2 bg-card p-4 pt-0">
|
||||
<Link
|
||||
className={cn('flex gap-2', buttonVariants())}
|
||||
href={`/${organizationSlug}/${projectId}/reports`}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
Create a report
|
||||
</Link>
|
||||
<LayoutOrganizationSelector organizations={organizations} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function StickyBelowHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'top-0 z-20 border-b border-border bg-card md:sticky [[id=dashboard]_&]:top-16 [[id=dashboard]_&]:rounded-none',
|
||||
'top-0 z-20 border-b border-border bg-card md:sticky',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getDashboardsByProjectId,
|
||||
} from '@openpanel/db';
|
||||
|
||||
import LayoutContent from './layout-content';
|
||||
import { LayoutSidebar } from './layout-sidebar';
|
||||
import SideEffects from './side-effects';
|
||||
|
||||
@@ -46,9 +47,15 @@ export default async function AppLayout({
|
||||
return (
|
||||
<div id="dashboard">
|
||||
<LayoutSidebar
|
||||
{...{ organizationSlug, projectId, organizations, dashboards }}
|
||||
{...{
|
||||
organizationSlug,
|
||||
projectId,
|
||||
organizations,
|
||||
projects,
|
||||
dashboards,
|
||||
}}
|
||||
/>
|
||||
<div className="transition-all lg:pl-72">{children}</div>
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
<SideEffects />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,39 +1,15 @@
|
||||
import DarkModeToggle from '@/components/dark-mode-toggle';
|
||||
import withSuspense from '@/hocs/with-suspense';
|
||||
|
||||
import { getCurrentProjects } from '@openpanel/db';
|
||||
|
||||
import LayoutProjectSelector from './layout-project-selector';
|
||||
|
||||
interface PageLayoutProps {
|
||||
title: React.ReactNode;
|
||||
organizationSlug: string;
|
||||
}
|
||||
|
||||
async function PageLayout({ title, organizationSlug }: PageLayoutProps) {
|
||||
const projects = await getCurrentProjects(organizationSlug);
|
||||
|
||||
function PageLayout({ title }: PageLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4 pl-12 lg:pl-4">
|
||||
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4 pl-14 lg:pl-4">
|
||||
<div className="text-xl font-medium">{title}</div>
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<DarkModeToggle className="hidden sm:flex" />
|
||||
</div>
|
||||
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Loading = ({ title }: PageLayoutProps) => (
|
||||
<>
|
||||
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4 pl-12 lg:pl-4">
|
||||
<div className="text-xl font-medium">{title}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default withSuspense(PageLayout, Loading);
|
||||
export default PageLayout;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import ServerLiveCounter from '@/components/overview/live-counter';
|
||||
@@ -10,7 +9,6 @@ import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
|
||||
import OverviewMetrics from '../../../../components/overview/overview-metrics';
|
||||
import { StickyBelowHeader } from './layout-sticky-below-header';
|
||||
import { OverviewReportRange } from './overview-sticky-header';
|
||||
|
||||
interface PageProps {
|
||||
@@ -20,14 +18,11 @@ interface PageProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
params: { organizationSlug, projectId },
|
||||
}: PageProps) {
|
||||
export default function Page({ params: { projectId } }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<PageLayout title="Overview" organizationSlug={organizationSlug} />
|
||||
<StickyBelowHeader>
|
||||
<div className="flex justify-between gap-2 p-4">
|
||||
<div className="col gap-2 p-4">
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<OverviewReportRange />
|
||||
<OverviewFiltersDrawer projectId={projectId} mode="events" />
|
||||
@@ -38,8 +33,8 @@ export default function Page({
|
||||
</div>
|
||||
</div>
|
||||
<OverviewFiltersButtons />
|
||||
</StickyBelowHeader>
|
||||
<div className="grid grid-cols-6 gap-4 p-4">
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
|
||||
@@ -18,12 +18,12 @@ const MostEvents = ({ data }: Props) => {
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.name} className="relative px-3 py-2">
|
||||
<div
|
||||
className="bg-def-200 absolute bottom-0 left-0 top-0 rounded"
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
<div className="relative flex justify-between text-sm">
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.name}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { start } from 'repl';
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import ClickToCopy from '@/components/click-to-copy';
|
||||
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import {
|
||||
eventQueryFiltersParser,
|
||||
eventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
|
||||
import type { GetEventListOptions } from '@openpanel/db';
|
||||
import { getProfileById } from '@openpanel/db';
|
||||
import { getProfileByIdCached } from '@openpanel/db';
|
||||
|
||||
import EventListServer from '../../events/event-list';
|
||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
||||
import MostEventsServer from './most-events';
|
||||
import PopularRoutesServer from './popular-routes';
|
||||
import ProfileActivityServer from './profile-activity';
|
||||
import ProfileCharts from './profile-charts';
|
||||
import Events from './profile-events';
|
||||
import ProfileMetrics from './profile-metrics';
|
||||
|
||||
interface PageProps {
|
||||
@@ -38,66 +30,50 @@ interface PageProps {
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { projectId, profileId, organizationSlug },
|
||||
searchParams,
|
||||
params: { projectId, profileId },
|
||||
}: PageProps) {
|
||||
const eventListOptions: GetEventListOptions = {
|
||||
projectId,
|
||||
profileId,
|
||||
take: 50,
|
||||
cursor:
|
||||
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
|
||||
events: eventQueryNamesFilter.parseServerSide(searchParams.events ?? ''),
|
||||
filters:
|
||||
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ??
|
||||
undefined,
|
||||
};
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
const profile = await getProfileByIdCached(profileId, projectId);
|
||||
|
||||
if (!profile) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout organizationSlug={organizationSlug} title={<div />} />
|
||||
<StickyBelowHeader className="!relative !top-auto !z-0 flex flex-col gap-8 p-4 md:flex-row md:items-center md:p-8">
|
||||
<div className="flex flex-1 gap-4">
|
||||
<ProfileAvatar {...profile} size={'lg'} />
|
||||
<div className="min-w-0">
|
||||
<ClickToCopy value={profile.id}>
|
||||
<h1 className="max-w-full overflow-hidden text-ellipsis break-words text-lg font-semibold md:max-w-sm md:whitespace-nowrap md:text-2xl">
|
||||
{getProfileName(profile)}
|
||||
</h1>
|
||||
</ClickToCopy>
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<ListPropertiesIcon {...profile.properties} />
|
||||
</div>
|
||||
</div>
|
||||
<Padding>
|
||||
<div className="row mb-4 items-center gap-4">
|
||||
<ProfileAvatar {...profile} />
|
||||
<div className="min-w-0">
|
||||
<ClickToCopy value={profile.id}>
|
||||
<h1 className="max-w-full truncate text-3xl font-semibold">
|
||||
{getProfileName(profile)}
|
||||
</h1>
|
||||
</ClickToCopy>
|
||||
</div>
|
||||
<ProfileMetrics profileId={profileId} projectId={projectId} />
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-6">
|
||||
<div className="col-span-2">
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="col-span-6">
|
||||
<ProfileMetrics projectId={projectId} profile={profile} />
|
||||
</div>
|
||||
<div className="col-span-6">
|
||||
<ProfileActivityServer
|
||||
profileId={profileId}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="col-span-6 md:col-span-3">
|
||||
<MostEventsServer profileId={profileId} projectId={projectId} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="col-span-6 md:col-span-3">
|
||||
<PopularRoutesServer profileId={profileId} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<ProfileCharts profileId={profileId} projectId={projectId} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<EventListServer {...eventListOptions} />
|
||||
<Events profileId={profileId} projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Padding>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ const PopularRoutes = ({ data }: Props) => {
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.path} className="relative px-3 py-2">
|
||||
<div
|
||||
className="bg-def-200 absolute bottom-0 left-0 top-0 rounded"
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
<div className="relative flex justify-between text-sm">
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.path}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
endOfMonth,
|
||||
format,
|
||||
formatISO,
|
||||
isSameMonth,
|
||||
startOfMonth,
|
||||
subMonths,
|
||||
} from 'date-fns';
|
||||
@@ -43,19 +44,72 @@ const ProfileActivity = ({ data }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={isSameMonth(startDate, new Date())}
|
||||
onClick={() => setStartDate(addMonths(startDate, 1))}
|
||||
>
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
<div className="grid grid-cols-2">
|
||||
<WidgetBody>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="p-1 text-xs">
|
||||
<div className="mb-2 text-sm">
|
||||
{format(subMonths(startDate, 3), 'MMMM yyyy')}
|
||||
</div>
|
||||
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
||||
{eachDayOfInterval({
|
||||
start: startOfMonth(subMonths(startDate, 3)),
|
||||
end: endOfMonth(subMonths(startDate, 3)),
|
||||
}).map((date) => {
|
||||
const hit = data.find((item) =>
|
||||
item.date.includes(
|
||||
formatISO(date, { representation: 'date' })
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className={cn(
|
||||
'aspect-square w-full rounded',
|
||||
hit ? 'bg-highlight' : 'bg-def-200'
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-sm">
|
||||
{format(subMonths(startDate, 2), 'MMMM yyyy')}
|
||||
</div>
|
||||
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
||||
{eachDayOfInterval({
|
||||
start: startOfMonth(subMonths(startDate, 2)),
|
||||
end: endOfMonth(subMonths(startDate, 2)),
|
||||
}).map((date) => {
|
||||
const hit = data.find((item) =>
|
||||
item.date.includes(
|
||||
formatISO(date, { representation: 'date' })
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className={cn(
|
||||
'aspect-square w-full rounded',
|
||||
hit ? 'bg-highlight' : 'bg-def-200'
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-sm">
|
||||
{format(subMonths(startDate, 1), 'MMMM yyyy')}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 p-1">
|
||||
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
||||
{eachDayOfInterval({
|
||||
start: startOfMonth(subMonths(startDate, 1)),
|
||||
end: endOfMonth(subMonths(startDate, 1)),
|
||||
@@ -78,8 +132,8 @@ const ProfileActivity = ({ data }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="p-1 text-xs">{format(startDate, 'MMMM yyyy')}</div>
|
||||
<div className="grid grid-cols-7 gap-1 p-1">
|
||||
<div className="mb-2 text-sm">{format(startDate, 'MMMM yyyy')}</div>
|
||||
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
||||
{eachDayOfInterval({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
|
||||
@@ -80,19 +80,19 @@ const ProfileCharts = ({ profileId, projectId }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-3 w-full">
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<span className="title">Page views</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex gap-2">
|
||||
<WidgetBody>
|
||||
<ChartRoot {...pageViewsChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget className="col-span-3 w-full">
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex gap-2">
|
||||
<WidgetBody>
|
||||
<ChartRoot {...eventsChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { TableButtons } from '@/components/data-table';
|
||||
import { EventsTable } from '@/components/events/table';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { api } from '@/trpc/client';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
import { GetEventListOptions } from '@openpanel/db';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
const Events = ({ projectId, profileId }: Props) => {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [eventNames] = useEventQueryNamesFilter();
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(0)
|
||||
);
|
||||
const query = api.event.events.useQuery(
|
||||
{
|
||||
cursor,
|
||||
projectId,
|
||||
take: 50,
|
||||
events: eventNames,
|
||||
filters,
|
||||
profileId,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableButtons>
|
||||
<OverviewFiltersDrawer
|
||||
mode="events"
|
||||
projectId={projectId}
|
||||
enableEventsFilter
|
||||
/>
|
||||
<OverviewFiltersButtons className="justify-end p-0" />
|
||||
{query.isRefetching && (
|
||||
<div className="center-center size-8 rounded border bg-background">
|
||||
<Loader2Icon
|
||||
size={12}
|
||||
className="size-4 shrink-0 animate-spin text-black text-highlight"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableButtons>
|
||||
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
||||
@@ -1,17 +1,18 @@
|
||||
import withSuspense from '@/hocs/with-suspense';
|
||||
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
import { getProfileMetrics } from '@openpanel/db';
|
||||
|
||||
import ProfileMetrics from './profile-metrics';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
profile: IServiceProfile;
|
||||
};
|
||||
|
||||
const ProfileMetricsServer = async ({ projectId, profileId }: Props) => {
|
||||
const data = await getProfileMetrics(profileId, projectId);
|
||||
return <ProfileMetrics data={data} />;
|
||||
const ProfileMetricsServer = async ({ projectId, profile }: Props) => {
|
||||
const data = await getProfileMetrics(profile.id, projectId);
|
||||
return <ProfileMetrics data={data} profile={profile} />;
|
||||
};
|
||||
|
||||
export default withSuspense(ProfileMetricsServer, () => null);
|
||||
|
||||
@@ -1,66 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { utc } from '@/utils/date';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { formatDateTime, utc } from '@/utils/date';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
import type { IProfileMetrics } from '@openpanel/db';
|
||||
import type { IProfileMetrics, IServiceProfile } from '@openpanel/db';
|
||||
|
||||
type Props = {
|
||||
data: IProfileMetrics;
|
||||
profile: IServiceProfile;
|
||||
};
|
||||
|
||||
const ProfileMetrics = ({ data }: Props) => {
|
||||
function Card({ title, value }: { title: string; value: string }) {
|
||||
return (
|
||||
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
||||
<div className="text-muted-foreground">{title}</div>
|
||||
<div className="font-mono truncate text-2xl font-bold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ title, value }: { title: string; value: string }) {
|
||||
return (
|
||||
<div className="col gap-2">
|
||||
<div className="capitalize text-muted-foreground">{title}</div>
|
||||
<div className="font-mono truncate">{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ProfileMetrics = ({ data, profile }: Props) => {
|
||||
const [tab, setTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum(['profile', 'properties']).withDefault('profile')
|
||||
);
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div className="flex flex-wrap gap-6 whitespace-nowrap md:justify-end md:text-right">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
First seen
|
||||
</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{formatDistanceToNow(utc(data.firstSeen))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Last seen
|
||||
</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{formatDistanceToNow(utc(data.lastSeen))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Sessions
|
||||
</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{number.format(data.sessions)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Avg. Session
|
||||
</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{number.formatWithUnit(data.durationAvg / 1000, 'min')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
P90. Session
|
||||
</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{number.formatWithUnit(data.durationP90 / 1000, 'min')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Page views
|
||||
</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{number.format(data.screenViews)}
|
||||
<div className="@container">
|
||||
<div className="grid grid-cols-2 overflow-hidden whitespace-nowrap rounded-md border bg-background @xl:grid-cols-3 @4xl:grid-cols-6">
|
||||
<div className="col-span-2 @xl:col-span-3 @4xl:col-span-6">
|
||||
<div className="row border-b">
|
||||
<button
|
||||
onClick={() => setTab('profile')}
|
||||
className={cn(
|
||||
'p-4',
|
||||
'opacity-50',
|
||||
tab === 'profile' &&
|
||||
'border-b border-foreground text-foreground opacity-100'
|
||||
)}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<div className="h-full w-px bg-border" />
|
||||
<button
|
||||
onClick={() => setTab('properties')}
|
||||
className={cn(
|
||||
'p-4',
|
||||
'opacity-50',
|
||||
tab === 'properties' &&
|
||||
'border-b border-foreground text-foreground opacity-100'
|
||||
)}
|
||||
>
|
||||
Properties
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 p-4">
|
||||
{tab === 'profile' && (
|
||||
<>
|
||||
<Info title="ID" value={profile.id} />
|
||||
<Info title="First name" value={profile.firstName} />
|
||||
<Info title="Last name" value={profile.lastName} />
|
||||
<Info title="Email" value={profile.email} />
|
||||
<Info
|
||||
title="Updated"
|
||||
value={formatDateTime(new Date(profile.createdAt))}
|
||||
/>
|
||||
<ListPropertiesIcon {...profile.properties} />
|
||||
</>
|
||||
)}
|
||||
{tab === 'properties' && (
|
||||
<>
|
||||
{Object.entries(profile.properties)
|
||||
.filter(([key, value]) => value !== undefined)
|
||||
.map(([key, value]) => (
|
||||
<Info key={key} title={key} value={value as string} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Card
|
||||
title="First seen"
|
||||
value={formatDistanceToNow(utc(data.firstSeen))}
|
||||
/>
|
||||
<Card
|
||||
title="Last seen"
|
||||
value={formatDistanceToNow(utc(data.lastSeen))}
|
||||
/>
|
||||
<Card title="Sessions" value={number.format(data.sessions)} />
|
||||
<Card
|
||||
title="Avg. Session"
|
||||
value={number.formatWithUnit(data.durationAvg / 1000, 'min')}
|
||||
/>
|
||||
<Card
|
||||
title="P90. Session"
|
||||
value={number.formatWithUnit(data.durationP90 / 1000, 'min')}
|
||||
/>
|
||||
<Card title="Page views" value={number.format(data.screenViews)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
import { Suspense } from 'react';
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { parseAsStringEnum } from 'nuqs';
|
||||
|
||||
import LastActiveUsersServer from '../retention/last-active-users';
|
||||
import ProfileListServer from './profile-list';
|
||||
import ProfileTopServer from './profile-top';
|
||||
import PowerUsers from './power-users';
|
||||
import Profiles from './profiles';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationSlug: string;
|
||||
projectId: string;
|
||||
organizationSlug: string;
|
||||
};
|
||||
searchParams: {
|
||||
f?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
searchParams: Record<string, string>;
|
||||
}
|
||||
|
||||
const nuqsOptions = {
|
||||
shallow: false,
|
||||
};
|
||||
|
||||
export default function Page({
|
||||
params: { organizationSlug, projectId },
|
||||
searchParams: { cursor, f },
|
||||
params: { projectId },
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const tab = parseAsStringEnum(['profiles', 'power-users'])
|
||||
.withDefault('profiles')
|
||||
.parseServerSide(searchParams.tab);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout title="Profiles" organizationSlug={organizationSlug} />
|
||||
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
|
||||
<ProfileListServer
|
||||
projectId={projectId}
|
||||
cursor={parseAsInteger.parseServerSide(cursor ?? '') ?? undefined}
|
||||
filters={
|
||||
eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LastActiveUsersServer projectId={projectId} />
|
||||
<ProfileTopServer
|
||||
projectId={projectId}
|
||||
organizationSlug={organizationSlug}
|
||||
/>
|
||||
<Padding>
|
||||
<div className="mb-4">
|
||||
<PageTabs>
|
||||
<PageTabsLink href={`?tab=profiles`} isActive={tab === 'profiles'}>
|
||||
Profiles
|
||||
</PageTabsLink>
|
||||
<PageTabsLink
|
||||
href={`?tab=power-users`}
|
||||
isActive={tab === 'power-users'}
|
||||
>
|
||||
Power users
|
||||
</PageTabsLink>
|
||||
</PageTabs>
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'profiles' && <Profiles projectId={projectId} />}
|
||||
{tab === 'power-users' && <PowerUsers projectId={projectId} />}
|
||||
</Padding>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { ProfilesTable } from '@/components/profiles/table';
|
||||
import { api } from '@/trpc/client';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId?: string;
|
||||
};
|
||||
|
||||
const Events = ({ projectId }: Props) => {
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(0)
|
||||
);
|
||||
const query = api.profile.powerUsers.useQuery(
|
||||
{
|
||||
cursor,
|
||||
projectId,
|
||||
take: 50,
|
||||
// filters,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProfilesTable
|
||||
query={query}
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
type="power-users"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
||||
@@ -61,7 +61,7 @@ export default async function ProfileLastSeenServer({ projectId }: Props) {
|
||||
<div className="flex w-full flex-wrap items-start justify-start">
|
||||
{res.map(renderItem)}
|
||||
</div>
|
||||
<div className="text-center text-xs text-muted-foreground">DAYS</div>
|
||||
<div className="text-center text-sm text-muted-foreground">DAYS</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
|
||||
import { getProfileList, getProfileListCount } from '@openpanel/db';
|
||||
import type { IChartEventFilter } from '@openpanel/validation';
|
||||
|
||||
import { ProfileList } from './profile-list';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
cursor?: number;
|
||||
filters?: IChartEventFilter[];
|
||||
}
|
||||
|
||||
const limit = 40;
|
||||
|
||||
async function ProfileListServer({ projectId, cursor, filters }: Props) {
|
||||
const [profiles, count] = await Promise.all([
|
||||
getProfileList({
|
||||
projectId,
|
||||
take: limit,
|
||||
cursor,
|
||||
filters,
|
||||
}),
|
||||
getProfileListCount({
|
||||
projectId,
|
||||
filters,
|
||||
}),
|
||||
]);
|
||||
return <ProfileList data={profiles} count={count} limit={limit} />;
|
||||
}
|
||||
|
||||
export default withLoadingWidget(ProfileListServer);
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { Widget, WidgetHead } from '@/components/widget';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useCursor } from '@/hooks/useCursor';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
|
||||
interface ProfileListProps {
|
||||
data: IServiceProfile[];
|
||||
count: number;
|
||||
limit?: number;
|
||||
}
|
||||
export function ProfileList({ data, count, limit = 50 }: ProfileListProps) {
|
||||
const { organizationSlug, projectId } = useAppParams();
|
||||
const { cursor, setCursor, loading } = useCursor();
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<div className="title">Profiles</div>
|
||||
<Pagination
|
||||
size="sm"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
loading={loading}
|
||||
count={count}
|
||||
take={limit}
|
||||
/>
|
||||
</WidgetHead>
|
||||
{data.length ? (
|
||||
<>
|
||||
<WidgetTable
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
columns={[
|
||||
{
|
||||
name: 'Name',
|
||||
render(profile) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${organizationSlug}/${projectId}/profiles/${profile.id}`}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
title={getProfileName(profile, false)}
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
render(profile) {
|
||||
return <ListPropertiesIcon {...profile.properties} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Last seen',
|
||||
render(profile) {
|
||||
return (
|
||||
<Tooltiper
|
||||
asChild
|
||||
content={profile.createdAt.toLocaleString()}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{profile.createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="border-t border-border p-4">
|
||||
<Pagination
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={limit}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<FullPageEmptyState title="No profiles" icon={UsersIcon}>
|
||||
{cursor !== 0 ? (
|
||||
<>
|
||||
<p>Looks like you have reached the end of the list</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCursor(Math.max(0, count / limit - 1))}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p>Looks like there are no profiles here</p>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { Widget, WidgetHead } from '@/components/widget';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import Link from 'next/link';
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
import { chQuery, getProfiles, TABLE_NAMES } from '@openpanel/db';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
organizationSlug: string;
|
||||
}
|
||||
|
||||
async function ProfileTopServer({ organizationSlug, projectId }: Props) {
|
||||
// Days since last event from users
|
||||
// group by days
|
||||
const res = await chQuery<{ profile_id: string; count: number }>(
|
||||
`SELECT profile_id, count(*) as count from ${TABLE_NAMES.events} where profile_id != '' and project_id = ${escape(projectId)} group by profile_id order by count() DESC LIMIT 50`
|
||||
);
|
||||
const profiles = await getProfiles(res.map((r) => r.profile_id));
|
||||
const list = res.map((item) => {
|
||||
return {
|
||||
count: item.count,
|
||||
...(profiles.find((p) => p.id === item.profile_id)! ?? {}),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<div className="title">Power users</div>
|
||||
</WidgetHead>
|
||||
<WidgetTable
|
||||
data={list.filter((item) => !!item.id)}
|
||||
keyExtractor={(item) => item.id}
|
||||
columns={[
|
||||
{
|
||||
name: 'Name',
|
||||
render(profile) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${organizationSlug}/${projectId}/profiles/${profile.id}`}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
render(profile) {
|
||||
return <ListPropertiesIcon {...profile.properties} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
render(profile) {
|
||||
return profile.count;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
export default withLoadingWidget(ProfileTopServer);
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { ProfilesTable } from '@/components/profiles/table';
|
||||
import { api } from '@/trpc/client';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
profileId?: string;
|
||||
};
|
||||
|
||||
const Events = ({ projectId }: Props) => {
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(0)
|
||||
);
|
||||
const query = api.profile.list.useQuery(
|
||||
{
|
||||
cursor,
|
||||
projectId,
|
||||
take: 50,
|
||||
// filters,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProfilesTable query={query} cursor={cursor} setCursor={setCursor} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
||||
@@ -99,7 +99,7 @@ const Map = ({ markers }: Props) => {
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 right-0 top-0',
|
||||
!isFullscreen && 'top-16 lg:left-72'
|
||||
!isFullscreen && 'lg:left-72'
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
@@ -123,8 +123,8 @@ const Map = ({ markers }: Props) => {
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={theme.theme === 'dark' ? '#0f0f0f' : '#F0F4F9'}
|
||||
stroke={theme.theme === 'dark' ? '#262626' : '#DDE3E9'}
|
||||
fill={theme.theme === 'dark' ? '#000' : '#e5eef6'}
|
||||
stroke={theme.theme === 'dark' ? '#333' : '#bcccda'}
|
||||
pointerEvents={'none'}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -18,23 +18,19 @@ type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
};
|
||||
export default function Page({
|
||||
params: { projectId, organizationSlug },
|
||||
}: Props) {
|
||||
export default function Page({ params: { projectId } }: Props) {
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
title={<FullscreenOpen />}
|
||||
{...{ projectId, organizationSlug }}
|
||||
/>
|
||||
<Fullscreen>
|
||||
<FullscreenClose />
|
||||
<RealtimeReloader projectId={projectId} />
|
||||
<Suspense>
|
||||
<RealtimeMap projectId={projectId} />
|
||||
</Suspense>
|
||||
<div className="relative z-10 grid min-h-[calc(100vh-theme(spacing.16))] items-start gap-4 overflow-hidden p-8 md:grid-cols-3">
|
||||
<div className="card bg-card/80 p-4">
|
||||
|
||||
<div className="row relative z-10 min-h-screen items-start gap-4 overflow-hidden p-8">
|
||||
<FullscreenOpen />
|
||||
<div className="card min-w-52 bg-card/80 p-4 md:min-w-80">
|
||||
<RealtimeLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
|
||||
@@ -83,7 +83,7 @@ export function RealtimeLiveHistogram({
|
||||
{staticArray.map((percent, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 animate-pulse rounded-md bg-def-200"
|
||||
className="flex-1 animate-pulse rounded bg-def-200"
|
||||
style={{ height: `${percent}%` }}
|
||||
/>
|
||||
))}
|
||||
@@ -103,7 +103,7 @@ export function RealtimeLiveHistogram({
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 rounded-md transition-all ease-in-out hover:scale-110',
|
||||
'flex-1 rounded transition-all ease-in-out hover:scale-110',
|
||||
minute.count === 0 ? 'bg-def-200' : 'bg-highlight'
|
||||
)}
|
||||
style={{
|
||||
@@ -138,11 +138,11 @@ const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4">
|
||||
<div className="col gap-2 p-4">
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Unique vistors last 30 minutes
|
||||
</div>
|
||||
<div className="text-6xl font-bold">
|
||||
<div className="font-mono text-6xl font-bold">
|
||||
<AnimatedNumbers
|
||||
includeComma
|
||||
transitions={(index) => ({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import EditReportName from '@/components/report/edit-report-name';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getReportById } from '@openpanel/db';
|
||||
@@ -9,15 +8,12 @@ import ReportEditor from '../report-editor';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationSlug: string;
|
||||
projectId: string;
|
||||
reportId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { reportId, organizationSlug },
|
||||
}: PageProps) {
|
||||
export default async function Page({ params: { reportId } }: PageProps) {
|
||||
const report = await getReportById(reportId);
|
||||
|
||||
if (!report) {
|
||||
@@ -26,10 +22,7 @@ export default async function Page({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
organizationSlug={organizationSlug}
|
||||
title={<EditReportName name={report.name} />}
|
||||
/>
|
||||
<PageLayout title={<EditReportName name={report.name} />} />
|
||||
<ReportEditor report={report} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import EditReportName from '@/components/report/edit-report-name';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
||||
import ReportEditor from './report-editor';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({ params: { organizationSlug } }: PageProps) {
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
organizationSlug={organizationSlug}
|
||||
title={<EditReportName name={undefined} />}
|
||||
/>
|
||||
<PageLayout title={<EditReportName name={undefined} />} />
|
||||
<ReportEditor report={null} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -23,15 +23,15 @@ function Tooltip(props: any) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 text-sm shadow-xl">
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Days since last seen
|
||||
</div>
|
||||
<div className="text-lg font-semibold">{payload.days}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Active users</div>
|
||||
<div className="text-sm text-muted-foreground">Active users</div>
|
||||
<div className="text-lg font-semibold">{payload.users}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
|
||||
import PageLayout from '../page-layout';
|
||||
import LastActiveUsersServer from './last-active-users';
|
||||
import RollingActiveUsers from './rolling-active-users';
|
||||
import UsersRetentionSeries from './users-retention-series';
|
||||
@@ -9,16 +9,15 @@ import WeeklyCohortsServer from './weekly-cohorts';
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
organizationSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
};
|
||||
|
||||
const Retention = ({ params: { projectId, organizationSlug } }: Props) => {
|
||||
const Retention = ({ params: { projectId } }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<PageLayout title="Retention" organizationSlug={organizationSlug} />
|
||||
<div className="flex flex-col gap-8 p-8">
|
||||
<Padding>
|
||||
<h1 className="mb-4 text-3xl font-semibold">Retention</h1>
|
||||
<div className="flex max-w-6xl flex-col gap-8">
|
||||
<Alert>
|
||||
<AlertCircleIcon size={18} />
|
||||
<AlertTitle>Experimental feature</AlertTitle>
|
||||
@@ -59,7 +58,7 @@ const Retention = ({ params: { projectId, organizationSlug } }: Props) => {
|
||||
<UsersRetentionSeries projectId={projectId} />
|
||||
<WeeklyCohortsServer projectId={projectId} />
|
||||
</div>
|
||||
</>
|
||||
</Padding>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,20 +29,20 @@ function Tooltip(props: any) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 text-sm shadow-xl">
|
||||
<div className="text-xs text-muted-foreground">{payload.date}</div>
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<div className="text-sm text-muted-foreground">{payload.date}</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Monthly active users
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-chart-2">{payload.mau}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Weekly active users</div>
|
||||
<div className="text-sm text-muted-foreground">Weekly active users</div>
|
||||
<div className="text-lg font-semibold text-chart-1">{payload.wau}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Daily active users</div>
|
||||
<div className="text-sm text-muted-foreground">Daily active users</div>
|
||||
<div className="text-lg font-semibold text-chart-0">{payload.dau}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,20 +33,20 @@ function Tooltip({ payload }: any) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 text-sm shadow-xl">
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{formatDate(new Date(date))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Active Users</div>
|
||||
<div className="text-sm text-muted-foreground">Active Users</div>
|
||||
<div className="text-lg font-semibold">{active_users}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Retained Users</div>
|
||||
<div className="text-sm text-muted-foreground">Retained Users</div>
|
||||
<div className="text-lg font-semibold">{retained_users}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Retention</div>
|
||||
<div className="text-sm text-muted-foreground">Retention</div>
|
||||
<div className="text-lg font-semibold">{round(retention, 2)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ const Cell = ({ value, ratio }: { value: number; ratio: number }) => {
|
||||
className={cn('relative h-8 border', ratio !== 0 && 'border-background')}
|
||||
>
|
||||
<div
|
||||
className="bg-highlight absolute inset-0 z-0"
|
||||
className="absolute inset-0 z-0 bg-highlight"
|
||||
style={{ opacity: ratio }}
|
||||
></div>
|
||||
<div className="relative z-10">{value}</div>
|
||||
@@ -76,7 +76,7 @@ const WeeklyCohortsServer = async ({ projectId }: Props) => {
|
||||
<tbody>
|
||||
{res.map((row) => (
|
||||
<tr key={row.first_seen}>
|
||||
<td className="bg-def-100 text-def-1000 text-sm font-medium">
|
||||
<td className="text-def-1000 bg-def-100 font-medium">
|
||||
{row.first_seen}
|
||||
</td>
|
||||
<Cell
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
@@ -126,7 +128,7 @@ export default function CreateInvite({ projects }: Props) {
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Leave empty to give access to all projects
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { TableButtons } from '@/components/data-table';
|
||||
import { InvitesTable } from '@/components/settings/invites';
|
||||
|
||||
import { getInvites, getProjectsByOrganizationSlug } from '@openpanel/db';
|
||||
|
||||
import Invites from './invites';
|
||||
import CreateInvite from './create-invite';
|
||||
|
||||
interface Props {
|
||||
organizationSlug: string;
|
||||
@@ -12,7 +15,14 @@ const InvitesServer = async ({ organizationSlug }: Props) => {
|
||||
getProjectsByOrganizationSlug(organizationSlug),
|
||||
]);
|
||||
|
||||
return <Invites invites={invites} projects={projects} />;
|
||||
return (
|
||||
<div>
|
||||
<TableButtons>
|
||||
<CreateInvite projects={projects} />
|
||||
</TableButtons>
|
||||
<InvitesTable data={invites} projects={projects} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitesServer;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Widget, WidgetHead } from '@/components/widget';
|
||||
import { api } from '@/trpc/client';
|
||||
import { MoreHorizontalIcon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { pathOr } from 'ramda';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
|
||||
|
||||
import CreateInvite from './create-invite';
|
||||
|
||||
interface Props {
|
||||
invites: IServiceInvite[];
|
||||
projects: IServiceProject[];
|
||||
}
|
||||
|
||||
const Invites = ({ invites, projects }: Props) => {
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Invites</span>
|
||||
<CreateInvite projects={projects} />
|
||||
</WidgetHead>
|
||||
<Table className="mini">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Mail</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Access</TableHead>
|
||||
<TableHead>More</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.map((item) => {
|
||||
return <Item {...item} projects={projects} key={item.id} />;
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps extends IServiceInvite {
|
||||
projects: IServiceProject[];
|
||||
}
|
||||
|
||||
function Item({ id, email, role, createdAt, projects, meta }: ItemProps) {
|
||||
const router = useRouter();
|
||||
const access = pathOr<string[]>([], ['access'], meta);
|
||||
const revoke = api.organization.revokeInvite.useMutation({
|
||||
onSuccess() {
|
||||
toast.success(`Invite for ${email} revoked`);
|
||||
router.refresh();
|
||||
},
|
||||
onError() {
|
||||
toast.error(`Failed to revoke invite for ${email}`);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<TableRow key={id}>
|
||||
<TableCell className="font-medium">{email}</TableCell>
|
||||
<TableCell>{role}</TableCell>
|
||||
<TableCell>
|
||||
<TooltipComplete content={new Date(createdAt).toLocaleString()}>
|
||||
{new Date(createdAt).toLocaleDateString()}
|
||||
</TooltipComplete>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{access.map((id) => {
|
||||
const project = projects.find((p) => p.id === id);
|
||||
if (!project) {
|
||||
return (
|
||||
<Badge key={id} className="mr-1">
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge key={id} color="blue" className="mr-1">
|
||||
{project.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{access.length === 0 && (
|
||||
<Badge variant={'secondary'}>All projects</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
revoke.mutate({ memberId: id });
|
||||
}}
|
||||
>
|
||||
Revoke invite
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default Invites;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getMembers, getProjectsByOrganizationSlug } from '@openpanel/db';
|
||||
import { MembersTable } from '@/components/settings/members';
|
||||
|
||||
import Members from './members';
|
||||
import { getMembers, getProjectsByOrganizationSlug } from '@openpanel/db';
|
||||
|
||||
interface Props {
|
||||
organizationSlug: string;
|
||||
@@ -12,7 +12,7 @@ const MembersServer = async ({ organizationSlug }: Props) => {
|
||||
getProjectsByOrganizationSlug(organizationSlug),
|
||||
]);
|
||||
|
||||
return <Members members={members} projects={projects} />;
|
||||
return <MembersTable data={members} projects={projects} />;
|
||||
};
|
||||
|
||||
export default MembersServer;
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Widget, WidgetHead } from '@/components/widget';
|
||||
import { api } from '@/trpc/client';
|
||||
import { MoreHorizontalIcon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { IServiceMember, IServiceProject } from '@openpanel/db';
|
||||
|
||||
interface Props {
|
||||
members: IServiceMember[];
|
||||
projects: IServiceProject[];
|
||||
}
|
||||
|
||||
const Members = ({ members, projects }: Props) => {
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Members</span>
|
||||
</WidgetHead>
|
||||
<Table className="mini">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Access</TableHead>
|
||||
<TableHead>More</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((item) => {
|
||||
return <Item {...item} projects={projects} key={item.id} />;
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps extends IServiceMember {
|
||||
projects: IServiceProject[];
|
||||
}
|
||||
|
||||
function Item({
|
||||
id,
|
||||
user,
|
||||
role,
|
||||
organizationId,
|
||||
createdAt,
|
||||
projects,
|
||||
access: prevAccess,
|
||||
}: ItemProps) {
|
||||
const router = useRouter();
|
||||
const mutation = api.organization.updateMemberAccess.useMutation();
|
||||
const revoke = api.organization.removeMember.useMutation({
|
||||
onSuccess() {
|
||||
toast.success(
|
||||
`${user?.firstName} has been removed from the organization`
|
||||
);
|
||||
router.refresh();
|
||||
},
|
||||
onError() {
|
||||
toast.error(`Failed to remove ${user?.firstName} from the organization`);
|
||||
},
|
||||
});
|
||||
const [access, setAccess] = useState<string[]>(
|
||||
prevAccess.map((item) => item.projectId)
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={id}>
|
||||
<TableCell className="font-medium">
|
||||
<div>{[user?.firstName, user?.lastName].filter(Boolean).join(' ')}</div>
|
||||
<div className="text-sm text-muted-foreground">{user?.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>{role}</TableCell>
|
||||
<TableCell>
|
||||
<TooltipComplete content={new Date(createdAt).toLocaleString()}>
|
||||
{new Date(createdAt).toLocaleDateString()}
|
||||
</TooltipComplete>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ComboboxAdvanced
|
||||
placeholder="Restrict access to projects"
|
||||
value={access}
|
||||
onChange={(newAccess) => {
|
||||
setAccess(newAccess);
|
||||
mutation.mutate({
|
||||
userId: user.id,
|
||||
organizationSlug: organizationId,
|
||||
access: newAccess as string[],
|
||||
});
|
||||
}}
|
||||
items={projects.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
revoke.mutate({ organizationId: organizationId, userId: id });
|
||||
}}
|
||||
>
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default Members;
|
||||
@@ -1,8 +1,11 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { ShieldAlertIcon } from 'lucide-react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { parseAsStringEnum } from 'nuqs';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
|
||||
@@ -14,11 +17,16 @@ interface PageProps {
|
||||
params: {
|
||||
organizationSlug: string;
|
||||
};
|
||||
searchParams: Record<string, string>;
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationSlug },
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const tab = parseAsStringEnum(['org', 'members', 'invites'])
|
||||
.withDefault('org')
|
||||
.parseServerSide(searchParams.tab);
|
||||
const session = auth();
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
@@ -49,30 +57,36 @@ export default async function Page({
|
||||
|
||||
const hasAccess = member?.role === 'org:admin';
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<FullPageEmptyState icon={ShieldAlertIcon} title="No access">
|
||||
You do not have access to this page. You need to be an admin of this
|
||||
organization to access this page.
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
title={organization.name}
|
||||
organizationSlug={organizationSlug}
|
||||
/>
|
||||
{hasAccess ? (
|
||||
<div className="grid gap-8 p-4 lg:grid-cols-2">
|
||||
<EditOrganization organization={organization} />
|
||||
<div className="col-span-2">
|
||||
<MembersServer organizationSlug={organizationSlug} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<InvitesServer organizationSlug={organizationSlug} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FullPageEmptyState icon={ShieldAlertIcon} title="No access">
|
||||
You do not have access to this page. You need to be an admin of this
|
||||
organization to access this page.
|
||||
</FullPageEmptyState>
|
||||
</>
|
||||
<Padding>
|
||||
<PageTabs className="mb-4">
|
||||
<PageTabsLink href={`?tab=org`} isActive={tab === 'org'}>
|
||||
Organization
|
||||
</PageTabsLink>
|
||||
<PageTabsLink href={`?tab=members`} isActive={tab === 'members'}>
|
||||
Members
|
||||
</PageTabsLink>
|
||||
<PageTabsLink href={`?tab=invites`} isActive={tab === 'invites'}>
|
||||
Invites
|
||||
</PageTabsLink>
|
||||
</PageTabs>
|
||||
|
||||
{tab === 'org' && <EditOrganization organization={organization} />}
|
||||
{tab === 'members' && (
|
||||
<MembersServer organizationSlug={organizationSlug} />
|
||||
)}
|
||||
</>
|
||||
{tab === 'invites' && (
|
||||
<InvitesServer organizationSlug={organizationSlug} />
|
||||
)}
|
||||
</Padding>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
|
||||
import { getUserById } from '@openpanel/db';
|
||||
|
||||
import EditProfile from './edit-profile';
|
||||
import { Logout } from './logout';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationSlug: string;
|
||||
};
|
||||
}
|
||||
export default async function Page({
|
||||
params: { organizationSlug },
|
||||
}: PageProps) {
|
||||
export default async function Page() {
|
||||
const { userId } = auth();
|
||||
const profile = await getUserById(userId!);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
title={profile.lastName}
|
||||
organizationSlug={organizationSlug}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<EditProfile profile={profile} />
|
||||
<Logout />
|
||||
</div>
|
||||
</>
|
||||
<Padding>
|
||||
<h1 className="mb-4 text-2xl font-bold">Profile</h1>
|
||||
<EditProfile profile={profile} />
|
||||
</Padding>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,93 +24,89 @@ interface ListProjectsProps {
|
||||
export default function ListProjects({ projects, clients }: ListProjectsProps) {
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div />
|
||||
<Button icon={PlusIcon} onClick={() => pushModal('AddProject')}>
|
||||
<span className="max-sm:hidden">Create project</span>
|
||||
<span className="sm:hidden">Project</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<div className="card p-4">
|
||||
<Alert className="mb-4">
|
||||
<InfoIcon size={16} />
|
||||
<AlertTitle>What is a project</AlertTitle>
|
||||
<AlertDescription>
|
||||
A project can be a website, mobile app or any other application
|
||||
that you want to track event for. Each project can have one or
|
||||
more clients. The client is used to send events to the project.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Accordion type="single" collapsible className="-mx-4">
|
||||
{projects.map((project) => {
|
||||
const pClients = clients.filter(
|
||||
(client) => client.projectId === project.id
|
||||
);
|
||||
return (
|
||||
<AccordionItem
|
||||
value={project.id}
|
||||
key={project.id}
|
||||
className="last:border-b-0"
|
||||
>
|
||||
<AccordionTrigger className="px-4">
|
||||
<div className="flex-1 text-left">
|
||||
{project.name}
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{pClients.length > 0
|
||||
? `(${pClients.length} clients)`
|
||||
: 'No clients created yet'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-4"></div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4">
|
||||
<ProjectActions {...project} />
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
{pClients.map((item) => {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded border border-border p-4"
|
||||
key={item.id}
|
||||
<div className="row mb-4 justify-between">
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<Button icon={PlusIcon} onClick={() => pushModal('AddProject')}>
|
||||
<span className="max-sm:hidden">Create project</span>
|
||||
<span className="sm:hidden">Project</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<Alert className="mb-4">
|
||||
<InfoIcon size={16} />
|
||||
<AlertTitle>What is a project</AlertTitle>
|
||||
<AlertDescription>
|
||||
A project can be a website, mobile app or any other application that
|
||||
you want to track event for. Each project can have one or more
|
||||
clients. The client is used to send events to the project.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Accordion type="single" collapsible className="-mx-4">
|
||||
{projects.map((project) => {
|
||||
const pClients = clients.filter(
|
||||
(client) => client.projectId === project.id
|
||||
);
|
||||
return (
|
||||
<AccordionItem
|
||||
value={project.id}
|
||||
key={project.id}
|
||||
className="last:border-b-0"
|
||||
>
|
||||
<AccordionTrigger className="px-4">
|
||||
<div className="flex-1 text-left">
|
||||
{project.name}
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{pClients.length > 0
|
||||
? `(${pClients.length} clients)`
|
||||
: 'No clients created yet'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-4"></div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4">
|
||||
<ProjectActions {...project} />
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
{pClients.map((item) => {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded border border-border p-4"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="mb-1 font-medium">{item.name}</div>
|
||||
<Tooltiper
|
||||
className="text-muted-foreground"
|
||||
content={item.id}
|
||||
>
|
||||
<div className="mb-1 font-medium">{item.name}</div>
|
||||
<Tooltiper
|
||||
className="text-muted-foreground"
|
||||
content={item.id}
|
||||
>
|
||||
Client ID: ...{item.id.slice(-12)}
|
||||
</Tooltiper>
|
||||
<div className="text-muted-foreground">
|
||||
{item.cors &&
|
||||
item.cors !== '*' &&
|
||||
`Website: ${item.cors}`}
|
||||
</div>
|
||||
<div className="absolute right-4 top-4">
|
||||
<ClientActions {...item} />
|
||||
</div>
|
||||
Client ID: ...{item.id.slice(-12)}
|
||||
</Tooltiper>
|
||||
<div className="text-muted-foreground">
|
||||
{item.cors &&
|
||||
item.cors !== '*' &&
|
||||
`Website: ${item.cors}`}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
pushModal('AddClient', {
|
||||
projectId: project.id,
|
||||
});
|
||||
}}
|
||||
className="flex items-center justify-center gap-4 rounded bg-muted p-4"
|
||||
>
|
||||
<PlusSquareIcon />
|
||||
<div className="font-medium">New client</div>
|
||||
</button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
<div className="absolute right-4 top-4">
|
||||
<ClientActions {...item} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
pushModal('AddClient', {
|
||||
projectId: project.id,
|
||||
});
|
||||
}}
|
||||
className="flex items-center justify-center gap-4 rounded bg-muted p-4"
|
||||
>
|
||||
<PlusSquareIcon />
|
||||
<div className="font-medium">New client</div>
|
||||
</button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
|
||||
import {
|
||||
getClientsByOrganizationSlug,
|
||||
@@ -22,9 +23,8 @@ export default async function Page({
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout title="Projects" organizationSlug={organizationSlug} />
|
||||
<Padding>
|
||||
<ListProjects projects={projects} clients={clients} />
|
||||
</>
|
||||
</Padding>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { columns } from '@/components/references/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { pushModal } from '@/modals';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
@@ -15,19 +15,15 @@ interface ListProjectsProps {
|
||||
|
||||
export default function ListReferences({ data }: ListProjectsProps) {
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div />
|
||||
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
|
||||
<span className="max-sm:hidden">Create reference</span>
|
||||
<span className="sm:hidden">Reference</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<DataTable data={data} columns={columns} />
|
||||
<Padding>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">References</h1>
|
||||
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
|
||||
<span className="max-sm:hidden">Create reference</span>
|
||||
<span className="sm:hidden">Reference</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
<DataTable data={data} columns={columns} />
|
||||
</Padding>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||
|
||||
import { getReferences } from '@openpanel/db';
|
||||
|
||||
import ListReferences from './list-references';
|
||||
@@ -11,9 +9,7 @@ interface PageProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationSlug, projectId },
|
||||
}: PageProps) {
|
||||
export default async function Page({ params: { projectId } }: PageProps) {
|
||||
const references = await getReferences({
|
||||
where: {
|
||||
projectId,
|
||||
@@ -24,7 +20,6 @@ export default async function Page({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout title="References" organizationSlug={organizationSlug} />
|
||||
<ListReferences data={references} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -20,9 +20,9 @@ const Page = ({ children }: Props) => {
|
||||
</FullWidthNavbar>
|
||||
<div className="mx-auto w-full md:max-w-[95vw] lg:max-w-[80vw]">
|
||||
<div className="grid md:grid-cols-[25vw_1fr] lg:grid-cols-[20vw_1fr]">
|
||||
<div className="max-w-screen bg-def-200 flex flex-col gap-4 overflow-hidden p-4 pr-0 md:bg-transparent md:py-14">
|
||||
<div className="max-w-screen flex flex-col gap-4 overflow-hidden bg-def-200 p-4 pr-0 md:bg-transparent md:py-14">
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase text-[#7b94ac]">
|
||||
<div className="text-sm font-bold uppercase text-[#7b94ac]">
|
||||
Welcome to Openpanel
|
||||
</div>
|
||||
<div className="text-xl font-medium leading-loose">
|
||||
|
||||
@@ -15,7 +15,7 @@ const ConnectApp = ({ client }: Props) => {
|
||||
<SmartphoneIcon />
|
||||
App
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Pick a framework below to get started.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
@@ -30,7 +30,7 @@ const ConnectApp = ({ client }: Props) => {
|
||||
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
|
||||
key={framework.name}
|
||||
>
|
||||
<div className="bg-def-200 h-10 w-10 rounded-md p-2">
|
||||
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
|
||||
<img
|
||||
className="h-full w-full object-contain"
|
||||
src={framework.logo}
|
||||
@@ -43,7 +43,7 @@ const ConnectApp = ({ client }: Props) => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Missing a framework?{' '}
|
||||
<a
|
||||
href="mailto:hello@openpanel.dev"
|
||||
|
||||
@@ -15,7 +15,7 @@ const ConnectBackend = ({ client }: Props) => {
|
||||
<ServerIcon />
|
||||
Backend
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Pick a framework below to get started.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
@@ -30,7 +30,7 @@ const ConnectBackend = ({ client }: Props) => {
|
||||
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
|
||||
key={framework.name}
|
||||
>
|
||||
<div className="bg-def-200 h-10 w-10 rounded-md p-2">
|
||||
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
|
||||
<img
|
||||
className="h-full w-full object-contain"
|
||||
src={framework.logo}
|
||||
@@ -43,7 +43,7 @@ const ConnectBackend = ({ client }: Props) => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Missing a framework?{' '}
|
||||
<a
|
||||
href="mailto:hello@openpanel.dev"
|
||||
|
||||
@@ -15,7 +15,7 @@ const ConnectWeb = ({ client }: Props) => {
|
||||
<MonitorIcon />
|
||||
Website
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Pick a framework below to get started.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
@@ -30,7 +30,7 @@ const ConnectWeb = ({ client }: Props) => {
|
||||
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
|
||||
key={framework.name}
|
||||
>
|
||||
<div className="bg-def-200 h-10 w-10 rounded-md p-2">
|
||||
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
|
||||
<img
|
||||
className="h-full w-full object-contain"
|
||||
src={framework.logo}
|
||||
@@ -43,7 +43,7 @@ const ConnectWeb = ({ client }: Props) => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Missing a framework?{' '}
|
||||
<a
|
||||
href="mailto:hello@openpanel.dev"
|
||||
|
||||
@@ -61,7 +61,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
<div className="flex items-center gap-2 text-2xl capitalize">
|
||||
{client?.name}
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-semibold text-muted-foreground">
|
||||
<div className="mt-2 text-sm font-semibold text-muted-foreground">
|
||||
Connection status: {renderBadge()}
|
||||
</div>
|
||||
|
||||
@@ -81,13 +81,13 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
{isConnected ? (
|
||||
<div className="flex flex-col-reverse">
|
||||
{events.length > 5 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 ">
|
||||
<CheckIcon size={14} />{' '}
|
||||
<span>{events.length - 5} more events</span>
|
||||
</div>
|
||||
)}
|
||||
{events.slice(-5).map((event, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-sm">
|
||||
<div key={index} className="flex items-center gap-2 ">
|
||||
<CheckIcon size={14} />{' '}
|
||||
<span className="font-medium">{event.name}</span>{' '}
|
||||
<span className="ml-auto text-emerald-800">
|
||||
@@ -97,7 +97,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
<div className="">
|
||||
Verify that your events works before submitting any changes to App
|
||||
Store/Google Play
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
You can{' '}
|
||||
<button
|
||||
className="underline"
|
||||
|
||||
@@ -73,7 +73,7 @@ const Verify = ({ project, events }: Props) => {
|
||||
{!verified && (
|
||||
<Link
|
||||
href={`/${project.organizationSlug}/${project.id}`}
|
||||
className="text-sm text-muted-foreground underline"
|
||||
className=" text-muted-foreground underline"
|
||||
>
|
||||
Skip for now
|
||||
</Link>
|
||||
|
||||
@@ -34,7 +34,7 @@ const SkipOnboarding = () => {
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
Skip onboarding
|
||||
<ChevronLastIcon size={16} />
|
||||
|
||||
@@ -59,9 +59,9 @@ const Steps = ({ className }: Props) => {
|
||||
const currentIndex = steps.findIndex((i) => i.status === 'current');
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="bg-def-200 absolute bottom-4 left-4 top-4 w-px"></div>
|
||||
<div className="absolute bottom-4 left-4 top-4 w-px bg-def-200"></div>
|
||||
<div
|
||||
className="bg-highlight absolute left-4 top-4 w-px"
|
||||
className="absolute left-4 top-4 w-px bg-highlight"
|
||||
style={{
|
||||
height: `calc(${((currentIndex + 1) / steps.length) * 100}% - 3.5rem)`,
|
||||
}}
|
||||
@@ -86,17 +86,17 @@ const Steps = ({ className }: Props) => {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm text-white'
|
||||
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-highlight absolute inset-0 z-0 rounded-full',
|
||||
'absolute inset-0 z-0 rounded-full bg-highlight',
|
||||
step.status === 'pending' && 'bg-def-400'
|
||||
)}
|
||||
></div>
|
||||
{step.status === 'current' && (
|
||||
<div className="bg-highlight absolute inset-1 z-0 animate-ping-slow rounded-full"></div>
|
||||
<div className="absolute inset-1 z-0 animate-ping-slow rounded-full bg-highlight"></div>
|
||||
)}
|
||||
<div className="relative">
|
||||
{step.status === 'completed' && <CheckCheckIcon size={14} />}
|
||||
@@ -109,7 +109,7 @@ const Steps = ({ className }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">{step.name}</div>
|
||||
<div className=" font-medium">{step.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export default async function Page({
|
||||
href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share"
|
||||
className="flex flex-col items-end text-lg font-medium"
|
||||
>
|
||||
<span className="text-xs">POWERED BY</span>
|
||||
<span className="text-sm">POWERED BY</span>
|
||||
<span>openpanel.dev</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,9 @@ import Providers from './providers';
|
||||
import '@/styles/globals.css';
|
||||
import '/node_modules/flag-icons/css/flag-icons.min.css';
|
||||
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Overview - Openpanel.dev',
|
||||
};
|
||||
@@ -25,7 +28,11 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn('grainy bg-def-100 min-h-screen font-sans antialiased')}
|
||||
className={cn(
|
||||
'grainy min-h-screen bg-def-100 font-sans text-base antialiased',
|
||||
GeistSans.variable,
|
||||
GeistMono.variable
|
||||
)}
|
||||
>
|
||||
<NextTopLoader
|
||||
showSpinner={false}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
|
||||
<div className="w-full">
|
||||
<CopyInput label="Secret" value={secret} />
|
||||
{cors && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
You will only need the secret if you want to send server events.
|
||||
</p>
|
||||
)}
|
||||
@@ -25,7 +25,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
|
||||
{cors && (
|
||||
<div className="text-left">
|
||||
<Label>CORS settings</Label>
|
||||
<div className="flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 font-mono text-sm">
|
||||
<div className="font-mono flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 ">
|
||||
{cors}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export const columns: ColumnDef<IServiceClientWithProject>[] = [
|
||||
return (
|
||||
<div>
|
||||
<div>{row.original.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className=" text-muted-foreground">
|
||||
{row.original.project?.name ?? 'No project'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function ColorSquare({ children, className }: ColorSquareProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-xs font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -7,20 +8,27 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from './ui/table';
|
||||
import { Grid, GridBody, GridCell, GridHeader, GridRow } from './grid-table';
|
||||
|
||||
interface DataTableProps<TData> {
|
||||
columns: ColumnDef<TData, any>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function TableButtons({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('mb-2 flex flex-wrap items-center gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -29,47 +37,45 @@ export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<Grid columns={columns.length}>
|
||||
<GridHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
<GridRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<GridCell key={header.id} isHeader>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</GridCell>
|
||||
))}
|
||||
</GridRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</GridHeader>
|
||||
<GridBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
<GridRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<GridCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
</GridCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</GridRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<GridRow>
|
||||
<GridCell colSpan={columns.length}>
|
||||
<div className="h-24 text-center">No results.</div>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</GridBody>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
145
apps/dashboard/src/components/events/table/columns.tsx
Normal file
145
apps/dashboard/src/components/events/table/columns.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { EventIcon } from '@/app/(app)/[organizationSlug]/[projectId]/events/event-list/event-icon';
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { formatDateTime, formatTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { isToday } from 'date-fns';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
export function useColumns() {
|
||||
const number = useNumber();
|
||||
const columns: ColumnDef<IServiceEvent>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell({ row }) {
|
||||
const { name, path, duration } = row.original;
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
return <span className="max-w-md truncate">{path}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">Screen: </span>
|
||||
<span className="max-w-md truncate">{path}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return name.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<EventIcon
|
||||
size="sm"
|
||||
name={row.original.name}
|
||||
meta={row.original.meta}
|
||||
/>
|
||||
<span className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
pushModal('EventDetails', {
|
||||
id: row.original.id,
|
||||
});
|
||||
}}
|
||||
className="font-medium"
|
||||
>
|
||||
{renderName()}
|
||||
</button>
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: 'Country',
|
||||
cell({ row }) {
|
||||
const { country, city } = row.original;
|
||||
return (
|
||||
<div className="inline-flex min-w-full flex-none items-center gap-2">
|
||||
<SerieIcon name={country} />
|
||||
<span>{city}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'os',
|
||||
header: 'OS',
|
||||
cell({ row }) {
|
||||
const { os } = row.original;
|
||||
return (
|
||||
<div className="flex min-w-full items-center gap-2">
|
||||
<SerieIcon name={os} />
|
||||
<span>{os}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'browser',
|
||||
header: 'Browser',
|
||||
cell({ row }) {
|
||||
const { browser } = row.original;
|
||||
return (
|
||||
<div className="inline-flex min-w-full flex-none items-center gap-2">
|
||||
<SerieIcon name={browser} />
|
||||
<span>{browser}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'profileId',
|
||||
header: 'Profile',
|
||||
cell({ row }) {
|
||||
const { profile } = row.original;
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${profile?.id}`}
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap font-medium hover:underline"
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created at',
|
||||
cell({ row }) {
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns;
|
||||
}
|
||||
71
apps/dashboard/src/components/events/table/index.tsx
Normal file
71
apps/dashboard/src/components/events/table/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
|
||||
type Props =
|
||||
| {
|
||||
query: UseQueryResult<IServiceEvent[]>;
|
||||
}
|
||||
| {
|
||||
query: UseQueryResult<IServiceEvent[]>;
|
||||
cursor: number;
|
||||
setCursor: Dispatch<SetStateAction<number>>;
|
||||
};
|
||||
|
||||
export const EventsTable = ({ query, ...props }: Props) => {
|
||||
const columns = useColumns();
|
||||
const { data, isFetching, isLoading } = query;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
return (
|
||||
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
|
||||
<p>Could not find any events</p>
|
||||
{'cursor' in props && props.cursor !== 0 && (
|
||||
<Button
|
||||
className="mt-8"
|
||||
variant="outline"
|
||||
onClick={() => props.setCursor((p) => p - 1)}
|
||||
>
|
||||
Go to previous page
|
||||
</Button>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data ?? []} columns={columns} />
|
||||
{'cursor' in props && (
|
||||
<Pagination
|
||||
className="mt-2"
|
||||
setCursor={props.setCursor}
|
||||
cursor={props.cursor}
|
||||
count={Infinity}
|
||||
take={50}
|
||||
loading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
|
||||
<div>
|
||||
<label
|
||||
className={cn(
|
||||
'hover:bg-def-200 flex items-center gap-4 px-4 py-6 transition-colors',
|
||||
'flex items-center gap-4 px-4 py-6 transition-colors hover:bg-def-200',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
htmlFor={id}
|
||||
@@ -32,8 +32,8 @@ export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
|
||||
{Icon && <div className="w-6 shrink-0">{<Icon />}</div>}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
<div className=" text-muted-foreground">{description}</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
|
||||
@@ -17,7 +17,7 @@ const CopyInput = ({ label, value, className }: Props) => {
|
||||
onClick={() => clipboard(value)}
|
||||
>
|
||||
{!!label && <Label>{label}</Label>}
|
||||
<div className="flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 font-mono text-sm">
|
||||
<div className="font-mono flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 ">
|
||||
{value}
|
||||
<CopyIcon size={16} />
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const WithLabel = ({
|
||||
</Label>
|
||||
{error && (
|
||||
<Tooltiper asChild content={error}>
|
||||
<div className="flex items-center gap-1 text-sm leading-none text-destructive">
|
||||
<div className="flex items-center gap-1 leading-none text-destructive">
|
||||
Issues
|
||||
<BanIcon size={14} />
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@ const TagInput = ({
|
||||
data-tag={tag}
|
||||
key={tag}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 text-sm',
|
||||
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 ',
|
||||
isMarkedForDeletion &&
|
||||
i === value.length - 1 &&
|
||||
'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1',
|
||||
@@ -136,7 +136,7 @@ const TagInput = ({
|
||||
<input
|
||||
ref={inputRef}
|
||||
placeholder={`${placeholder} ↵`}
|
||||
className="min-w-20 flex-1 py-1 text-sm focus-visible:outline-none"
|
||||
className="min-w-20 flex-1 py-1 focus-visible:outline-none"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChevronLeftIcon, FullscreenIcon } from 'lucide-react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useDebounce } from 'usehooks-ts';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltiper } from './ui/tooltip';
|
||||
|
||||
type Props = {
|
||||
@@ -37,18 +38,21 @@ export const Fullscreen = (props: Props) => {
|
||||
};
|
||||
|
||||
export const FullscreenOpen = () => {
|
||||
const [, setIsFullscreen] = useFullscreen();
|
||||
const [fullscreen, setIsFullscreen] = useFullscreen();
|
||||
if (fullscreen) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltiper content="Toggle fullscreen" asChild>
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsFullscreen((p) => !p);
|
||||
}}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
Realtime
|
||||
</button>
|
||||
<FullscreenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
);
|
||||
};
|
||||
|
||||
88
apps/dashboard/src/components/grid-table.tsx
Normal file
88
apps/dashboard/src/components/grid-table.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export const Grid: React.FC<
|
||||
React.HTMLAttributes<HTMLDivElement> & { columns: number }
|
||||
> = ({ className, columns, children, ...props }) => (
|
||||
<div className={cn('card', className)}>
|
||||
<div className="relative w-full overflow-auto rounded-md">
|
||||
<div
|
||||
className={cn('grid w-full')}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, auto)`,
|
||||
width: 'max-content',
|
||||
minWidth: '100%',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GridHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<div className={cn('contents', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GridBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn('contents [&>*:last-child]:border-0', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GridCell: React.FC<
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
as?: React.ElementType;
|
||||
colSpan?: number;
|
||||
isHeader?: boolean;
|
||||
}
|
||||
> = ({
|
||||
className,
|
||||
children,
|
||||
as: Component = 'div',
|
||||
colSpan,
|
||||
isHeader,
|
||||
...props
|
||||
}) => (
|
||||
<Component
|
||||
className={cn(
|
||||
'flex h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
|
||||
isHeader && 'h-10 bg-def-100 font-semibold text-muted-foreground',
|
||||
colSpan && `col-span-${colSpan}`,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
export const GridRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
'contents transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
23
apps/dashboard/src/components/links.tsx
Normal file
23
apps/dashboard/src/components/links.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function ProjectLink({
|
||||
children,
|
||||
...props
|
||||
}: LinkProps & {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const { organizationSlug, projectId } = useAppParams();
|
||||
if (typeof props.href === 'string') {
|
||||
return (
|
||||
<Link {...props} href={`/${organizationSlug}/${projectId}/${props.href}`}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>ProjectLink</p>;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function OverviewFiltersButtons({
|
||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||
if (filters.length === 0 && events.length === 0) return null;
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2 px-4 pb-4', className)}>
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
{events.map((event) => (
|
||||
<Button
|
||||
key={event}
|
||||
@@ -32,7 +32,7 @@ export function OverviewFiltersButtons({
|
||||
icon={X}
|
||||
onClick={() => setEvents((p) => p.filter((e) => e !== event))}
|
||||
>
|
||||
<strong>{event}</strong>
|
||||
<strong className="font-semibold">{event}</strong>
|
||||
</Button>
|
||||
))}
|
||||
{filters.map((filter) => {
|
||||
@@ -49,7 +49,7 @@ export function OverviewFiltersButtons({
|
||||
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
|
||||
>
|
||||
<span className="mr-1">{getPropertyLabel(filter.name)} is</span>
|
||||
<strong>{filter.value[0]}</strong>
|
||||
<strong className="font-semibold">{filter.value[0]}</strong>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useDebounceVal } from '@/hooks/useDebounceVal';
|
||||
import { useDebounceState } from '@/hooks/useDebounceState';
|
||||
import useWS from '@/hooks/useWS';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -28,7 +28,7 @@ const FIFTEEN_SECONDS = 1000 * 30;
|
||||
|
||||
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
|
||||
const client = useQueryClient();
|
||||
const counter = useDebounceVal(data, 1000, {
|
||||
const counter = useDebounceState(data, 1000, {
|
||||
maxWait: 5000,
|
||||
});
|
||||
const lastRefresh = useRef(Date.now());
|
||||
|
||||
@@ -13,7 +13,7 @@ export function OverviewChartToggle({ chartType, setChartType }: Props) {
|
||||
return (
|
||||
<Button
|
||||
size={'icon'}
|
||||
variant={'outline'}
|
||||
variant={'ghost'}
|
||||
onClick={() => {
|
||||
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
|
||||
}}
|
||||
|
||||
@@ -3,22 +3,25 @@ import { ScanEyeIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type Props = {
|
||||
chart: IChartProps;
|
||||
};
|
||||
|
||||
const OverviewDetailsButton = ({ chart }: Props) => {
|
||||
return (
|
||||
<button
|
||||
className="-mb-2 mt-5 flex w-full items-center justify-center gap-2 text-sm font-semibold"
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
pushModal('OverviewChartDetails', {
|
||||
chart: chart,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ScanEyeIcon size={18} /> Details
|
||||
</button>
|
||||
<ScanEyeIcon size={18} />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ interface WrapperProps {
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative mb-1 text-xs font-medium text-muted-foreground">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} unique vistors last 30 minutes
|
||||
</div>
|
||||
<div className="relative flex h-full w-full flex-1 items-end gap-1">
|
||||
|
||||
@@ -192,8 +192,8 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 md:m-0">
|
||||
<div className="card mb-2 grid grid-cols-4">
|
||||
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
||||
<div className="card mb-2 grid grid-cols-4 overflow-hidden rounded-md">
|
||||
{reports.map((report, index) => (
|
||||
<button
|
||||
key={index}
|
||||
|
||||
@@ -32,7 +32,7 @@ const OverviewTopBots = ({ projectId }: Props) => {
|
||||
<>
|
||||
<div className="-m-4">
|
||||
<WidgetTable
|
||||
className="max-w-full [&_td:first-child]:w-full [&_th]:text-xs [&_tr]:text-xs"
|
||||
className="max-w-full [&_td:first-child]:w-full [&_th]:text-sm [&_tr]:text-sm"
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
columns={[
|
||||
|
||||
@@ -11,7 +11,7 @@ import { LazyChart } from '../report/chart/LazyChart';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -271,10 +271,7 @@ export default function OverviewTopDevices({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
@@ -312,8 +309,11 @@ export default function OverviewTopDevices({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { IChartType } from '@openpanel/validation';
|
||||
import { Widget, WidgetBody } from '../../widget';
|
||||
import { OverviewChartToggle } from '../overview-chart-toggle';
|
||||
import OverviewDetailsButton from '../overview-details-button';
|
||||
import { WidgetButtons, WidgetHead } from '../overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from '../overview-widget';
|
||||
import { useOverviewOptions } from '../useOverviewOptions';
|
||||
import { useOverviewWidget } from '../useOverviewWidget';
|
||||
|
||||
@@ -143,10 +143,7 @@ export default function OverviewTopEvents({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets
|
||||
.filter((item) => item.hide !== true)
|
||||
@@ -163,8 +160,11 @@ export default function OverviewTopEvents({
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<LazyChart hideID {...widget.chart} previous={false} />
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { LazyChart } from '../report/chart/LazyChart';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -143,10 +143,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
@@ -180,8 +177,11 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import OverviewTopBots from './overview-top-bots';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -154,10 +154,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
@@ -196,8 +193,13 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{widget.chart?.name && <OverviewDetailsButton chart={widget.chart} />}
|
||||
</WidgetBody>
|
||||
{widget.chart?.name && (
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
)}
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ScanEyeIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
@@ -12,7 +10,7 @@ import { LazyChart } from '../report/chart/LazyChart';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -282,10 +280,7 @@ export default function OverviewTopSources({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
@@ -335,8 +330,11 @@ export default function OverviewTopSources({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user