move back 😏
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import {
|
||||
getDefaultIntervalByDates,
|
||||
getDefaultIntervalByRange,
|
||||
} from '@openpanel/constants';
|
||||
import type { getReportsByDashboardId } from '@openpanel/db';
|
||||
|
||||
import { OverviewReportRange } from '../../overview-sticky-header';
|
||||
|
||||
interface ListReportsProps {
|
||||
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
|
||||
}
|
||||
|
||||
export function ListReports({ reports }: ListReportsProps) {
|
||||
const router = useRouter();
|
||||
const params = useAppParams<{ dashboardId: string }>();
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader className="p-4 items-center justify-between flex">
|
||||
<OverviewReportRange />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/${params.organizationId}/${
|
||||
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="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
|
||||
{reports.map((report) => {
|
||||
const chartRange = report.range; // timeRanges[report.range];
|
||||
return (
|
||||
<div className="card" key={report.id}>
|
||||
<Link
|
||||
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
|
||||
className="flex border-b border-border p-4 leading-none [&_svg]:hover:opacity-100 items-center justify-between"
|
||||
shallow
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 text-sm flex gap-2">
|
||||
<span
|
||||
className={
|
||||
range !== null || (startDate && endDate)
|
||||
? 'line-through'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{chartRange}
|
||||
</span>
|
||||
{startDate && endDate ? (
|
||||
<span>Custom dates</span>
|
||||
) : (
|
||||
range !== null && <span>{range}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="h-8 w-8 hover:border rounded justify-center items-center flex">
|
||||
<MoreHorizontal size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(event) => {
|
||||
// event.stopPropagation();
|
||||
// deletion.mutate({
|
||||
// reportId: report.id,
|
||||
// });
|
||||
}}
|
||||
>
|
||||
<Trash size={16} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChevronRight
|
||||
className="opacity-10 transition-opacity"
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4',
|
||||
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
|
||||
)}
|
||||
>
|
||||
<LazyChart
|
||||
{...report}
|
||||
range={range ?? report.range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={
|
||||
getDefaultIntervalByDates(startDate, endDate) ||
|
||||
(range ? getDefaultIntervalByRange(range) : report.interval)
|
||||
}
|
||||
editMode={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
|
||||
|
||||
import { ListReports } from './list-reports';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
dashboardId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId, dashboardId },
|
||||
}: PageProps) {
|
||||
const [dashboard, reports] = await Promise.all([
|
||||
getDashboardById(dashboardId, projectId),
|
||||
getReportsByDashboardId(dashboardId),
|
||||
getExists(organizationId),
|
||||
]);
|
||||
|
||||
if (!dashboard) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title={dashboard.name} organizationSlug={organizationId}>
|
||||
<ListReports reports={reports} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
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="p-4 flex justify-between items-center">
|
||||
<div />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
pushModal('AddDashboard');
|
||||
}}
|
||||
>
|
||||
<span className="max-sm:hidden">Create dashboard</span>
|
||||
<span className="sm:hidden">Dashboard</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleErrorToastOptions } from '@/app/_trpc/client';
|
||||
import { Card, CardActions, CardActionsItem } from '@/components/card';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { IServiceDashboards } from '@openpanel/db';
|
||||
|
||||
interface ListDashboardsProps {
|
||||
dashboards: IServiceDashboards;
|
||||
}
|
||||
|
||||
export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
||||
const router = useRouter();
|
||||
const params = useAppParams();
|
||||
const { organizationId, projectId } = params;
|
||||
const deletion = api.dashboard.delete.useMutation({
|
||||
onError: (error, variables) => {
|
||||
return handleErrorToastOptions({
|
||||
action: {
|
||||
label: 'Force delete',
|
||||
onClick: () => {
|
||||
deletion.mutate({
|
||||
forceDelete: true,
|
||||
id: variables.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
})(error);
|
||||
},
|
||||
onSuccess() {
|
||||
router.refresh();
|
||||
toast('Success', {
|
||||
description: 'Dashboard deleted.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (dashboards.length === 0) {
|
||||
return (
|
||||
<FullPageEmptyState title="No dashboards" icon={LayoutPanelTopIcon}>
|
||||
<p>You have not created any dashboards for this project yet</p>
|
||||
<Button
|
||||
onClick={() => pushModal('AddDashboard')}
|
||||
className="mt-14"
|
||||
icon={PlusIcon}
|
||||
>
|
||||
Create dashboard
|
||||
</Button>
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid sm:grid-cols-2 gap-4 p-4">
|
||||
{dashboards.map((item) => (
|
||||
<Card key={item.id} hover>
|
||||
<div>
|
||||
<Link
|
||||
href={`/${organizationId}/${projectId}/dashboards/${item.id}`}
|
||||
className="block p-4 flex flex-col"
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<CardActions>
|
||||
<CardActionsItem className="w-full" asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
pushModal('EditDashboard', item);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Edit
|
||||
</button>
|
||||
</CardActionsItem>
|
||||
<CardActionsItem className="text-destructive w-full" asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
deletion.mutate({
|
||||
id: item.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash size={16} />
|
||||
Delete
|
||||
</button>
|
||||
</CardActionsItem>
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
|
||||
import { getDashboardsByProjectId } from '@openpanel/db';
|
||||
|
||||
import { HeaderDashboards } from './header-dashboards';
|
||||
import { ListDashboards } from './list-dashboards';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { projectId, organizationId },
|
||||
}: PageProps) {
|
||||
const [dashboards] = await Promise.all([
|
||||
getDashboardsByProjectId(projectId),
|
||||
await getExists(organizationId, projectId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageLayout title="Dashboards" organizationSlug={organizationId}>
|
||||
{dashboards.length > 0 && <HeaderDashboards />}
|
||||
<ListDashboards dashboards={dashboards} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ChartSwitchShortcut } from '@/components/report/chart';
|
||||
|
||||
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 (
|
||||
<div className="card p-4 mb-8">
|
||||
<ChartSwitchShortcut
|
||||
projectId={projectId}
|
||||
range="1m"
|
||||
chartType="histogram"
|
||||
events={
|
||||
events && events.length > 0
|
||||
? events.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
displayName: name,
|
||||
segment: 'event',
|
||||
filters: filters ?? [],
|
||||
}))
|
||||
: fallback
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Widget, WidgetHead } from '@/components/widget';
|
||||
import { isSameDay } from 'date-fns';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
import { EventListItem } from '../event-list-item';
|
||||
|
||||
function showDateHeader(a: Date, b?: Date) {
|
||||
if (!b) return true;
|
||||
return !isSameDay(a, b);
|
||||
}
|
||||
|
||||
interface EventListProps {
|
||||
data: IServiceCreateEventPayload[];
|
||||
}
|
||||
export function EventConversionsList({ data }: EventListProps) {
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetHead>
|
||||
<div className="title">Conversions</div>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-2 overflow-y-auto max-h-80 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="bg-slate-100 border border-slate-300 rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2">
|
||||
{item.createdAt.toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EventListItem {...item} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Widget } from '@/components/widget';
|
||||
|
||||
import { db, getEvents } from '@openpanel/db';
|
||||
|
||||
import { EventConversionsList } from './event-conversions-list';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default async function EventConversionsListServer({ projectId }: Props) {
|
||||
const conversions = await db.eventMeta.findMany({
|
||||
where: {
|
||||
project_id: projectId,
|
||||
conversion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (conversions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const events = await getEvents(
|
||||
`SELECT * FROM events WHERE project_id = '${projectId}' AND name IN (${conversions.map((c) => `'${c.name}'`).join(', ')}) ORDER BY created_at DESC LIMIT 20;`,
|
||||
{
|
||||
profile: true,
|
||||
meta: true,
|
||||
}
|
||||
);
|
||||
|
||||
return <EventConversionsList data={events} />;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ChartSwitchShortcut } 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 { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
import { EventEdit } from './event-edit';
|
||||
|
||||
interface Props {
|
||||
event: IServiceCreateEventPayload;
|
||||
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: 'Duration',
|
||||
value: event.duration ? round(event.duration / 1000, 1) : undefined,
|
||||
},
|
||||
{
|
||||
name: 'Referrer',
|
||||
value: event.referrer,
|
||||
onClick() {
|
||||
setFilter('referrer', event.referrer ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Referrer name',
|
||||
value: event.referrerName,
|
||||
onClick() {
|
||||
setFilter('referrer_name', event.referrerName ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Referrer type',
|
||||
value: event.referrerType,
|
||||
onClick() {
|
||||
setFilter('referrer_type', event.referrerType ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Brand',
|
||||
value: event.brand,
|
||||
onClick() {
|
||||
setFilter('brand', event.brand ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Model',
|
||||
value: event.model,
|
||||
onClick() {
|
||||
setFilter('model', event.model ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Browser',
|
||||
value: event.browser,
|
||||
onClick() {
|
||||
setFilter('browser', event.browser ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Browser version',
|
||||
value: event.browserVersion,
|
||||
onClick() {
|
||||
setFilter('browser_version', event.browserVersion ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'OS',
|
||||
value: event.os,
|
||||
onClick() {
|
||||
setFilter('os', event.os ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'OS version',
|
||||
value: event.osVersion,
|
||||
onClick() {
|
||||
setFilter('os_version', event.osVersion ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'City',
|
||||
value: event.city,
|
||||
onClick() {
|
||||
setFilter('city', event.city ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Region',
|
||||
value: event.region,
|
||||
onClick() {
|
||||
setFilter('region', event.region ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Country',
|
||||
value: event.country,
|
||||
onClick() {
|
||||
setFilter('country', event.country ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Continent',
|
||||
value: event.continent,
|
||||
onClick() {
|
||||
setFilter('continent', event.continent ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Device',
|
||||
value: event.device,
|
||||
onClick() {
|
||||
setFilter('device', event.device ?? '');
|
||||
},
|
||||
},
|
||||
].filter((item) => typeof item.value === 'string' && item.value);
|
||||
|
||||
const properties = Object.entries(event.properties)
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
value: value as string | number | undefined,
|
||||
}))
|
||||
.filter((item) => typeof item.value === 'string' && item.value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent>
|
||||
<div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{properties.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Params</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{properties.map((item) => (
|
||||
<KeyValue
|
||||
key={item.name}
|
||||
name={item.name}
|
||||
value={item.value}
|
||||
onClick={() => {
|
||||
setFilter(
|
||||
`properties.${item.name}`,
|
||||
item.value ? String(item.value) : '',
|
||||
'is'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Common</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{common.map((item) => (
|
||||
<KeyValue
|
||||
key={item.name}
|
||||
name={item.name}
|
||||
value={item.value}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm font-medium mb-2">
|
||||
<div>Similar events</div>
|
||||
<button
|
||||
className="hover:underline text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEvents([event.name]);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
<ChartSwitchShortcut
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
import {
|
||||
EventIconColors,
|
||||
EventIconMapper,
|
||||
EventIconRecords,
|
||||
} from './event-icon';
|
||||
|
||||
interface Props {
|
||||
event: IServiceCreateEventPayload;
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function EventEdit({ event, open, setOpen }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const { name, meta, projectId } = event;
|
||||
|
||||
const [selectedIcon, setIcon] = useState(
|
||||
meta?.icon ??
|
||||
EventIconRecords[name]?.icon ??
|
||||
EventIconRecords.default?.icon ??
|
||||
''
|
||||
);
|
||||
const [selectedColor, setColor] = useState(
|
||||
meta?.color ??
|
||||
EventIconRecords[name]?.color ??
|
||||
EventIconRecords.default?.color ??
|
||||
''
|
||||
);
|
||||
const [conversion, setConversion] = useState(!!meta?.conversion);
|
||||
|
||||
useEffect(() => {
|
||||
if (meta?.icon) {
|
||||
setIcon(meta.icon);
|
||||
}
|
||||
}, [meta?.icon]);
|
||||
useEffect(() => {
|
||||
if (meta?.color) {
|
||||
setColor(meta.color);
|
||||
}
|
||||
}, [meta?.color]);
|
||||
useEffect(() => {
|
||||
setConversion(meta?.conversion ?? false);
|
||||
}, [meta?.conversion]);
|
||||
|
||||
const SelectedIcon = EventIconMapper[selectedIcon]!;
|
||||
|
||||
const mutation = api.event.updateEventMeta.useMutation({
|
||||
onSuccess() {
|
||||
setOpen(false);
|
||||
toast('Event updated');
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
const getBg = (color: string) => `bg-${color}-200`;
|
||||
const getText = (color: string) => `text-${color}-700`;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit "{name}"</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-8 my-8">
|
||||
<div>
|
||||
<Label className="mb-4 block">Conversion</Label>
|
||||
<label className="cursor-pointer flex items-center select-none border border-border rounded-md p-4 gap-4">
|
||||
<Checkbox
|
||||
checked={conversion}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked === 'indeterminate') return;
|
||||
setConversion(checked);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span>Yes, this event is important!</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-4 block">Pick a icon</Label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(EventIconMapper).map(([name, Icon]) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => {
|
||||
setIcon(name);
|
||||
}}
|
||||
className={cn(
|
||||
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer inline-flex transition-all bg-slate-100 flex items-center justify-center',
|
||||
name === selectedIcon
|
||||
? 'scale-110 ring-1 ring-black'
|
||||
: '[&_svg]:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-4 block">Pick a color</Label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{EventIconColors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => {
|
||||
setColor(color);
|
||||
}}
|
||||
className={cn(
|
||||
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer transition-all flex justify-center items-center',
|
||||
color === selectedColor ? 'ring-1 ring-black' : '',
|
||||
getBg(color)
|
||||
)}
|
||||
>
|
||||
{SelectedIcon ? (
|
||||
<SelectedIcon size={16} />
|
||||
) : (
|
||||
<svg
|
||||
className={`${getText(color)} opacity-70`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12.1" cy="12.1" r="4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
mutation.mutate({
|
||||
projectId,
|
||||
name,
|
||||
icon: selectedIcon,
|
||||
color: selectedColor,
|
||||
conversion,
|
||||
})
|
||||
}
|
||||
>
|
||||
Update event
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Tooltip, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { EventMeta } from '@openpanel/db';
|
||||
|
||||
const variants = cva('flex items-center justify-center shrink-0 rounded-full', {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'w-6 h-6',
|
||||
default: 'w-10 h-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
type EventIconProps = VariantProps<typeof variants> & {
|
||||
name: string;
|
||||
meta?: EventMeta;
|
||||
projectId: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const EventIconRecords: Record<
|
||||
string,
|
||||
{
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
> = {
|
||||
default: {
|
||||
icon: 'BotIcon',
|
||||
color: 'slate',
|
||||
},
|
||||
screen_view: {
|
||||
icon: 'MonitorPlayIcon',
|
||||
color: 'blue',
|
||||
},
|
||||
session_start: {
|
||||
icon: 'ActivityIcon',
|
||||
color: 'teal',
|
||||
},
|
||||
link_out: {
|
||||
icon: 'ExternalLinkIcon',
|
||||
color: 'indigo',
|
||||
},
|
||||
};
|
||||
|
||||
export const EventIconMapper: Record<string, LucideIcon> = {
|
||||
DownloadIcon: Icons.DownloadIcon,
|
||||
BotIcon: Icons.BotIcon,
|
||||
BoxIcon: Icons.BoxIcon,
|
||||
AccessibilityIcon: Icons.AccessibilityIcon,
|
||||
ActivityIcon: Icons.ActivityIcon,
|
||||
AirplayIcon: Icons.AirplayIcon,
|
||||
AlarmCheckIcon: Icons.AlarmCheckIcon,
|
||||
AlertTriangleIcon: Icons.AlertTriangleIcon,
|
||||
BellIcon: Icons.BellIcon,
|
||||
BoltIcon: Icons.BoltIcon,
|
||||
CandyIcon: Icons.CandyIcon,
|
||||
ConeIcon: Icons.ConeIcon,
|
||||
MonitorPlayIcon: Icons.MonitorPlayIcon,
|
||||
PizzaIcon: Icons.PizzaIcon,
|
||||
SearchIcon: Icons.SearchIcon,
|
||||
HomeIcon: Icons.HomeIcon,
|
||||
MailIcon: Icons.MailIcon,
|
||||
AngryIcon: Icons.AngryIcon,
|
||||
AnnoyedIcon: Icons.AnnoyedIcon,
|
||||
ArchiveIcon: Icons.ArchiveIcon,
|
||||
AwardIcon: Icons.AwardIcon,
|
||||
BadgeCheckIcon: Icons.BadgeCheckIcon,
|
||||
BeerIcon: Icons.BeerIcon,
|
||||
BluetoothIcon: Icons.BluetoothIcon,
|
||||
BookIcon: Icons.BookIcon,
|
||||
BookmarkIcon: Icons.BookmarkIcon,
|
||||
BookCheckIcon: Icons.BookCheckIcon,
|
||||
BookMinusIcon: Icons.BookMinusIcon,
|
||||
BookPlusIcon: Icons.BookPlusIcon,
|
||||
CalendarIcon: Icons.CalendarIcon,
|
||||
ClockIcon: Icons.ClockIcon,
|
||||
CogIcon: Icons.CogIcon,
|
||||
LoaderIcon: Icons.LoaderIcon,
|
||||
CrownIcon: Icons.CrownIcon,
|
||||
FileIcon: Icons.FileIcon,
|
||||
KeyRoundIcon: Icons.KeyRoundIcon,
|
||||
GemIcon: Icons.GemIcon,
|
||||
GlobeIcon: Icons.GlobeIcon,
|
||||
LightbulbIcon: Icons.LightbulbIcon,
|
||||
LightbulbOffIcon: Icons.LightbulbOffIcon,
|
||||
LockIcon: Icons.LockIcon,
|
||||
MessageCircleIcon: Icons.MessageCircleIcon,
|
||||
RadioIcon: Icons.RadioIcon,
|
||||
RepeatIcon: Icons.RepeatIcon,
|
||||
ShareIcon: Icons.ShareIcon,
|
||||
ExternalLinkIcon: Icons.ExternalLinkIcon,
|
||||
};
|
||||
|
||||
export const EventIconColors = [
|
||||
'rose',
|
||||
'pink',
|
||||
'fuchsia',
|
||||
'purple',
|
||||
'violet',
|
||||
'indigo',
|
||||
'blue',
|
||||
'sky',
|
||||
'cyan',
|
||||
'teal',
|
||||
'emerald',
|
||||
'green',
|
||||
'lime',
|
||||
'yellow',
|
||||
'amber',
|
||||
'orange',
|
||||
'red',
|
||||
'stone',
|
||||
'neutral',
|
||||
'zinc',
|
||||
'grey',
|
||||
'slate',
|
||||
];
|
||||
|
||||
export function EventIcon({ className, name, size, meta }: EventIconProps) {
|
||||
const Icon =
|
||||
EventIconMapper[
|
||||
meta?.icon ??
|
||||
EventIconRecords[name]?.icon ??
|
||||
EventIconRecords.default?.icon ??
|
||||
''
|
||||
]!;
|
||||
const color =
|
||||
meta?.color ??
|
||||
EventIconRecords[name]?.color ??
|
||||
EventIconRecords.default?.color ??
|
||||
'';
|
||||
|
||||
return (
|
||||
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
|
||||
<Icon size={size === 'sm' ? 14 : 20} className={`text-${color}-700`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
import { EventDetails } from './event-details';
|
||||
import { EventIcon } from './event-icon';
|
||||
|
||||
type EventListItemProps = IServiceCreateEventPayload;
|
||||
|
||||
export function EventListItem(props: EventListItemProps) {
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const { createdAt, name, path, duration, meta, profile } = props;
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
|
||||
const number = useNumber();
|
||||
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `Route: ${path}`;
|
||||
}
|
||||
|
||||
return name.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventDetails
|
||||
event={props}
|
||||
open={isDetailsOpen}
|
||||
setOpen={setIsDetailsOpen}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsDetailsOpen(true)}
|
||||
className={cn(
|
||||
'w-full card p-4 flex hover:bg-slate-50 rounded-lg transition-colors justify-between items-center',
|
||||
meta?.conversion && `bg-${meta.color}-50 hover:bg-${meta.color}-100`
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-4 items-center text-left text-sm">
|
||||
<EventIcon size="sm" name={name} meta={meta} projectId={projectId} />
|
||||
<span>
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
{' '}
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Tooltiper
|
||||
asChild
|
||||
content={`${profile?.firstName} ${profile?.lastName}`}
|
||||
>
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
href={`/${organizationId}/${projectId}/profiles/${profile?.id}`}
|
||||
className="text-muted-foreground text-sm hover:underline whitespace-nowrap max-w-[80px] overflow-hidden text-ellipsis"
|
||||
>
|
||||
{profile?.firstName} {profile?.lastName}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment, Suspense } from 'react';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useCursor } from '@/hooks/useCursor';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { isSameDay } from 'date-fns';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
GanttChartIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { IServiceCreateEventPayload } 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: IServiceCreateEventPayload[];
|
||||
count: number;
|
||||
}
|
||||
export function EventList({ data, count }: EventListProps) {
|
||||
const { cursor, setCursor } = 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 recieved 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="bg-slate-100 border border-slate-300 rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2">
|
||||
{item.createdAt.toLocaleDateString()}
|
||||
</div>
|
||||
{index === 0 && (
|
||||
<Pagination
|
||||
size="sm"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EventListItem {...item} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
className="mt-2"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
|
||||
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
|
||||
ssr: false,
|
||||
loading: () => <div>0</div>,
|
||||
});
|
||||
|
||||
export default function EventListener() {
|
||||
const router = useRouter();
|
||||
const { projectId } = useAppParams();
|
||||
const ws = String(process.env.NEXT_PUBLIC_API_URL)
|
||||
.replace(/^https/, 'wss')
|
||||
.replace(/^http/, 'ws');
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [socketUrl] = useState(`${ws}/live/events/${projectId}`);
|
||||
|
||||
useWebSocket(socketUrl, {
|
||||
shouldReconnect: () => true,
|
||||
onMessage(payload) {
|
||||
const event = JSON.parse(payload.data) as IServiceCreateEventPayload;
|
||||
if (event?.name) {
|
||||
setCounter((prev) => prev + 1);
|
||||
toast(`New event ${event.name} from ${event.country}!`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCounter(0);
|
||||
router.refresh();
|
||||
}}
|
||||
className="bg-white border border-border rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2"
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all'
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all'
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
{counter === 0 ? (
|
||||
'Listening'
|
||||
) : (
|
||||
<>
|
||||
<AnimatedNumbers
|
||||
includeComma
|
||||
transitions={(index) => ({
|
||||
type: 'spring',
|
||||
duration: index + 0.3,
|
||||
|
||||
damping: 10,
|
||||
stiffness: 200,
|
||||
})}
|
||||
animateToNumber={counter}
|
||||
locale="en"
|
||||
/>
|
||||
new events
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{counter === 0 ? 'Listening to new events' : 'Click to refresh'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[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 { getExists } from '@/server/pageExists';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
|
||||
import { getEventList, getEventsCount } from '@openpanel/db';
|
||||
|
||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||
import { EventsPerDayChart } from './charts/events-per-day-chart';
|
||||
import EventConversionsListServer from './event-conversions-list';
|
||||
import { EventList } from './event-list';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
searchParams: {
|
||||
events?: string;
|
||||
cursor?: string;
|
||||
f?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const nuqsOptions = {
|
||||
shallow: false,
|
||||
};
|
||||
|
||||
export default async function Page({
|
||||
params: { projectId, organizationId },
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const filters =
|
||||
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined;
|
||||
const eventsFilter = eventQueryNamesFilter.parseServerSide(
|
||||
searchParams.events ?? ''
|
||||
);
|
||||
const [events, count] = await Promise.all([
|
||||
getEventList({
|
||||
cursor:
|
||||
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
|
||||
projectId,
|
||||
take: 50,
|
||||
events: eventsFilter,
|
||||
filters,
|
||||
}),
|
||||
getEventsCount({
|
||||
projectId,
|
||||
events: eventsFilter,
|
||||
filters,
|
||||
}),
|
||||
getExists(organizationId, projectId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageLayout title="Events" organizationSlug={organizationId}>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<OverviewFiltersDrawer
|
||||
mode="events"
|
||||
projectId={projectId}
|
||||
nuqsOptions={nuqsOptions}
|
||||
enableEventsFilter
|
||||
/>
|
||||
<OverviewFiltersButtons
|
||||
className="p-0 justify-end"
|
||||
nuqsOptions={nuqsOptions}
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<div className="grid md:grid-cols-2 p-4 gap-4">
|
||||
<div>
|
||||
<EventList data={events} count={count} />
|
||||
</div>
|
||||
<div>
|
||||
<EventsPerDayChart
|
||||
projectId={projectId}
|
||||
events={eventsFilter}
|
||||
filters={filters}
|
||||
/>
|
||||
<EventConversionsListServer projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useUser } from '@clerk/nextjs';
|
||||
import {
|
||||
BookmarkIcon,
|
||||
BuildingIcon,
|
||||
CogIcon,
|
||||
GanttChartIcon,
|
||||
KeySquareIcon,
|
||||
LayoutPanelTopIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WallpaperIcon,
|
||||
WarehouseIcon,
|
||||
} from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import type { IServiceDashboards } from '@openpanel/db';
|
||||
|
||||
function LinkWithIcon({
|
||||
href,
|
||||
icon: Icon,
|
||||
label,
|
||||
active: overrideActive,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ElementType<LucideProps>;
|
||||
label: React.ReactNode;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const active = overrideActive || href === pathname;
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
'text-slate-800 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
|
||||
active && 'bg-blue-50',
|
||||
className
|
||||
)}
|
||||
href={href}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<div className="flex-1">{label}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface LayoutMenuProps {
|
||||
dashboards: IServiceDashboards;
|
||||
}
|
||||
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
||||
const { user } = useUser();
|
||||
|
||||
const pathname = usePathname();
|
||||
const params = useAppParams();
|
||||
const hasProjectId =
|
||||
params.projectId &&
|
||||
params.projectId !== 'null' &&
|
||||
params.projectId !== 'undefined';
|
||||
const projectId = hasProjectId
|
||||
? params.projectId
|
||||
: (user?.unsafeMetadata.projectId as string);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasProjectId) {
|
||||
user?.update({
|
||||
unsafeMetadata: {
|
||||
projectId: params.projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [params.projectId, hasProjectId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkWithIcon
|
||||
icon={WallpaperIcon}
|
||||
label="Overview"
|
||||
href={`/${params.organizationId}/${projectId}`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={LayoutPanelTopIcon}
|
||||
label="Dashboards"
|
||||
href={`/${params.organizationId}/${projectId}/dashboards`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={GanttChartIcon}
|
||||
label="Events"
|
||||
href={`/${params.organizationId}/${projectId}/events`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={UsersIcon}
|
||||
label="Profiles"
|
||||
href={`/${params.organizationId}/${projectId}/profiles`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={CogIcon}
|
||||
label="Settings"
|
||||
href={`/${params.organizationId}/${projectId}/settings/organization`}
|
||||
/>
|
||||
{pathname?.includes('/settings/') && (
|
||||
<div className="pl-7 flex flex-col gap-1">
|
||||
<LinkWithIcon
|
||||
icon={BuildingIcon}
|
||||
label="Organization"
|
||||
href={`/${params.organizationId}/${projectId}/settings/organization`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={WarehouseIcon}
|
||||
label="Projects"
|
||||
href={`/${params.organizationId}/${projectId}/settings/projects`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={KeySquareIcon}
|
||||
label="Clients"
|
||||
href={`/${params.organizationId}/${projectId}/settings/clients`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={UserIcon}
|
||||
label="Profile (yours)"
|
||||
href={`/${params.organizationId}/${projectId}/settings/profile`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={BookmarkIcon}
|
||||
label="References"
|
||||
href={`/${params.organizationId}/${projectId}/settings/references`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{dashboards.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="font-medium mb-2 text-sm">Your dashboards</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dashboards.map((item) => (
|
||||
<LinkWithIcon
|
||||
key={item.id}
|
||||
icon={LayoutPanelTopIcon}
|
||||
label={
|
||||
<div className="flex justify-between gap-0.5 items-center">
|
||||
<span>{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.project.name}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { Building } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
|
||||
interface LayoutOrganizationSelectorProps {
|
||||
organizations: IServiceOrganization[];
|
||||
}
|
||||
|
||||
export default function LayoutOrganizationSelector({
|
||||
organizations,
|
||||
}: LayoutOrganizationSelectorProps) {
|
||||
const params = useAppParams();
|
||||
const router = useRouter();
|
||||
|
||||
const organization = organizations.find(
|
||||
(item) => item.slug === params.organizationId
|
||||
);
|
||||
|
||||
if (!organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
className="w-full"
|
||||
placeholder="Select organization"
|
||||
icon={Building}
|
||||
value={organization.slug}
|
||||
items={
|
||||
organizations
|
||||
.filter((item) => item.slug)
|
||||
.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.slug!,
|
||||
})) ?? []
|
||||
}
|
||||
onChange={(value) => {
|
||||
router.push(`/${value}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import type { getProjectsByOrganizationSlug } from '@openpanel/db';
|
||||
|
||||
interface LayoutProjectSelectorProps {
|
||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
|
||||
}
|
||||
export default function LayoutProjectSelector({
|
||||
projects,
|
||||
}: LayoutProjectSelectorProps) {
|
||||
const router = useRouter();
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const pathname = usePathname() || '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Combobox
|
||||
portal
|
||||
align="end"
|
||||
className="w-auto min-w-0 max-sm:max-w-[100px]"
|
||||
placeholder={'Select project'}
|
||||
onChange={(value) => {
|
||||
if (organizationId && 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(`/${organizationId}/${value}`);
|
||||
}
|
||||
}}
|
||||
value={projectId}
|
||||
items={
|
||||
projects.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { buttonVariants } 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 { usePathname } from 'next/navigation';
|
||||
|
||||
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
|
||||
|
||||
import LayoutMenu from './layout-menu';
|
||||
import LayoutOrganizationSelector from './layout-organization-selector';
|
||||
|
||||
interface LayoutSidebarProps {
|
||||
organizations: IServiceOrganization[];
|
||||
dashboards: IServiceDashboards;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
}
|
||||
export function LayoutSidebar({
|
||||
organizations,
|
||||
dashboards,
|
||||
organizationId,
|
||||
projectId,
|
||||
}: LayoutSidebarProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setActive(false);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setActive(false)}
|
||||
className={cn(
|
||||
'fixed top-0 left-0 right-0 bottom-0 backdrop-blur-sm z-30 transition-opacity',
|
||||
active
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 left-0 h-screen border-r border-border w-72 bg-white flex flex-col z-30 transition-transform',
|
||||
'-translate-x-72 lg:-translate-x-0', // responsive
|
||||
active && 'translate-x-0' // force active on mobile
|
||||
)}
|
||||
>
|
||||
<div className="absolute -right-12 h-16 flex items-center lg:hidden">
|
||||
<Hamburger toggled={active} onToggle={setActive} size={20} />
|
||||
</div>
|
||||
<div className="h-16 border-b border-border px-4 shrink-0 flex items-center">
|
||||
<Link href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
|
||||
<LayoutMenu dashboards={dashboards} />
|
||||
{/* Placeholder for LayoutOrganizationSelector */}
|
||||
<div className="h-32 block shrink-0"></div>
|
||||
</div>
|
||||
<div className="fixed bottom-0 left-0 right-0">
|
||||
<div className="bg-gradient-to-t from-white to-white/0 h-8 w-full"></div>
|
||||
<div className="bg-white p-4 pt-0 flex flex-col gap-2">
|
||||
<Link
|
||||
className={cn('flex gap-2', buttonVariants())}
|
||||
href={`/${organizationId}/${projectId}/reports`}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
Create a report
|
||||
</Link>
|
||||
<LayoutOrganizationSelector organizations={organizations} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface StickyBelowHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StickyBelowHeader({
|
||||
children,
|
||||
className,
|
||||
}: StickyBelowHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'md:sticky bg-white border-b border-border z-20 [[id=dashboard]_&]:top-16 [[id=dashboard]_&]:rounded-none rounded-lg top-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import {
|
||||
getCurrentOrganizations,
|
||||
getDashboardsByProjectId,
|
||||
getProjectsByOrganizationSlug,
|
||||
} from '@openpanel/db';
|
||||
|
||||
import { LayoutSidebar } from './layout-sidebar';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
params: { organizationId, projectId },
|
||||
}: AppLayoutProps) {
|
||||
const [organizations, projects, dashboards] = await Promise.all([
|
||||
getCurrentOrganizations(),
|
||||
getProjectsByOrganizationSlug(organizationId),
|
||||
getDashboardsByProjectId(projectId),
|
||||
]);
|
||||
|
||||
if (!organizations.find((item) => item.slug === organizationId)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!projects.find((item) => item.id === projectId)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="dashboard">
|
||||
<LayoutSidebar
|
||||
{...{ organizationId, projectId, organizations, dashboards }}
|
||||
/>
|
||||
<div className="lg:pl-72 transition-all">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { ReportRange } from '@/components/report/ReportRange';
|
||||
import { endOfDay, startOfDay } from 'date-fns';
|
||||
|
||||
export function OverviewReportRange() {
|
||||
const { range, setRange, setEndDate, setStartDate, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
return (
|
||||
<ReportRange
|
||||
range={range}
|
||||
onRangeChange={(value) => {
|
||||
setRange(value);
|
||||
setStartDate(null);
|
||||
setEndDate(null);
|
||||
}}
|
||||
dates={{
|
||||
startDate,
|
||||
endDate,
|
||||
}}
|
||||
onDatesChange={(val) => {
|
||||
if (!val) return;
|
||||
|
||||
if (val.from && val.to) {
|
||||
setRange(null);
|
||||
setStartDate(startOfDay(val.from).toISOString());
|
||||
setEndDate(endOfDay(val.to).toISOString());
|
||||
} else if (val.from) {
|
||||
setStartDate(startOfDay(val.from).toISOString());
|
||||
} else if (val.to) {
|
||||
setEndDate(endOfDay(val.to).toISOString());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getProjectsByOrganizationSlug } from '@openpanel/db';
|
||||
|
||||
import LayoutProjectSelector from './layout-project-selector';
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
organizationSlug: string;
|
||||
}
|
||||
|
||||
export default async function PageLayout({
|
||||
children,
|
||||
title,
|
||||
organizationSlug,
|
||||
}: PageLayoutProps) {
|
||||
const projects = await getProjectsByOrganizationSlug(organizationSlug);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-16 border-b border-border flex-shrink-0 sticky top-0 bg-white px-4 flex items-center justify-between z-20 pl-12 lg:pl-4">
|
||||
<div className="text-xl font-medium">{title}</div>
|
||||
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[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';
|
||||
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
|
||||
import { OverviewShare } from '@/components/overview/overview-share';
|
||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
|
||||
import OverviewMetrics from '../../../../components/overview/overview-metrics';
|
||||
import { StickyBelowHeader } from './layout-sticky-below-header';
|
||||
import { OverviewReportRange } from './overview-sticky-header';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
const [share] = await Promise.all([
|
||||
db.shareOverview.findUnique({
|
||||
where: {
|
||||
project_id: projectId,
|
||||
},
|
||||
}),
|
||||
getExists(organizationId, projectId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageLayout title="Overview" organizationSlug={organizationId}>
|
||||
<StickyBelowHeader>
|
||||
<div className="p-4 flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewReportRange />
|
||||
<OverviewFiltersDrawer projectId={projectId} mode="events" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ServerLiveCounter projectId={projectId} />
|
||||
<OverviewShare data={share} />
|
||||
</div>
|
||||
</div>
|
||||
<OverviewFiltersButtons />
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4 grid gap-4 grid-cols-6">
|
||||
<div className="col-span-6">
|
||||
<OverviewLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
<OverviewTopEvents projectId={projectId} />
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { ChartSwitch } from '@/components/report/chart';
|
||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import {
|
||||
eventQueryFiltersParser,
|
||||
eventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { parseAsInteger, parseAsString } from 'nuqs';
|
||||
|
||||
import type { GetEventListOptions } from '@openpanel/db';
|
||||
import {
|
||||
getConversionEventNames,
|
||||
getEventList,
|
||||
getEventsCount,
|
||||
getProfileById,
|
||||
getProfileName,
|
||||
} from '@openpanel/db';
|
||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||
|
||||
import { EventList } from '../../events/event-list';
|
||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
searchParams: {
|
||||
events?: string;
|
||||
cursor?: string;
|
||||
f?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { projectId, profileId, organizationId },
|
||||
searchParams,
|
||||
}: 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 startDate = parseAsString.parseServerSide(searchParams.startDate);
|
||||
const endDate = parseAsString.parseServerSide(searchParams.endDate);
|
||||
const [profile, events, count, conversions] = await Promise.all([
|
||||
getProfileById(profileId),
|
||||
getEventList(eventListOptions),
|
||||
getEventsCount(eventListOptions),
|
||||
getConversionEventNames(projectId),
|
||||
getExists(organizationId, projectId),
|
||||
]);
|
||||
|
||||
const chartSelectedEvents: IChartEvent[] = [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'Events',
|
||||
},
|
||||
];
|
||||
|
||||
if (conversions.length) {
|
||||
chartSelectedEvents.push({
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: conversions.map((c) => c.name),
|
||||
},
|
||||
],
|
||||
id: 'B',
|
||||
name: '*',
|
||||
displayName: 'Conversions',
|
||||
});
|
||||
}
|
||||
|
||||
const profileChart: IChartInput = {
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
chartType: 'histogram',
|
||||
events: chartSelectedEvents,
|
||||
breakdowns: [],
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: 'Events',
|
||||
range: '1m',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
if (!profile) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
organizationSlug={organizationId}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
|
||||
{getProfileName(profile)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<OverviewFiltersDrawer
|
||||
projectId={projectId}
|
||||
mode="events"
|
||||
nuqsOptions={{ shallow: false }}
|
||||
/>
|
||||
<OverviewFiltersButtons
|
||||
nuqsOptions={{ shallow: false }}
|
||||
className="p-0 justify-end"
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
|
||||
<div>
|
||||
<EventList data={events} count={count} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex gap-2">
|
||||
<ChartSwitch {...profileChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex justify-between items-center">
|
||||
<span className="title">Profile</span>
|
||||
<ProfileAvatar {...profile} />
|
||||
</WidgetHead>
|
||||
<div className="grid grid-cols-1 text-sm">
|
||||
<ValueRow name={'ID'} value={profile.id} />
|
||||
<ValueRow name={'First name'} value={profile.firstName} />
|
||||
<ValueRow name={'Last name'} value={profile.lastName} />
|
||||
<ValueRow name={'Mail'} value={profile.email} />
|
||||
<ValueRow
|
||||
name={'Last seen'}
|
||||
value={profile.createdAt.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</Widget>
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Properties</span>
|
||||
</WidgetHead>
|
||||
<div className="grid grid-cols-1 text-sm">
|
||||
{Object.entries(profile.properties)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => (
|
||||
<ValueRow key={key} name={key} value={value} />
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueRow({ name, value }: { name: string; value?: unknown }) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-row justify-between p-2 px-4">
|
||||
<div className="font-medium text-muted-foreground capitalize">
|
||||
{name.replace('_', ' ')}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center text-right">
|
||||
{typeof value === 'string' ? (
|
||||
<>
|
||||
<SerieIcon name={value} /> {value}
|
||||
</>
|
||||
) : (
|
||||
<>{value}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
|
||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||
import ProfileLastSeenServer from './profile-last-seen';
|
||||
import ProfileListServer from './profile-list';
|
||||
import ProfileTopServer from './profile-top';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
searchParams: {
|
||||
f?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const nuqsOptions = {
|
||||
shallow: false,
|
||||
};
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
searchParams: { cursor, f },
|
||||
}: PageProps) {
|
||||
await getExists(organizationId, projectId);
|
||||
|
||||
return (
|
||||
<PageLayout title="Profiles" organizationSlug={organizationId}>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<OverviewFiltersDrawer
|
||||
projectId={projectId}
|
||||
nuqsOptions={nuqsOptions}
|
||||
mode="events"
|
||||
/>
|
||||
<OverviewFiltersButtons
|
||||
className="p-0 justify-end"
|
||||
nuqsOptions={nuqsOptions}
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ProfileListServer
|
||||
projectId={projectId}
|
||||
cursor={parseAsInteger.parseServerSide(cursor ?? '') ?? undefined}
|
||||
filters={
|
||||
eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ProfileLastSeenServer projectId={projectId} />
|
||||
<ProfileTopServer
|
||||
projectId={projectId}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { chQuery } from '@openpanel/db';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default async function ProfileLastSeenServer({ projectId }: Props) {
|
||||
interface Row {
|
||||
days: number;
|
||||
count: number;
|
||||
}
|
||||
// Days since last event from users
|
||||
// group by days
|
||||
const res = await chQuery<Row>(
|
||||
`SELECT age('days',created_at, now()) as days, count(distinct profile_id) as count FROM events where project_id = '${projectId}' group by days order by days ASC`
|
||||
);
|
||||
|
||||
const take = 18;
|
||||
const split = take / 2;
|
||||
const max = Math.max(...res.map((item) => item.count));
|
||||
const renderItem = (item: Row) => (
|
||||
<div
|
||||
key={item.days}
|
||||
className="flex-1 shrink-0 h-full flex flex-col items-center"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="w-full flex-1 bg-slate-200 rounded flex flex-col justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded',
|
||||
item.days < split ? 'bg-blue-600' : 'bg-blue-400'
|
||||
)}
|
||||
style={{
|
||||
height: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{item.count} profiles last seen{' '}
|
||||
{item.days === 0 ? 'today' : `${item.days} days ago`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="text-xs mt-1">{item.days}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<div className="title">Last seen</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<div className="flex aspect-[3/1] w-full gap-1 items-end">
|
||||
{res.length >= 18 ? (
|
||||
<>
|
||||
{res.slice(0, split).map(renderItem)}
|
||||
{res.slice(-split).map(renderItem)}
|
||||
</>
|
||||
) : (
|
||||
res.map(renderItem)
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-xs text-muted-foreground">DAYS</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
export default async function ProfileListServer({
|
||||
projectId,
|
||||
cursor,
|
||||
filters,
|
||||
}: Props) {
|
||||
const [profiles, count] = await Promise.all([
|
||||
getProfileList({
|
||||
projectId,
|
||||
take: 10,
|
||||
cursor,
|
||||
filters,
|
||||
}),
|
||||
getProfileListCount({
|
||||
projectId,
|
||||
filters,
|
||||
}),
|
||||
]);
|
||||
return <ProfileList data={profiles} count={count} />;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'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 { UsersIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getProfileName } from '@openpanel/db';
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
|
||||
interface ProfileListProps {
|
||||
data: IServiceProfile[];
|
||||
count: number;
|
||||
}
|
||||
export function ProfileList({ data, count }: ProfileListProps) {
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const { cursor, setCursor } = useCursor();
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetHead className="flex justify-between items-center">
|
||||
<div className="title">Profiles</div>
|
||||
<Pagination
|
||||
size="sm"
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={10}
|
||||
/>
|
||||
</WidgetHead>
|
||||
{data.length ? (
|
||||
<>
|
||||
<WidgetTable
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
columns={[
|
||||
{
|
||||
name: 'Name',
|
||||
render(profile) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${organizationId}/${projectId}/profiles/${profile.id}`}
|
||||
className="flex gap-2 items-center font-medium"
|
||||
>
|
||||
<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-muted-foreground text-sm">
|
||||
{profile.createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="p-4 border-t border-border">
|
||||
<Pagination
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={10}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<FullPageEmptyState title="No profiles here" 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(count / 10 - 1)}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p>Looks like there is no profiles here</p>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 Link from 'next/link';
|
||||
|
||||
import { chQuery, getProfileName, getProfiles } from '@openpanel/db';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export default async function ProfileTopServer({
|
||||
organizationId,
|
||||
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 events where profile_id != '' and project_id = '${projectId}' group by profile_id order by count() DESC LIMIT 10`
|
||||
);
|
||||
const profiles = await getProfiles({ ids: 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={`/${organizationId}/${projectId}/profiles/${profile.id}`}
|
||||
className="flex gap-2 items-center font-medium"
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
render(profile) {
|
||||
return <ListPropertiesIcon {...profile.properties} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
render(profile) {
|
||||
return profile.count;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getOrganizationBySlug, getReportById } from '@openpanel/db';
|
||||
|
||||
import ReportEditor from '../report-editor';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
projectId: string;
|
||||
reportId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { reportId, organizationId, projectId },
|
||||
}: PageProps) {
|
||||
const [report] = await Promise.all([
|
||||
getReportById(reportId),
|
||||
getExists(organizationId, projectId),
|
||||
]);
|
||||
|
||||
if (!report) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
organizationSlug={organizationId}
|
||||
title={
|
||||
<div className="flex gap-2 items-center cursor-pointer">
|
||||
{report.name}
|
||||
<Pencil size={16} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ReportEditor report={report} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getOrganizationBySlug } from '@openpanel/db';
|
||||
|
||||
import ReportEditor from './report-editor';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
await getExists(organizationId, projectId);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
organizationSlug={organizationId}
|
||||
title={
|
||||
<div className="flex gap-2 items-center cursor-pointer">
|
||||
Unnamed report
|
||||
<Pencil size={16} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ReportEditor report={null} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||
import { ChartSwitch } from '@/components/report/chart';
|
||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||
import { ReportRange } from '@/components/report/ReportRange';
|
||||
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
|
||||
import {
|
||||
changeDateRanges,
|
||||
changeDates,
|
||||
changeEndDate,
|
||||
changeStartDate,
|
||||
ready,
|
||||
reset,
|
||||
setReport,
|
||||
} from '@/components/report/reportSlice';
|
||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { endOfDay, startOfDay } from 'date-fns';
|
||||
import { GanttChartSquareIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceReport } from '@openpanel/db';
|
||||
|
||||
interface ReportEditorProps {
|
||||
report: IServiceReport | null;
|
||||
}
|
||||
|
||||
export default function ReportEditor({
|
||||
report: initialReport,
|
||||
}: ReportEditorProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const dispatch = useDispatch();
|
||||
const report = useSelector((state) => state.report);
|
||||
|
||||
// Set report if reportId exists
|
||||
useEffect(() => {
|
||||
if (initialReport) {
|
||||
dispatch(setReport(initialReport));
|
||||
} else {
|
||||
dispatch(ready());
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(reset());
|
||||
};
|
||||
}, [initialReport, dispatch]);
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<StickyBelowHeader className="p-4 grid grid-cols-2 gap-2 md:grid-cols-6">
|
||||
<SheetTrigger asChild>
|
||||
<div>
|
||||
<Button icon={GanttChartSquareIcon} variant="cta">
|
||||
Pick events
|
||||
</Button>
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
|
||||
<ReportChartType className="min-w-0 flex-1" />
|
||||
<ReportRange
|
||||
className="min-w-0 flex-1"
|
||||
range={report.range}
|
||||
onRangeChange={(value) => {
|
||||
dispatch(changeDateRanges(value));
|
||||
}}
|
||||
dates={{
|
||||
startDate: report.startDate,
|
||||
endDate: report.endDate,
|
||||
}}
|
||||
onDatesChange={(val) => {
|
||||
if (!val) return;
|
||||
|
||||
if (val.from && val.to) {
|
||||
dispatch(
|
||||
changeDates({
|
||||
startDate: startOfDay(val.from).toISOString(),
|
||||
endDate: endOfDay(val.to).toISOString(),
|
||||
})
|
||||
);
|
||||
} else if (val.from) {
|
||||
dispatch(changeStartDate(startOfDay(val.from).toISOString()));
|
||||
} else if (val.to) {
|
||||
dispatch(changeEndDate(endOfDay(val.to).toISOString()));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReportInterval className="min-w-0 flex-1" />
|
||||
<ReportLineType className="min-w-0 flex-1" />
|
||||
</div>
|
||||
<div className="col-start-2 md:col-start-6 row-start-1 text-right">
|
||||
<ReportSaveButton />
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{report.ready && (
|
||||
<ChartSwitch {...report} projectId={projectId} editMode />
|
||||
)}
|
||||
</div>
|
||||
<SheetContent className="!max-w-lg" side="left">
|
||||
<ReportSidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||
import { columns } from '@/components/clients/table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
import type { getClientsByOrganizationId } from '@openpanel/db';
|
||||
|
||||
interface ListClientsProps {
|
||||
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
|
||||
}
|
||||
export default function ListClients({ clients }: ListClientsProps) {
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div />
|
||||
<Button icon={PlusIcon} onClick={() => pushModal('AddClient')}>
|
||||
<span className="max-sm:hidden">Create client</span>
|
||||
<span className="sm:hidden">Client</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<DataTable data={clients} columns={columns} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
|
||||
import { getClientsByOrganizationId } from '@openpanel/db';
|
||||
|
||||
import ListClients from './list-clients';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
await getExists(organizationId);
|
||||
const clients = await getClientsByOrganizationId(organizationId);
|
||||
|
||||
return (
|
||||
<PageLayout title="Clients" organizationSlug={organizationId}>
|
||||
<ListClients clients={clients} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { getOrganizationBySlug } from '@openpanel/db';
|
||||
|
||||
const validator = z.object({
|
||||
id: z.string().min(2),
|
||||
name: z.string().min(2),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
interface EditOrganizationProps {
|
||||
organization: Awaited<ReturnType<typeof getOrganizationBySlug>>;
|
||||
}
|
||||
export default function EditOrganization({
|
||||
organization,
|
||||
}: EditOrganizationProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
defaultValues: organization ?? undefined,
|
||||
});
|
||||
|
||||
const mutation = api.organization.update.useMutation({
|
||||
onSuccess(res) {
|
||||
toast('Organization updated', {
|
||||
description: 'Your organization has been updated.',
|
||||
});
|
||||
reset(res);
|
||||
router.refresh();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Org. details</span>
|
||||
<Button size="sm" type="submit" disabled={!formState.isDirty}>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
{...register('name')}
|
||||
defaultValue={organization?.name}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SendIcon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { zInviteUser } from '@openpanel/validation';
|
||||
|
||||
type IForm = z.infer<typeof zInviteUser>;
|
||||
|
||||
export function InviteUser() {
|
||||
const router = useRouter();
|
||||
const { organizationId: organizationSlug } = useAppParams();
|
||||
|
||||
const { register, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
resolver: zodResolver(zInviteUser),
|
||||
defaultValues: {
|
||||
organizationSlug,
|
||||
email: '',
|
||||
role: 'org:member',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = api.organization.inviteUser.useMutation({
|
||||
onSuccess() {
|
||||
toast('User invited!', {
|
||||
description: 'The user has been invited to the organization.',
|
||||
});
|
||||
reset();
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => mutation.mutate(values))}
|
||||
className="flex items-end gap-4"
|
||||
>
|
||||
<InputWithLabel
|
||||
className="w-full max-w-sm"
|
||||
label="Email"
|
||||
placeholder="Who do you want to invite?"
|
||||
{...register('email')}
|
||||
/>
|
||||
<Button
|
||||
icon={SendIcon}
|
||||
type="submit"
|
||||
disabled={!formState.isDirty}
|
||||
loading={mutation.isLoading}
|
||||
>
|
||||
Invite user
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
|
||||
import type { IServiceInvites } from '@openpanel/db';
|
||||
|
||||
import { InviteUser } from './invite-user';
|
||||
|
||||
interface InvitedUsersProps {
|
||||
invites: IServiceInvites;
|
||||
}
|
||||
export default function InvitedUsers({ invites }: InvitedUsersProps) {
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Invites</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<InviteUser />
|
||||
|
||||
<div className="font-medium mt-8 mb-2">Invited users</div>
|
||||
<Table className="mini">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.map((item) => {
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.email}</TableCell>
|
||||
<TableCell>{item.role}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{invites.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="italic">
|
||||
No invites
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { clerkClient } from '@clerk/nextjs';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getInvites, getOrganizationBySlug } from '@openpanel/db';
|
||||
|
||||
import EditOrganization from './edit-organization';
|
||||
import InvitedUsers from './invited-users';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const organization = await getOrganizationBySlug(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const invites = await getInvites(organization.id);
|
||||
|
||||
return (
|
||||
<PageLayout title={organization.name} organizationSlug={organizationId}>
|
||||
<div className="p-4 grid grid-cols-1 gap-4">
|
||||
<EditOrganization organization={organization} />
|
||||
<InvitedUsers invites={invites} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './organization/page';
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { getUserById } from '@openpanel/db';
|
||||
|
||||
const validator = z.object({
|
||||
firstName: z.string().min(2),
|
||||
lastName: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
interface EditProfileProps {
|
||||
profile: Awaited<ReturnType<typeof getUserById>>;
|
||||
}
|
||||
export default function EditProfile({ profile }: EditProfileProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, reset, formState } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
firstName: profile.firstName ?? '',
|
||||
lastName: profile.lastName ?? '',
|
||||
email: profile.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = api.user.update.useMutation({
|
||||
onSuccess(res) {
|
||||
toast('Profile updated', {
|
||||
description: 'Your profile has been updated.',
|
||||
});
|
||||
reset(res);
|
||||
router.refresh();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Your profile</span>
|
||||
<Button size="sm" type="submit" disabled={!formState.isDirty}>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex flex-col gap-4">
|
||||
<InputWithLabel
|
||||
label="First name"
|
||||
placeholder="Your first name"
|
||||
defaultValue={profile.firstName ?? ''}
|
||||
{...register('firstName')}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Last name"
|
||||
placeholder="Your last name"
|
||||
defaultValue={profile.lastName ?? ''}
|
||||
{...register('lastName')}
|
||||
/>
|
||||
<InputWithLabel
|
||||
disabled
|
||||
label="Email"
|
||||
placeholder="Your email"
|
||||
defaultValue={profile.email ?? ''}
|
||||
{...register('email')}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { SignOutButton } from '@clerk/nextjs';
|
||||
|
||||
export function Logout() {
|
||||
return (
|
||||
<Widget className="border-destructive">
|
||||
<WidgetHead>
|
||||
<span className="title">Sad part</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<p className="mb-4">
|
||||
Sometime's you need to go. See you next time
|
||||
</p>
|
||||
<SignOutButton
|
||||
// @ts-expect-error
|
||||
className={buttonVariants({ variant: 'destructive' })}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { auth } from '@clerk/nextjs';
|
||||
|
||||
import { getUserById } from '@openpanel/db';
|
||||
|
||||
import EditProfile from './edit-profile';
|
||||
import { Logout } from './logout';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const { userId } = auth();
|
||||
await getExists(organizationId);
|
||||
const profile = await getUserById(userId!);
|
||||
|
||||
return (
|
||||
<PageLayout title={profile.lastName} organizationSlug={organizationId}>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<EditProfile profile={profile} />
|
||||
<Logout />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { columns } from '@/components/projects/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
import type { getProjectsByOrganizationSlug } from '@openpanel/db';
|
||||
|
||||
interface ListProjectsProps {
|
||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
|
||||
}
|
||||
export default function ListProjects({ projects }: ListProjectsProps) {
|
||||
const organizationId = useAppParams().organizationId;
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() =>
|
||||
pushModal('AddProject', {
|
||||
organizationId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="max-sm:hidden">Create project</span>
|
||||
<span className="sm:hidden">Project</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<DataTable data={projects} columns={columns} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
|
||||
import { getProjectsByOrganizationSlug } from '@openpanel/db';
|
||||
|
||||
import ListProjects from './list-projects';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
await getExists(organizationId);
|
||||
const projects = await getProjectsByOrganizationSlug(organizationId);
|
||||
|
||||
return (
|
||||
<PageLayout title="Projects" organizationSlug={organizationId}>
|
||||
<ListProjects projects={projects} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { columns } from '@/components/references/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceReference } from '@openpanel/db';
|
||||
|
||||
interface ListProjectsProps {
|
||||
data: IServiceReference[];
|
||||
}
|
||||
|
||||
export default function ListReferences({ data }: ListProjectsProps) {
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<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} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
|
||||
import { getReferences } from '@openpanel/db';
|
||||
|
||||
import ListReferences from './list-references';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
await getExists(organizationId, projectId);
|
||||
const references = await getReferences({
|
||||
where: {
|
||||
project_id: projectId,
|
||||
},
|
||||
take: 50,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageLayout title="References" organizationSlug={organizationId}>
|
||||
<ListReferences data={references} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { LogoSquare } from '@/components/logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { api, handleError } from '../../_trpc/client';
|
||||
|
||||
const validation = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validation>;
|
||||
|
||||
export function CreateProject() {
|
||||
const params = useAppParams();
|
||||
const router = useRouter();
|
||||
const form = useForm<IForm>({
|
||||
resolver: zodResolver(validation),
|
||||
});
|
||||
const mutation = api.project.create.useMutation({
|
||||
onError: handleError,
|
||||
onSuccess() {
|
||||
toast.success('Project created');
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
const onSubmit: SubmitHandler<IForm> = (values) => {
|
||||
mutation.mutate({
|
||||
name: values.name,
|
||||
organizationId: params.organizationId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<LogoSquare className="w-20 md:w-28 mb-8" />
|
||||
<h1 className="font-medium text-3xl">Create your first project</h1>
|
||||
<div className="text-lg">
|
||||
A project is just a container for your events. You can create as many
|
||||
as you want.
|
||||
</div>
|
||||
<form
|
||||
className="mt-8 flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label>Project name</Label>
|
||||
<Input
|
||||
placeholder="My App"
|
||||
size="large"
|
||||
error={form.formState.errors.name?.message}
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
icon={SaveIcon}
|
||||
loading={mutation.isLoading}
|
||||
>
|
||||
Create project
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
apps/dashboard/src/app/(app)/[organizationId]/page.tsx
Normal file
69
apps/dashboard/src/app/(app)/[organizationId]/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { LogoSquare } from '@/components/logo';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
getOrganizationBySlug,
|
||||
getProjectsByOrganizationSlug,
|
||||
isWaitlistUserAccepted,
|
||||
} from '@openpanel/db';
|
||||
|
||||
import { CreateProject } from './create-project';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const [organization, projects] = await Promise.all([
|
||||
getOrganizationBySlug(organizationId),
|
||||
getProjectsByOrganizationSlug(organizationId),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (process.env.BLOCK) {
|
||||
const isAccepted = await isWaitlistUserAccepted();
|
||||
if (!isAccepted) {
|
||||
return (
|
||||
<div className="p-4 flex items-center justify-center h-screen">
|
||||
<div className="max-w-lg w-full">
|
||||
<LogoSquare className="w-20 md:w-28 mb-8" />
|
||||
<h1 className="font-medium text-3xl">Not quite there yet</h1>
|
||||
<div className="text-lg">
|
||||
We're still working on Openpanel, but we're not quite there yet.
|
||||
We'll let you know when we're ready to go!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen p-4 ">
|
||||
<div className="max-w-lg w-full">
|
||||
<CreateProject />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 1 && projects[0]) {
|
||||
return redirect(`/${organizationId}/${projects[0].id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl w-full mx-auto flex flex-col gap-4 pt-20 p-4 ">
|
||||
<h1 className="font-medium text-xl">Select project</h1>
|
||||
{projects.map((item) => (
|
||||
<ProjectCard key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
apps/dashboard/src/app/(app)/create-organization.tsx
Normal file
154
apps/dashboard/src/app/(app)/create-organization.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { CreateClientSuccess } from '@/components/clients/create-client-success';
|
||||
import { LogoSquare } from '@/components/logo';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SaveIcon, WallpaperIcon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { api, handleError } from '../_trpc/client';
|
||||
|
||||
const validation = z
|
||||
.object({
|
||||
organization: z.string().min(3),
|
||||
project: z.string().min(3),
|
||||
cors: z.string().nullable(),
|
||||
tab: z.string(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.tab === 'other' || (data.tab === 'website' && data.cors !== ''),
|
||||
{
|
||||
message: 'Cors is required',
|
||||
path: ['cors'],
|
||||
}
|
||||
);
|
||||
|
||||
type IForm = z.infer<typeof validation>;
|
||||
|
||||
export function CreateOrganization() {
|
||||
const router = useRouter();
|
||||
const form = useForm<IForm>({
|
||||
resolver: zodResolver(validation),
|
||||
defaultValues: {
|
||||
organization: '',
|
||||
project: '',
|
||||
cors: '',
|
||||
tab: 'website',
|
||||
},
|
||||
});
|
||||
const mutation = api.onboarding.organziation.useMutation({
|
||||
onError: handleError,
|
||||
});
|
||||
const onSubmit: SubmitHandler<IForm> = (values) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
cors: values.tab === 'website' ? values.cors : null,
|
||||
});
|
||||
};
|
||||
|
||||
if (mutation.isSuccess && mutation.data.client) {
|
||||
return (
|
||||
<div className="card p-4 md:p-8">
|
||||
<LogoSquare className="w-20 mb-4" />
|
||||
<h1 className="font-medium text-3xl">Nice job!</h1>
|
||||
<div className="mb-4">
|
||||
You're ready to start using our SDK. Save the client ID and secret (if
|
||||
you have any)
|
||||
</div>
|
||||
<CreateClientSuccess {...mutation.data.client} />
|
||||
<div className="flex gap-4 mt-4">
|
||||
<a
|
||||
className={cn(buttonVariants({ variant: 'secondary' }), 'flex-1')}
|
||||
href="https://docs.openpanel.dev/docs"
|
||||
target="_blank"
|
||||
>
|
||||
Read docs
|
||||
</a>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => router.refresh()}
|
||||
icon={WallpaperIcon}
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4 md:p-8">
|
||||
<LogoSquare className="w-20 mb-4" />
|
||||
<h1 className="font-medium text-3xl">Welcome to Openpanel</h1>
|
||||
<div className="text-lg">
|
||||
Create your organization below (can be personal or a company) and your
|
||||
first project.
|
||||
</div>
|
||||
<form
|
||||
className="mt-8 flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label>Organization name *</Label>
|
||||
<Input
|
||||
placeholder="Organization name"
|
||||
error={form.formState.errors.organization?.message}
|
||||
{...form.register('organization')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Project name *</Label>
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
error={form.formState.errors.project?.message}
|
||||
{...form.register('project')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="website"
|
||||
onValueChange={(val) => form.setValue('tab', val)}
|
||||
className="h-28"
|
||||
>
|
||||
<TabsList className="bg-slate-200">
|
||||
<TabsTrigger value="website">Website</TabsTrigger>
|
||||
<TabsTrigger value="other">Other</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="website">
|
||||
<Label>Cors *</Label>
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
error={form.formState.errors.cors?.message}
|
||||
{...form.register('cors')}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="other">
|
||||
<div className="p-2 px-3 bg-white rounded text-sm">
|
||||
🔑 You will get a secret to use for your API requests.
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
icon={SaveIcon}
|
||||
loading={mutation.isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
apps/dashboard/src/app/(app)/page.tsx
Normal file
41
apps/dashboard/src/app/(app)/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// import { CreateOrganization } from '@clerk/nextjs';
|
||||
|
||||
import { LogoSquare } from '@/components/logo';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getCurrentOrganizations, isWaitlistUserAccepted } from '@openpanel/db';
|
||||
|
||||
import { CreateOrganization } from './create-organization';
|
||||
|
||||
export default async function Page() {
|
||||
const organizations = await getCurrentOrganizations();
|
||||
if (process.env.BLOCK) {
|
||||
const isAccepted = await isWaitlistUserAccepted();
|
||||
if (!isAccepted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="max-w-lg w-full">
|
||||
<LogoSquare className="w-20 md:w-28 mb-8" />
|
||||
<h1 className="font-medium text-3xl">Not quite there yet</h1>
|
||||
<div className="text-lg">
|
||||
We're still working on Openpanel, but we're not quite there yet.
|
||||
We'll let you know when we're ready to go!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (organizations.length > 0) {
|
||||
return redirect(`/${organizations[0]?.slug}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="max-w-lg w-full">
|
||||
<CreateOrganization />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx
Normal file
76
apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||
import { OverviewReportRange } from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import ServerLiveCounter from '@/components/overview/live-counter';
|
||||
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
|
||||
import OverviewMetrics from '@/components/overview/overview-metrics';
|
||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getOrganizationBySlug, getShareOverviewById } from '@openpanel/db';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { id } }: PageProps) {
|
||||
const share = await getShareOverviewById(id);
|
||||
if (!share) {
|
||||
return notFound();
|
||||
}
|
||||
if (!share.public) {
|
||||
return notFound();
|
||||
}
|
||||
const projectId = share.project_id;
|
||||
const organization = await getOrganizationBySlug(share.organization_slug);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div className="leading-none">
|
||||
<span className="text-white mb-4">{organization?.name}</span>
|
||||
<h1 className="text-white text-xl font-medium">
|
||||
{share.project?.name}
|
||||
</h1>
|
||||
</div>
|
||||
<a href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share">
|
||||
<Logo className="text-white max-sm:[&_span]:hidden" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="max-sm:-mx-3 bg-slate-100 rounded-lg shadow ring-8 ring-blue-600/50">
|
||||
<StickyBelowHeader>
|
||||
<div className="p-4 flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewReportRange />
|
||||
{/* <OverviewFiltersDrawer projectId={projectId} mode="events" /> */}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ServerLiveCounter projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<OverviewFiltersButtons />
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4 grid gap-4 grid-cols-6">
|
||||
<div className="col-span-6">
|
||||
<OverviewLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
<OverviewTopEvents projectId={projectId} />
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/dashboard/src/app/_trpc/client.tsx
Normal file
39
apps/dashboard/src/app/_trpc/client.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { AppRouter } from '@/server/api/root';
|
||||
import type { TRPCClientErrorBase } from '@trpc/react-query';
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||
import type { ExternalToast } from 'sonner';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const api = createTRPCReact<AppRouter>({});
|
||||
|
||||
/**
|
||||
* Inference helper for inputs.
|
||||
*
|
||||
* @example type HelloInput = RouterInputs['example']['hello']
|
||||
*/
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
|
||||
/**
|
||||
* Inference helper for outputs.
|
||||
*
|
||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||
*/
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
export type IChartData = RouterOutputs['chart']['chart'];
|
||||
export type IChartSerieDataItem = IChartData['series'][number]['data'][number];
|
||||
|
||||
export function handleError(error: TRPCClientErrorBase<any>) {
|
||||
toast('Error', {
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
export function handleErrorToastOptions(options: ExternalToast) {
|
||||
return function (error: TRPCClientErrorBase<any>) {
|
||||
toast('Error', {
|
||||
description: error.message,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
}
|
||||
25
apps/dashboard/src/app/api/trpc/[trpc]/route.ts
Normal file
25
apps/dashboard/src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { appRouter } from '@/server/api/root';
|
||||
import { getAuth } from '@clerk/nextjs/server';
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
|
||||
const handler = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req,
|
||||
router: appRouter,
|
||||
async createContext({ req }) {
|
||||
const session = getAuth(req as any);
|
||||
return {
|
||||
session,
|
||||
};
|
||||
},
|
||||
onError(opts) {
|
||||
const { error, type, path, input, ctx, req } = opts;
|
||||
console.error('---- TRPC ERROR');
|
||||
console.error('Error:', error);
|
||||
console.error('Context:', ctx);
|
||||
console.error();
|
||||
},
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
87
apps/dashboard/src/app/auth.tsx
Normal file
87
apps/dashboard/src/app/auth.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { KeySquareIcon } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
export default function Auth() {
|
||||
const form = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
});
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [state, setState] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="flex items-center justify-center flex-col h-screen p-4">
|
||||
<Widget className="max-w-md w-full mb-4">
|
||||
<WidgetBody>
|
||||
<div className="flex justify-center py-8">
|
||||
<Logo />
|
||||
</div>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
const res = await signIn('credentials', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
redirect: false,
|
||||
}).catch(() => {
|
||||
setState('Something went wrong. Please try again later');
|
||||
});
|
||||
|
||||
if (res?.ok) {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (res?.status === 401) {
|
||||
setState('Wrong email or password. Please try again');
|
||||
}
|
||||
})}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<InputWithLabel
|
||||
label="Email"
|
||||
placeholder="Your email"
|
||||
error={form.formState.errors.email?.message}
|
||||
{...form.register('email')}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Password"
|
||||
placeholder="...and your password"
|
||||
error={form.formState.errors.password?.message}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{state !== null && (
|
||||
<Alert variant="destructive">
|
||||
<KeySquareIcon className="h-4 w-4" />
|
||||
<AlertTitle>Failed</AlertTitle>
|
||||
<AlertDescription>{state}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="submit">Sign in</Button>
|
||||
<Link href="/register" className="text-center text-sm">
|
||||
No account?{' '}
|
||||
<span className="font-medium text-blue-600">Sign up here!</span>
|
||||
</Link>
|
||||
</form>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<p className="text-xs">Terms & conditions</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/dashboard/src/app/layout.tsx
Normal file
33
apps/dashboard/src/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import Providers from './providers';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
import '/node_modules/flag-icons/css/flag-icons.min.css';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Overview - Openpanel.dev',
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="light">
|
||||
<body
|
||||
className={cn('min-h-screen font-sans antialiased grainy bg-slate-100')}
|
||||
>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
20
apps/dashboard/src/app/manifest.ts
Normal file
20
apps/dashboard/src/app/manifest.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Openpanel.dev',
|
||||
short_name: 'Openpanel.dev',
|
||||
description: '',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#fff',
|
||||
icons: [
|
||||
{
|
||||
src: 'https://openpanel.dev/favicon.ico',
|
||||
sizes: 'any',
|
||||
type: 'image/x-icon',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
71
apps/dashboard/src/app/providers.tsx
Normal file
71
apps/dashboard/src/app/providers.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { ModalProvider } from '@/modals';
|
||||
import type { AppStore } from '@/redux';
|
||||
import makeStore from '@/redux';
|
||||
import { ClerkProvider, useAuth } from '@clerk/nextjs';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpLink } from '@trpc/client';
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { Toaster } from 'sonner';
|
||||
import superjson from 'superjson';
|
||||
|
||||
function AllProviders({ children }: { children: React.ReactNode }) {
|
||||
const { getToken } = useAuth();
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
networkMode: 'always',
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const [trpcClient] = useState(() =>
|
||||
api.createClient({
|
||||
transformer: superjson,
|
||||
links: [
|
||||
httpLink({
|
||||
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/api/trpc`,
|
||||
async headers() {
|
||||
return { Authorization: `Bearer ${await getToken()}` };
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const storeRef = useRef<AppStore>();
|
||||
if (!storeRef.current) {
|
||||
// Create the store instance the first time this renders
|
||||
storeRef.current = makeStore();
|
||||
}
|
||||
|
||||
return (
|
||||
<ReduxProvider store={storeRef.current}>
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{children}
|
||||
<Toaster />
|
||||
<ModalProvider />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</api.Provider>
|
||||
</ReduxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<AllProviders>{children}</AllProviders>
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user