Feature/move list to client (#50)
This commit is contained in:
committed by
GitHub
parent
c2abdaadf2
commit
668434d246
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user