feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,249 @@
import {
HeadContent,
Scripts,
createRootRouteWithContext,
} from '@tanstack/react-router';
import 'flag-icons/css/flag-icons.min.css';
import 'katex/dist/katex.min.css';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import appCss from '../styles.css?url';
import type { QueryClient } from '@tanstack/react-query';
import { FullPageErrorState } from '@/components/full-page-error-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { Providers } from '@/components/providers';
import { ThemeScriptOnce } from '@/components/theme-provider';
import { LinkButton } from '@/components/ui/button';
import { useSessionExtension } from '@/hooks/use-session-extension';
import { op } from '@/utils/op';
import type { AppRouter } from '@openpanel/trpc';
import type { TRPCOptionsProxy } from '@trpc/tanstack-react-query';
op.init();
interface MyRouterContext {
queryClient: QueryClient;
trpc: TRPCOptionsProxy<AppRouter>;
apiUrl: string;
dashboardUrl: string;
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData(
context.trpc.auth.session.queryOptions(undefined, {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
}),
);
return { session };
},
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
],
title: 'OpenPanel.dev',
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
errorComponent: ({ error }) => (
<FullPageErrorState
title={'Something went wrong'}
description={error.message}
>
<LinkButton href="/">Go back to home</LinkButton>
</FullPageErrorState>
),
pendingComponent: FullPageLoadingState,
});
function RootDocument({ children }: { children: React.ReactNode }) {
useSessionExtension();
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
<script
dangerouslySetInnerHTML={{
__html: `window.$ujq=window.$ujq||[];window.uj=window.uj||new Proxy({},{get:(_,p)=>(...a)=>window.$ujq.push([p,...a])});document.head.appendChild(Object.assign(document.createElement('script'),{src:'https://cdn.userjot.com/sdk/v2/uj.js',type:'module',async:!0}));`,
}}
/>
<script
dangerouslySetInnerHTML={{
__html: `window.uj.init('cm6thlmwr03xr13jghznx87gk', { widget: true, trigger: 'custom' });`,
}}
/>
</head>
<body className="grainy min-h-screen bg-def-100 font-sans text-base antialiased leading-normal">
<Providers>{children}</Providers>
<ThemeScriptOnce />
<Scripts />
<div className="hidden">
<div className="text-chart-0 bg-chart-0" />
<div className="text-chart-1 bg-chart-1" />
<div className="text-chart-2 bg-chart-2" />
<div className="text-chart-3 bg-chart-3" />
<div className="text-chart-4 bg-chart-4" />
<div className="text-chart-5 bg-chart-5" />
<div className="text-chart-6 bg-chart-6" />
<div className="text-chart-7 bg-chart-7" />
<div className="text-chart-8 bg-chart-8" />
<div className="text-chart-9 bg-chart-9" />
<div className="text-chart-10 bg-chart-10" />
<div className="text-chart-11 bg-chart-11" />
<div className="text-rose-50 bg-rose-50 hover:bg-rose-50 border-rose-50 dark:bg-rose-50 dark:hover:bg-rose-50" />
<div className="text-rose-100 bg-rose-100 hover:bg-rose-100 border-rose-100 dark:bg-rose-100 dark:hover:bg-rose-100" />
<div className="text-rose-200 bg-rose-200 hover:bg-rose-200 border-rose-200 dark:bg-rose-200 dark:hover:bg-rose-200" />
<div className="text-rose-700 bg-rose-700 hover:bg-rose-700 border-rose-700 dark:bg-rose-700 dark:hover:bg-rose-700" />
<div className="text-rose-800 bg-rose-800 hover:bg-rose-800 border-rose-800 dark:bg-rose-800 dark:hover:bg-rose-800" />
<div className="text-rose-900 bg-rose-900 hover:bg-rose-900 border-rose-900 dark:bg-rose-900 dark:hover:bg-rose-900" />
<div className="text-pink-50 bg-pink-50 hover:bg-pink-50 border-pink-50 dark:bg-pink-50 dark:hover:bg-pink-50" />
<div className="text-pink-100 bg-pink-100 hover:bg-pink-100 border-pink-100 dark:bg-pink-100 dark:hover:bg-pink-100" />
<div className="text-pink-200 bg-pink-200 hover:bg-pink-200 border-pink-200 dark:bg-pink-200 dark:hover:bg-pink-200" />
<div className="text-pink-700 bg-pink-700 hover:bg-pink-700 border-pink-700 dark:bg-pink-700 dark:hover:bg-pink-700" />
<div className="text-pink-800 bg-pink-800 hover:bg-pink-800 border-pink-800 dark:bg-pink-800 dark:hover:bg-pink-800" />
<div className="text-pink-900 bg-pink-900 hover:bg-pink-900 border-pink-900 dark:bg-pink-900 dark:hover:bg-pink-900" />
<div className="text-fuchsia-50 bg-fuchsia-50 hover:bg-fuchsia-50 border-fuchsia-50 dark:bg-fuchsia-50 dark:hover:bg-fuchsia-50" />
<div className="text-fuchsia-100 bg-fuchsia-100 hover:bg-fuchsia-100 border-fuchsia-100 dark:bg-fuchsia-100 dark:hover:bg-fuchsia-100" />
<div className="text-fuchsia-200 bg-fuchsia-200 hover:bg-fuchsia-200 border-fuchsia-200 dark:bg-fuchsia-200 dark:hover:bg-fuchsia-200" />
<div className="text-fuchsia-700 bg-fuchsia-700 hover:bg-fuchsia-700 border-fuchsia-700 dark:bg-fuchsia-700 dark:hover:bg-fuchsia-700" />
<div className="text-fuchsia-800 bg-fuchsia-800 hover:bg-fuchsia-800 border-fuchsia-800 dark:bg-fuchsia-800 dark:hover:bg-fuchsia-800" />
<div className="text-fuchsia-900 bg-fuchsia-900 hover:bg-fuchsia-900 border-fuchsia-900 dark:bg-fuchsia-900 dark:hover:bg-fuchsia-900" />
<div className="text-purple-50 bg-purple-50 hover:bg-purple-50 border-purple-50 dark:bg-purple-50 dark:hover:bg-purple-50" />
<div className="text-purple-100 bg-purple-100 hover:bg-purple-100 border-purple-100 dark:bg-purple-100 dark:hover:bg-purple-100" />
<div className="text-purple-200 bg-purple-200 hover:bg-purple-200 border-purple-200 dark:bg-purple-200 dark:hover:bg-purple-200" />
<div className="text-purple-700 bg-purple-700 hover:bg-purple-700 border-purple-700 dark:bg-purple-700 dark:hover:bg-purple-700" />
<div className="text-purple-800 bg-purple-800 hover:bg-purple-800 border-purple-800 dark:bg-purple-800 dark:hover:bg-purple-800" />
<div className="text-purple-900 bg-purple-900 hover:bg-purple-900 border-purple-900 dark:bg-purple-900 dark:hover:bg-purple-900" />
<div className="text-violet-50 bg-violet-50 hover:bg-violet-50 border-violet-50 dark:bg-violet-50 dark:hover:bg-violet-50" />
<div className="text-violet-100 bg-violet-100 hover:bg-violet-100 border-violet-100 dark:bg-violet-100 dark:hover:bg-violet-100" />
<div className="text-violet-200 bg-violet-200 hover:bg-violet-200 border-violet-200 dark:bg-violet-200 dark:hover:bg-violet-200" />
<div className="text-violet-700 bg-violet-700 hover:bg-violet-700 border-violet-700 dark:bg-violet-700 dark:hover:bg-violet-700" />
<div className="text-violet-800 bg-violet-800 hover:bg-violet-800 border-violet-800 dark:bg-violet-800 dark:hover:bg-violet-800" />
<div className="text-violet-900 bg-violet-900 hover:bg-violet-900 border-violet-900 dark:bg-violet-900 dark:hover:bg-violet-900" />
<div className="text-indigo-50 bg-indigo-50 hover:bg-indigo-50 border-indigo-50 dark:bg-indigo-50 dark:hover:bg-indigo-50" />
<div className="text-indigo-100 bg-indigo-100 hover:bg-indigo-100 border-indigo-100 dark:bg-indigo-100 dark:hover:bg-indigo-100" />
<div className="text-indigo-200 bg-indigo-200 hover:bg-indigo-200 border-indigo-200 dark:bg-indigo-200 dark:hover:bg-indigo-200" />
<div className="text-indigo-700 bg-indigo-700 hover:bg-indigo-700 border-indigo-700 dark:bg-indigo-700 dark:hover:bg-indigo-700" />
<div className="text-indigo-800 bg-indigo-800 hover:bg-indigo-800 border-indigo-800 dark:bg-indigo-800 dark:hover:bg-indigo-800" />
<div className="text-indigo-900 bg-indigo-900 hover:bg-indigo-900 border-indigo-900 dark:bg-indigo-900 dark:hover:bg-indigo-900" />
<div className="text-blue-50 bg-blue-50 hover:bg-blue-50 border-blue-50 dark:bg-blue-50 dark:hover:bg-blue-50" />
<div className="text-blue-100 bg-blue-100 hover:bg-blue-100 border-blue-100 dark:bg-blue-100 dark:hover:bg-blue-100" />
<div className="text-blue-200 bg-blue-200 hover:bg-blue-200 border-blue-200 dark:bg-blue-200 dark:hover:bg-blue-200" />
<div className="text-blue-700 bg-blue-700 hover:bg-blue-700 border-blue-700 dark:bg-blue-700 dark:hover:bg-blue-700" />
<div className="text-blue-800 bg-blue-800 hover:bg-blue-800 border-blue-800 dark:bg-blue-800 dark:hover:bg-blue-800" />
<div className="text-blue-900 bg-blue-900 hover:bg-blue-900 border-blue-900 dark:bg-blue-900 dark:hover:bg-blue-900" />
<div className="text-sky-50 bg-sky-50 hover:bg-sky-50 border-sky-50 dark:bg-sky-50 dark:hover:bg-sky-50" />
<div className="text-sky-100 bg-sky-100 hover:bg-sky-100 border-sky-100 dark:bg-sky-100 dark:hover:bg-sky-100" />
<div className="text-sky-200 bg-sky-200 hover:bg-sky-200 border-sky-200 dark:bg-sky-200 dark:hover:bg-sky-200" />
<div className="text-sky-700 bg-sky-700 hover:bg-sky-700 border-sky-700 dark:bg-sky-700 dark:hover:bg-sky-700" />
<div className="text-sky-800 bg-sky-800 hover:bg-sky-800 border-sky-800 dark:bg-sky-800 dark:hover:bg-sky-800" />
<div className="text-sky-900 bg-sky-900 hover:bg-sky-900 border-sky-900 dark:bg-sky-900 dark:hover:bg-sky-900" />
<div className="text-cyan-50 bg-cyan-50 hover:bg-cyan-50 border-cyan-50 dark:bg-cyan-50 dark:hover:bg-cyan-50" />
<div className="text-cyan-100 bg-cyan-100 hover:bg-cyan-100 border-cyan-100 dark:bg-cyan-100 dark:hover:bg-cyan-100" />
<div className="text-cyan-200 bg-cyan-200 hover:bg-cyan-200 border-cyan-200 dark:bg-cyan-200 dark:hover:bg-cyan-200" />
<div className="text-cyan-700 bg-cyan-700 hover:bg-cyan-700 border-cyan-700 dark:bg-cyan-700 dark:hover:bg-cyan-700" />
<div className="text-cyan-800 bg-cyan-800 hover:bg-cyan-800 border-cyan-800 dark:bg-cyan-800 dark:hover:bg-cyan-800" />
<div className="text-cyan-900 bg-cyan-900 hover:bg-cyan-900 border-cyan-900 dark:bg-cyan-900 dark:hover:bg-cyan-900" />
<div className="text-teal-50 bg-teal-50 hover:bg-teal-50 border-teal-50 dark:bg-teal-50 dark:hover:bg-teal-50" />
<div className="text-teal-100 bg-teal-100 hover:bg-teal-100 border-teal-100 dark:bg-teal-100 dark:hover:bg-teal-100" />
<div className="text-teal-200 bg-teal-200 hover:bg-teal-200 border-teal-200 dark:bg-teal-200 dark:hover:bg-teal-200" />
<div className="text-teal-700 bg-teal-700 hover:bg-teal-700 border-teal-700 dark:bg-teal-700 dark:hover:bg-teal-700" />
<div className="text-teal-800 bg-teal-800 hover:bg-teal-800 border-teal-800 dark:bg-teal-800 dark:hover:bg-teal-800" />
<div className="text-teal-900 bg-teal-900 hover:bg-teal-900 border-teal-900 dark:bg-teal-900 dark:hover:bg-teal-900" />
<div className="text-emerald-50 bg-emerald-50 hover:bg-emerald-50 border-emerald-50 dark:bg-emerald-50 dark:hover:bg-emerald-50" />
<div className="text-emerald-100 bg-emerald-100 hover:bg-emerald-100 border-emerald-100 dark:bg-emerald-100 dark:hover:bg-emerald-100" />
<div className="text-emerald-200 bg-emerald-200 hover:bg-emerald-200 border-emerald-200 dark:bg-emerald-200 dark:hover:bg-emerald-200" />
<div className="text-emerald-700 bg-emerald-700 hover:bg-emerald-700 border-emerald-700 dark:bg-emerald-700 dark:hover:bg-emerald-700" />
<div className="text-emerald-800 bg-emerald-800 hover:bg-emerald-800 border-emerald-800 dark:bg-emerald-800 dark:hover:bg-emerald-800" />
<div className="text-emerald-900 bg-emerald-900 hover:bg-emerald-900 border-emerald-900 dark:bg-emerald-900 dark:hover:bg-emerald-900" />
<div className="text-green-50 bg-green-50 hover:bg-green-50 border-green-50 dark:bg-green-50 dark:hover:bg-green-50" />
<div className="text-green-100 bg-green-100 hover:bg-green-100 border-green-100 dark:bg-green-100 dark:hover:bg-green-100" />
<div className="text-green-200 bg-green-200 hover:bg-green-200 border-green-200 dark:bg-green-200 dark:hover:bg-green-200" />
<div className="text-green-700 bg-green-700 hover:bg-green-700 border-green-700 dark:bg-green-700 dark:hover:bg-green-700" />
<div className="text-green-800 bg-green-800 hover:bg-green-800 border-green-800 dark:bg-green-800 dark:hover:bg-green-800" />
<div className="text-green-900 bg-green-900 hover:bg-green-900 border-green-900 dark:bg-green-900 dark:hover:bg-green-900" />
<div className="text-lime-50 bg-lime-50 hover:bg-lime-50 border-lime-50 dark:bg-lime-50 dark:hover:bg-lime-50" />
<div className="text-lime-100 bg-lime-100 hover:bg-lime-100 border-lime-100 dark:bg-lime-100 dark:hover:bg-lime-100" />
<div className="text-lime-200 bg-lime-200 hover:bg-lime-200 border-lime-200 dark:bg-lime-200 dark:hover:bg-lime-200" />
<div className="text-lime-700 bg-lime-700 hover:bg-lime-700 border-lime-700 dark:bg-lime-700 dark:hover:bg-lime-700" />
<div className="text-lime-800 bg-lime-800 hover:bg-lime-800 border-lime-800 dark:bg-lime-800 dark:hover:bg-lime-800" />
<div className="text-lime-900 bg-lime-900 hover:bg-lime-900 border-lime-900 dark:bg-lime-900 dark:hover:bg-lime-900" />
<div className="text-yellow-50 bg-yellow-50 hover:bg-yellow-50 border-yellow-50 dark:bg-yellow-50 dark:hover:bg-yellow-50" />
<div className="text-yellow-100 bg-yellow-100 hover:bg-yellow-100 border-yellow-100 dark:bg-yellow-100 dark:hover:bg-yellow-100" />
<div className="text-yellow-200 bg-yellow-200 hover:bg-yellow-200 border-yellow-200 dark:bg-yellow-200 dark:hover:bg-yellow-200" />
<div className="text-yellow-700 bg-yellow-700 hover:bg-yellow-700 border-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-700" />
<div className="text-yellow-800 bg-yellow-800 hover:bg-yellow-800 border-yellow-800 dark:bg-yellow-800 dark:hover:bg-yellow-800" />
<div className="text-yellow-900 bg-yellow-900 hover:bg-yellow-900 border-yellow-900 dark:bg-yellow-900 dark:hover:bg-yellow-900" />
<div className="text-amber-50 bg-amber-50 hover:bg-amber-50 border-amber-50 dark:bg-amber-50 dark:hover:bg-amber-50" />
<div className="text-amber-100 bg-amber-100 hover:bg-amber-100 border-amber-100 dark:bg-amber-100 dark:hover:bg-amber-100" />
<div className="text-amber-200 bg-amber-200 hover:bg-amber-200 border-amber-200 dark:bg-amber-200 dark:hover:bg-amber-200" />
<div className="text-amber-700 bg-amber-700 hover:bg-amber-700 border-amber-700 dark:bg-amber-700 dark:hover:bg-amber-700" />
<div className="text-amber-800 bg-amber-800 hover:bg-amber-800 border-amber-800 dark:bg-amber-800 dark:hover:bg-amber-800" />
<div className="text-amber-900 bg-amber-900 hover:bg-amber-900 border-amber-900 dark:bg-amber-900 dark:hover:bg-amber-900" />
<div className="text-orange-50 bg-orange-50 hover:bg-orange-50 border-orange-50 dark:bg-orange-50 dark:hover:bg-orange-50" />
<div className="text-orange-100 bg-orange-100 hover:bg-orange-100 border-orange-100 dark:bg-orange-100 dark:hover:bg-orange-100" />
<div className="text-orange-200 bg-orange-200 hover:bg-orange-200 border-orange-200 dark:bg-orange-200 dark:hover:bg-orange-200" />
<div className="text-orange-700 bg-orange-700 hover:bg-orange-700 border-orange-700 dark:bg-orange-700 dark:hover:bg-orange-700" />
<div className="text-orange-800 bg-orange-800 hover:bg-orange-800 border-orange-800 dark:bg-orange-800 dark:hover:bg-orange-800" />
<div className="text-orange-900 bg-orange-900 hover:bg-orange-900 border-orange-900 dark:bg-orange-900 dark:hover:bg-orange-900" />
<div className="text-red-50 bg-red-50 hover:bg-red-50 border-red-50 dark:bg-red-50 dark:hover:bg-red-50" />
<div className="text-red-100 bg-red-100 hover:bg-red-100 border-red-100 dark:bg-red-100 dark:hover:bg-red-100" />
<div className="text-red-200 bg-red-200 hover:bg-red-200 border-red-200 dark:bg-red-200 dark:hover:bg-red-200" />
<div className="text-red-700 bg-red-700 hover:bg-red-700 border-red-700 dark:bg-red-700 dark:hover:bg-red-700" />
<div className="text-red-800 bg-red-800 hover:bg-red-800 border-red-800 dark:bg-red-800 dark:hover:bg-red-800" />
<div className="text-red-900 bg-red-900 hover:bg-red-900 border-red-900 dark:bg-red-900 dark:hover:bg-red-900" />
<div className="text-stone-50 bg-stone-50 hover:bg-stone-50 border-stone-50 dark:bg-stone-50 dark:hover:bg-stone-50" />
<div className="text-stone-100 bg-stone-100 hover:bg-stone-100 border-stone-100 dark:bg-stone-100 dark:hover:bg-stone-100" />
<div className="text-stone-200 bg-stone-200 hover:bg-stone-200 border-stone-200 dark:bg-stone-200 dark:hover:bg-stone-200" />
<div className="text-stone-700 bg-stone-700 hover:bg-stone-700 border-stone-700 dark:bg-stone-700 dark:hover:bg-stone-700" />
<div className="text-stone-800 bg-stone-800 hover:bg-stone-800 border-stone-800 dark:bg-stone-800 dark:hover:bg-stone-800" />
<div className="text-stone-900 bg-stone-900 hover:bg-stone-900 border-stone-900 dark:bg-stone-900 dark:hover:bg-stone-900" />
<div className="text-neutral-50 bg-neutral-50 hover:bg-neutral-50 border-neutral-50 dark:bg-neutral-50 dark:hover:bg-neutral-50" />
<div className="text-neutral-100 bg-neutral-100 hover:bg-neutral-100 border-neutral-100 dark:bg-neutral-100 dark:hover:bg-neutral-100" />
<div className="text-neutral-200 bg-neutral-200 hover:bg-neutral-200 border-neutral-200 dark:bg-neutral-200 dark:hover:bg-neutral-200" />
<div className="text-neutral-700 bg-neutral-700 hover:bg-neutral-700 border-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-700" />
<div className="text-neutral-800 bg-neutral-800 hover:bg-neutral-800 border-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800" />
<div className="text-neutral-900 bg-neutral-900 hover:bg-neutral-900 border-neutral-900 dark:bg-neutral-900 dark:hover:bg-neutral-900" />
<div className="text-zinc-50 bg-zinc-50 hover:bg-zinc-50 border-zinc-50 dark:bg-zinc-50 dark:hover:bg-zinc-50" />
<div className="text-zinc-100 bg-zinc-100 hover:bg-zinc-100 border-zinc-100 dark:bg-zinc-100 dark:hover:bg-zinc-100" />
<div className="text-zinc-200 bg-zinc-200 hover:bg-zinc-200 border-zinc-200 dark:bg-zinc-200 dark:hover:bg-zinc-200" />
<div className="text-zinc-700 bg-zinc-700 hover:bg-zinc-700 border-zinc-700 dark:bg-zinc-700 dark:hover:bg-zinc-700" />
<div className="text-zinc-800 bg-zinc-800 hover:bg-zinc-800 border-zinc-800 dark:bg-zinc-800 dark:hover:bg-zinc-800" />
<div className="text-zinc-900 bg-zinc-900 hover:bg-zinc-900 border-zinc-900 dark:bg-zinc-900 dark:hover:bg-zinc-900" />
<div className="text-grey-50 bg-grey-50 hover:bg-grey-50 border-grey-50 dark:bg-grey-50 dark:hover:bg-grey-50" />
<div className="text-grey-100 bg-grey-100 hover:bg-grey-100 border-grey-100 dark:bg-grey-100 dark:hover:bg-grey-100" />
<div className="text-grey-200 bg-grey-200 hover:bg-grey-200 border-grey-200 dark:bg-grey-200 dark:hover:bg-grey-200" />
<div className="text-grey-700 bg-grey-700 hover:bg-grey-700 border-grey-700 dark:bg-grey-700 dark:hover:bg-grey-700" />
<div className="text-grey-800 bg-grey-800 hover:bg-grey-800 border-grey-800 dark:bg-grey-800 dark:hover:bg-grey-800" />
<div className="text-grey-900 bg-grey-900 hover:bg-grey-900 border-grey-900 dark:bg-grey-900 dark:hover:bg-grey-900" />
<div className="text-slate-50 bg-slate-50 hover:bg-slate-50 border-slate-50 dark:bg-slate-50 dark:hover:bg-slate-50" />
<div className="text-slate-100 bg-slate-100 hover:bg-slate-100 border-slate-100 dark:bg-slate-100 dark:hover:bg-slate-100" />
<div className="text-slate-200 bg-slate-200 hover:bg-slate-200 border-slate-200 dark:bg-slate-200 dark:hover:bg-slate-200" />
<div className="text-slate-700 bg-slate-700 hover:bg-slate-700 border-slate-700 dark:bg-slate-700 dark:hover:bg-slate-700" />
<div className="text-slate-800 bg-slate-800 hover:bg-slate-800 border-slate-800 dark:bg-slate-800 dark:hover:bg-slate-800" />
<div className="text-slate-900 bg-slate-900 hover:bg-slate-900 border-slate-900 dark:bg-slate-900 dark:hover:bg-slate-900" />
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,57 @@
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { LiveCounter } from '@/components/overview/live-counter';
import { OverviewInterval } from '@/components/overview/overview-interval';
import OverviewMetrics from '@/components/overview/overview-metrics';
import { OverviewRange } from '@/components/overview/overview-range';
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 { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/$organizationId/$projectId')({
component: ProjectDashboard,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.DASHBOARD),
},
],
};
},
});
function ProjectDashboard() {
const { projectId } = Route.useParams();
return (
<div>
<div className="col gap-2 p-4">
<div className="flex justify-between gap-2">
<div className="flex gap-2">
<OverviewRange />
<OverviewInterval />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
</div>
<div className="flex gap-2">
<LiveCounter projectId={projectId} />
<OverviewShare projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
</div>
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Chat from '@/components/chat/chat';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { keepPreviousData, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import type { UIMessage } from 'ai';
export const Route = createFileRoute('/_app/$organizationId/$projectId_/chat')({
component: Component,
pendingComponent: FullPageLoadingState,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.CHAT),
},
],
};
},
});
function Component() {
const { organizationId, projectId } = Route.useParams();
const trpc = useTRPC();
const { data } = useSuspenseQuery(
trpc.chat.get.queryOptions(
{
projectId,
},
{
placeholderData: keepPreviousData,
},
),
);
const { data: organization } = useSuspenseQuery(
trpc.organization.get.queryOptions({
organizationId,
}),
);
const messages = ((data?.messages as unknown as UIMessage[]) || []).slice(
-10,
);
return (
<Chat
projectId={projectId}
initialMessages={messages}
organization={organization}
/>
);
}

View File

@@ -0,0 +1,216 @@
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/use-app-params';
import { pushModal, showConfirm } from '@/modals';
import { cn } from '@/utils/cn';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { format } from 'date-fns';
import {
AreaChartIcon,
BarChart3Icon,
BarChartHorizontalIcon,
ChartScatterIcon,
ConeIcon,
Globe2Icon,
HashIcon,
LayoutPanelTopIcon,
LineChartIcon,
Pencil,
PieChartIcon,
PlusIcon,
Trash,
TrendingUpIcon,
} from 'lucide-react';
import { toast } from 'sonner';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link, createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/dashboards',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle('Dashboards'),
},
],
};
},
async loader({ context, params }) {
await context.queryClient.prefetchQuery(
context.trpc.dashboard.list.queryOptions({
projectId: params.projectId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const query = useQuery(
trpc.dashboard.list.queryOptions({
projectId,
}),
);
const dashboards = query.data ?? [];
const deletion = useMutation(
trpc.dashboard.delete.mutationOptions({
onError: (error, variables) => {
return handleErrorToastOptions({
action: {
label: 'Force delete',
onClick: () => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
},
},
})(error);
},
onSuccess() {
query.refetch();
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 (
<PageContainer>
<PageHeader
title="Dashboards"
description="Access all your dashboards here"
className="mb-8"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{dashboards.map((item) => {
const visibleReports = item.reports.slice(
0,
item.reports.length > 6 ? 5 : 6,
);
return (
<Card key={item.id} hover>
<div>
<Link
from={Route.fullPath}
to={`${item.id}`}
className="flex flex-col p-4 @container"
>
<div className="col gap-2">
<div className="font-medium">{item.name}</div>
<div className="text-sm text-muted-foreground">
{format(item.updatedAt, 'HH:mm · MMM d')}
</div>
</div>
<div
className={cn(
'mt-4 grid gap-2',
'grid-cols-1 @sm:grid-cols-2',
)}
>
{visibleReports.map((report) => {
const Icon = {
bar: BarChartHorizontalIcon,
linear: LineChartIcon,
pie: PieChartIcon,
metric: HashIcon,
map: Globe2Icon,
histogram: BarChart3Icon,
funnel: ConeIcon,
area: AreaChartIcon,
retention: ChartScatterIcon,
conversion: TrendingUpIcon,
}[report.chartType];
return (
<div
className="row items-center gap-2 rounded-md bg-def-200 p-4 py-2"
key={report.id}
>
<Icon size={24} />
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{report.name}
</div>
</div>
);
})}
{item.reports.length > 6 && (
<div className="row items-center gap-2 rounded-md bg-def-100 p-4 py-2">
<PlusIcon size={24} />
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{item.reports.length - 5} more
</div>
</div>
)}
</div>
{/* <span className="overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground">
<span className="mr-2 font-medium">
{item.reports.length} reports
</span>
{item.reports.map((item) => item.name).join(', ')}
</span> */}
</Link>
</div>
<CardActions>
<CardActionsItem className="w-full" asChild>
<button
type="button"
onClick={() => {
pushModal('EditDashboard', item);
}}
>
<Pencil size={16} />
Edit
</button>
</CardActionsItem>
<CardActionsItem className="w-full text-destructive" asChild>
<button
type="button"
onClick={() => {
showConfirm({
title: 'Delete dashboard',
text: 'Are you sure you want to delete this dashboard? All your reports will be deleted!',
onConfirm: () => deletion.mutate({ id: item.id }),
});
}}
>
<Trash size={16} />
Delete
</button>
</CardActionsItem>
</CardActions>
</Card>
);
})}
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,580 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportChart } from '@/components/report-chart';
import { Button, LinkButton } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { createProjectTitle } from '@/utils/title';
import {
ChevronRight,
LayoutPanelTopIcon,
MoreHorizontal,
PlusIcon,
RotateCcw,
Trash,
TrashIcon,
} from 'lucide-react';
import { toast } from 'sonner';
import { timeWindows } from '@openpanel/constants';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
// @ts-ignore - types will be installed separately
import { Responsive, WidthProvider } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { ReportChartLoading } from '@/components/report-chart/common/loading';
import { ReportChartProvider } from '@/components/report-chart/context';
import { showConfirm } from '@/modals';
const ResponsiveGridLayout = WidthProvider(Responsive);
type Layout = {
i: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
};
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/dashboards_/$dashboardId',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle('Dashboard'),
},
],
};
},
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.dashboard.byId.queryOptions({
id: params.dashboardId,
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.report.list.queryOptions({
dashboardId: params.dashboardId,
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.project.getProjectWithClients.queryOptions({
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.organization.get.queryOptions({
organizationId: params.organizationId,
}),
),
]);
},
pendingComponent: FullPageLoadingState,
});
// Report Skeleton Component
function ReportSkeleton() {
return (
<div className="card h-full flex flex-col animate-pulse">
<div className="flex items-center justify-between border-b border-border p-4">
<div className="flex-1">
<div className="h-5 w-32 bg-muted rounded mb-2" />
<div className="h-4 w-24 bg-muted/50 rounded" />
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-muted rounded" />
<div className="w-8 h-8 bg-muted rounded" />
</div>
</div>
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
</div>
);
}
// Report Item Component
function ReportItem({
report,
organizationId,
projectId,
range,
startDate,
endDate,
interval,
onDelete,
}: {
report: any;
organizationId: string;
projectId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
onDelete: (reportId: string) => void;
}) {
const router = useRouter();
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
<div
className="flex-1 cursor-pointer -m-4 p-4"
onClick={(event) => {
if (event.metaKey) {
window.open(
`/${organizationId}/${projectId}/reports/${report.id}`,
'_blank',
);
return;
}
router.navigate({
from: Route.fullPath,
to: '/$organizationId/$projectId/reports/$reportId',
params: {
reportId: report.id,
},
});
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
router.navigate({
from: Route.fullPath,
to: '/$organizationId/$projectId/reports/$reportId',
params: {
reportId: report.id,
},
});
}
}}
role="button"
tabIndex={0}
>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
className="opacity-30 hover:opacity-100"
>
<circle cx="4" cy="4" r="1.5" />
<circle cx="4" cy="8" r="1.5" />
<circle cx="4" cy="12" r="1.5" />
<circle cx="12" cy="4" r="1.5" />
<circle cx="12" cy="8" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
</svg>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation();
onDelete(report.id);
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={
{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
} as any
}
/>
</div>
</div>
);
}
function Component() {
const router = useRouter();
const { organizationId, dashboardId, projectId } = Route.useParams();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const dashboardQuery = useQuery(
trpc.dashboard.byId.queryOptions({
id: dashboardId,
projectId,
}),
);
const reportsQuery = useQuery(
trpc.report.list.queryOptions({
dashboardId,
projectId,
}),
);
const dashboardDeletion = useMutation(
trpc.dashboard.delete.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
toast('Dashboard deleted');
router.navigate({
to: '/$organizationId/$projectId/dashboards',
params: {
organizationId,
projectId,
},
});
},
}),
);
const reports = reportsQuery.data ?? [];
const dashboard = dashboardQuery.data;
const [isGridReady, setIsGridReady] = useState(false);
const [enableTransitions, setEnableTransitions] = useState(false);
// Wait for initial render to ensure grid has proper dimensions
useEffect(() => {
if (reports.length > 0 && !isGridReady) {
// Small delay to ensure container has rendered with proper width
const timer = setTimeout(() => {
setIsGridReady(true);
// Enable transitions after initial render
setTimeout(() => setEnableTransitions(true), 100);
}, 0);
return () => clearTimeout(timer);
}
}, [reports.length, isGridReady]);
const reportDeletion = useMutation(
trpc.report.delete.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
reportsQuery.refetch();
toast('Report deleted');
},
}),
);
const updateLayout = useMutation(
trpc.report.updateLayout.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
// Silently refetch reports (which includes layouts)
reportsQuery.refetch();
},
}),
);
const resetLayout = useMutation(
trpc.report.resetLayout.mutationOptions({
onError: handleErrorToastOptions({}),
onSuccess() {
toast('Layout reset to default');
reportsQuery.refetch();
},
}),
);
// Convert reports to grid layout format for all breakpoints
const layouts = useMemo(() => {
const baseLayout = reports.map((report, index) => ({
i: report.id,
x: report.layout?.x ?? (index % 2) * 6,
y: report.layout?.y ?? Math.floor(index / 2) * 4,
w: report.layout?.w ?? 6,
h: report.layout?.h ?? 4,
minW: 3,
minH: 3,
}));
// Create responsive layouts for different breakpoints
return {
lg: baseLayout,
md: baseLayout,
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
};
}, [reports]);
const handleLayoutChange = useCallback((newLayout: Layout[]) => {
// This is called during dragging/resizing, we'll save on drag/resize stop
}, []);
const handleDragStop = useCallback(
(newLayout: Layout[]) => {
// Save each changed layout after drag stops
newLayout.forEach((item) => {
const report = reports.find((r) => r.id === item.i);
if (report) {
const oldLayout = report.layout;
// Only update if layout actually changed
if (
!oldLayout ||
oldLayout.x !== item.x ||
oldLayout.y !== item.y ||
oldLayout.w !== item.w ||
oldLayout.h !== item.h
) {
updateLayout.mutate({
reportId: item.i,
layout: {
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: item.minW ?? 3,
minH: item.minH ?? 3,
},
});
}
}
});
},
[reports, updateLayout],
);
const handleResizeStop = useCallback(
(newLayout: Layout[]) => {
// Save each changed layout after resize stops
newLayout.forEach((item) => {
const report = reports.find((r) => r.id === item.i);
if (report) {
const oldLayout = report.layout;
// Only update if layout actually changed
if (
!oldLayout ||
oldLayout.x !== item.x ||
oldLayout.y !== item.y ||
oldLayout.w !== item.w ||
oldLayout.h !== item.h
) {
updateLayout.mutate({
reportId: item.i,
layout: {
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: item.minW ?? 3,
minH: item.minH ?? 3,
},
});
}
}
});
},
[reports, updateLayout],
);
if (!dashboard) {
return null; // Loading handled by suspense
}
return (
<PageContainer>
<div className="row mb-4 items-center justify-between">
<PageHeader
title={dashboard.name}
description="View and manage your reports"
className="mb-0"
/>
<div className="flex items-center justify-end gap-2">
<OverviewRange />
<OverviewInterval />
<LinkButton
from={Route.fullPath}
to={'/$organizationId/$projectId/reports'}
icon={PlusIcon}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</LinkButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
showConfirm({
title: 'Reset layout',
text: 'Are you sure you want to reset the layout to default? This will clear all custom positioning and sizing.',
onConfirm: () =>
resetLayout.mutate({ dashboardId, projectId }),
})
}
>
<RotateCcw className="mr-2 size-4" />
Reset layout
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() =>
showConfirm({
title: 'Delete dashboard',
text: 'Are you sure you want to delete this dashboard? All your reports will be deleted!',
onConfirm: () =>
dashboardDeletion.mutate({ id: dashboardId }),
})
}
>
<TrashIcon className="mr-2 size-4" />
Delete dashboard
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{reports.length === 0 ? (
<FullPageEmptyState title="No reports" icon={LayoutPanelTopIcon}>
<p>You can visualize your data with a report</p>
<LinkButton
from={Route.fullPath}
to={'/$organizationId/$projectId/reports'}
className="mt-14"
icon={PlusIcon}
>
Create report
</LinkButton>
</FullPageEmptyState>
) : !isGridReady || reportsQuery.isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ReportSkeleton />
<ReportSkeleton />
<ReportSkeleton />
<ReportSkeleton />
<ReportSkeleton />
<ReportSkeleton />
</div>
) : (
<div className="w-full overflow-hidden -mx-4">
<style>{`
.react-grid-item {
transition: ${enableTransitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
}
.react-grid-item.react-grid-placeholder {
background: none !important;
opacity: 0.5;
transition-duration: 100ms;
border-radius: 0.5rem;
border: 1px dashed var(--primary);
}
.react-grid-item.resizing {
transition: none !important;
}
`}</style>
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
onLayoutChange={handleLayoutChange}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
isDraggable={true}
isResizable={true}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
>
{reports.map((report) => (
<div key={report.id}>
<ReportItem
report={report}
organizationId={organizationId}
projectId={projectId}
range={range}
startDate={startDate}
endDate={endDate}
interval={interval}
onDelete={(reportId) => {
reportDeletion.mutate({ reportId });
}}
/>
</div>
))}
</ResponsiveGridLayout>
</div>
)}
</PageContainer>
);
}

View File

@@ -0,0 +1,35 @@
import { EventsTable } from '@/components/events/table';
import { useTRPC } from '@/integrations/trpc/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs/conversions',
)({
component: Component,
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const [startDate, setStartDate] = useQueryState(
'startDate',
parseAsIsoDateTime,
);
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
const query = useInfiniteQuery(
trpc.event.conversions.infiniteQueryOptions(
{
projectId,
startDate: startDate || undefined,
endDate: endDate || undefined,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
return <EventsTable query={query} />;
}

View File

@@ -0,0 +1,41 @@
import { EventsTable } from '@/components/events/table';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs/events',
)({
component: Component,
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const [filters] = useEventQueryFilters();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
filters,
events: eventNames,
profileId: '',
startDate: startDate || undefined,
endDate: endDate || undefined,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
return <EventsTable query={query} />;
}

View File

@@ -0,0 +1,17 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs/',
)({
component: Component,
beforeLoad({ params }) {
throw redirect({
to: '/$organizationId/$projectId/events/events',
params,
});
},
});
function Component() {
return null;
}

View File

@@ -0,0 +1,182 @@
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import type { IChartEvent } from '@openpanel/validation';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs/stats',
)({
component: Component,
});
function Component() {
const { projectId } = Route.useParams();
const [filters] = useEventQueryFilters();
const [events] = useEventQueryNamesFilter();
const fallback: IChartEvent[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
];
return (
<div>
<div className="mb-2 flex items-center gap-2">
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<Widget className="w-full">
<WidgetHead>
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody>
<ReportChartShortcut
projectId={projectId}
range="30d"
chartType="histogram"
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: fallback
}
/>
</WidgetBody>
</Widget>
<Widget className="w-full">
<WidgetHead>
<span className="title">Event distribution</span>
</WidgetHead>
<WidgetBody>
<ReportChartShortcut
projectId={projectId}
range="30d"
chartType="pie"
breakdowns={[
{
id: 'A',
name: 'name',
},
]}
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
]
}
/>
</WidgetBody>
</Widget>
<Widget className="w-full">
<WidgetHead>
<span className="title">Event distribution</span>
</WidgetHead>
<WidgetBody>
<ReportChartShortcut
projectId={projectId}
range="30d"
chartType="bar"
breakdowns={[
{
id: 'A',
name: 'name',
},
]}
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
]
}
/>
</WidgetBody>
</Widget>
<Widget className="w-full">
<WidgetHead>
<span className="title">Event distribution</span>
</WidgetHead>
<WidgetBody>
<ReportChartShortcut
projectId={projectId}
range="30d"
chartType="linear"
breakdowns={[
{
id: 'A',
name: 'name',
},
]}
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
]
}
/>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.EVENTS),
},
],
};
},
});
function Component() {
const router = useRouter();
const { activeTab, tabs } = usePageTabs([
{ id: 'events', label: 'Events' },
{ id: 'conversions', label: 'Conversions' },
{ id: 'stats', label: 'Stats' },
]);
const handleTabChange = (tabId: string) => {
console.log('tabId', tabId, tabs[0].id === tabId);
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
return (
<div className="container p-8">
<PageHeader
title="Events"
description="Paginate through your events, conversions and overall stats"
/>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/notifications/_tabs/',
)({
component: Component,
beforeLoad({ params }) {
throw redirect({
to: '/$organizationId/$projectId/notifications/notifications',
params,
});
},
});
function Component() {
return null;
}

View File

@@ -0,0 +1,31 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { NotificationsTable } from '@/components/notifications/table';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/notifications/_tabs/notifications',
)({
component: Component,
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.notification.list.queryOptions({
projectId: params.projectId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const query = useQuery(
trpc.notification.list.queryOptions({
projectId,
}),
);
return <NotificationsTable query={query} />;
}

View File

@@ -0,0 +1,99 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { IntegrationCardSkeleton } from '@/components/integrations/integration-card';
import { RuleCard } from '@/components/notifications/rule-card';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { PencilRulerIcon, PlusIcon } from 'lucide-react';
import { useMemo } from 'react';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/notifications/_tabs/rules',
)({
component: Component,
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.notification.rules.queryOptions({
projectId: params.projectId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const query = useQuery(
trpc.notification.rules.queryOptions({
projectId,
}),
);
const data = useMemo(() => {
return query.data || [];
}, [query.data]);
const isLoading = query.isLoading;
if (!isLoading && data.length === 0) {
return (
<FullPageEmptyState title="No rules yet" icon={PencilRulerIcon}>
<p>
You have not created any rules yet. Create a rule to start getting
notifications.
</p>
<Button
className="mt-8"
variant="outline"
onClick={() =>
pushModal('AddNotificationRule', {
rule: undefined,
})
}
>
Add Rule
</Button>
</FullPageEmptyState>
);
}
return (
<div>
<div className="mb-2">
<Button
icon={PlusIcon}
variant="outline"
onClick={() =>
pushModal('AddNotificationRule', {
rule: undefined,
})
}
>
Add Rule
</Button>
</div>
<div className="col gap-4 w-full grid md:grid-cols-2">
{isLoading && (
<>
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
</>
)}
<AnimatePresence mode="popLayout">
{data.map((item) => {
return (
<motion.div key={item.id} layout="position">
<RuleCard rule={item} />
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/notifications/_tabs',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.NOTIFICATIONS),
},
],
};
},
});
function Component() {
const router = useRouter();
const { activeTab, tabs } = usePageTabs([
{ id: 'notifications', label: 'Notifications' },
{ id: 'rules', label: 'Rules' },
]);
const handleTabChange = (tabId: string) => {
console.log('tabId', tabId, tabs[0].id === tabId);
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
return (
<div className="container p-8">
<PageHeader
title="Notifications"
description="See notifications and manage your rules when to get notifications"
/>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Pagination } from '@/components/pagination';
import { FloatingPagination } from '@/components/pagination-floating';
import { ReportChart } from '@/components/report-chart';
import { Skeleton } from '@/components/skeleton';
import { Input } from '@/components/ui/input';
import { TableButtons } from '@/components/ui/table';
import { useAppContext } from '@/hooks/use-app-context';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsInteger, useQueryState } from 'nuqs';
import { memo } from 'react';
export const Route = createFileRoute('/_app/$organizationId/$projectId_/pages')(
{
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.PAGES),
},
],
};
},
},
);
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const take = 20;
const { range, interval } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(1),
);
const { debouncedSearch, setSearch, search } = useSearchQueryState();
const query = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor,
take,
search: debouncedSearch,
range,
interval,
filters,
},
{
placeholderData: keepPreviousData,
},
),
);
const data = query.data ?? [];
return (
<PageContainer>
<PageHeader
title="Pages"
description="Access all your pages here"
className="mb-8"
/>
<TableButtons>
<OverviewRange />
<OverviewInterval />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
<Input
className="self-auto"
placeholder="Search path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);
setCursor(0);
}}
/>
</TableButtons>
{data.length === 0 && !query.isLoading && (
<FullPageEmptyState
title="No pages"
description={'Integrate our web sdk to your site to get pages here.'}
/>
)}
{query.isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<PageCardSkeleton />
<PageCardSkeleton />
<PageCardSkeleton />
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.map((page) => {
return (
<PageCard
key={page.path}
page={page}
range={range}
interval={interval}
projectId={projectId}
/>
);
})}
</div>
{data.length !== 0 && (
<div className="p-4">
<FloatingPagination
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
canNextPage={true}
canPreviousPage={cursor > 0}
pageIndex={cursor - 1}
nextPage={() => {
setCursor((p) => p + 1);
}}
previousPage={() => {
setCursor((p) => p - 1);
}}
/>
</div>
)}
</PageContainer>
);
}
const PageCard = memo(
({
page,
range,
interval,
projectId,
}: {
page: RouterOutputs['event']['pages'][number];
range: IChartRange;
interval: IInterval;
projectId: string;
}) => {
const number = useNumber();
const { apiUrl } = useAppContext();
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<div className="row gap-2 items-center h-16">
<img
src={`${apiUrl}/misc/og?url=${page.origin}${page.path}`}
alt={page.title}
className="size-10 rounded-sm object-cover"
loading="lazy"
decoding="async"
/>
<div className="col min-w-0">
<div className="font-medium leading-[28px] truncate">
{page.title}
</div>
<a
target="_blank"
rel="noreferrer"
href={`${page.origin}${page.path}`}
className="text-muted-foreground font-mono truncate hover:underline"
>
{page.path}
</a>
</div>
</div>
</div>
<div className="row border-y">
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.formatWithUnit(page.avg_duration, 'min')}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
duration
</div>
</div>
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.formatWithUnit(page.bounce_rate / 100, '%')}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
bounce rate
</div>
</div>
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.format(page.sessions)}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
sessions
</div>
</div>
</div>
<ReportChart
options={{
hideID: true,
hideXAxis: true,
hideYAxis: true,
aspectRatio: 0.15,
}}
report={{
lineType: 'linear',
breakdowns: [],
name: 'screen_view',
metric: 'sum',
range,
interval,
previous: true,
chartType: 'linear',
projectId,
events: [
{
id: 'A',
name: 'screen_view',
segment: 'event',
filters: [
{
id: 'path',
name: 'path',
value: [page.path],
operator: 'is',
},
{
id: 'origin',
name: 'origin',
value: [page.origin],
operator: 'is',
},
],
},
],
}}
/>
</div>
);
},
);
const PageCardSkeleton = memo(() => {
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<div className="row gap-2 items-center h-16">
<Skeleton className="size-10 rounded-sm" />
<div className="col min-w-0">
<Skeleton className="h-3 w-32 mb-2" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
<div className="row border-y">
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-16 mb-1" />
<Skeleton className="h-4 w-12" />
</div>
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-12 mb-1" />
<Skeleton className="h-4 w-16" />
</div>
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-14 mb-1" />
<Skeleton className="h-4 w-14" />
</div>
</div>
<div className="p-4">
<Skeleton className="h-16 w-full rounded" />
</div>
</div>
);
});

View File

@@ -0,0 +1,41 @@
import { EventsTable } from '@/components/events/table';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs/events',
)({
component: Component,
});
function Component() {
const { projectId, profileId } = Route.useParams();
const trpc = useTRPC();
const [filters] = useEventQueryFilters();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
profileId,
filters,
startDate: startDate || undefined,
endDate: endDate || undefined,
events: eventNames,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
return <EventsTable query={query} />;
}

View File

@@ -0,0 +1,140 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LatestEvents } from '@/components/profiles/latest-events';
import { MostEvents } from '@/components/profiles/most-events';
import { PopularRoutes } from '@/components/profiles/popular-routes';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { ProfileCharts } from '@/components/profiles/profile-charts';
import { ProfileMetrics } from '@/components/profiles/profile-metrics';
import { ProfileProperties } from '@/components/profiles/profile-properties';
import { useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs/',
)({
component: Component,
loader: async ({ context, params }) => {
// Prefetch all profile data
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.profile.metrics.queryOptions({
profileId: params.profileId,
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.profile.activity.queryOptions({
profileId: params.profileId,
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.profile.mostEvents.queryOptions({
profileId: params.profileId,
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.profile.popularRoutes.queryOptions({
profileId: params.profileId,
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.event.events.queryOptions({
profileId: params.profileId,
projectId: params.projectId,
}),
),
]);
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const { profileId, projectId, organizationId } = Route.useParams();
const trpc = useTRPC();
// Get profile data from parent route
const profile = useSuspenseQuery(
trpc.profile.byId.queryOptions({
profileId,
projectId,
}),
);
const metrics = useSuspenseQuery(
trpc.profile.metrics.queryOptions({
profileId,
projectId,
}),
);
const activity = useSuspenseQuery(
trpc.profile.activity.queryOptions({
profileId,
projectId,
}),
);
const mostEvents = useSuspenseQuery(
trpc.profile.mostEvents.queryOptions({
profileId,
projectId,
}),
);
const popularRoutes = useSuspenseQuery(
trpc.profile.popularRoutes.queryOptions({
profileId,
projectId,
}),
);
return (
<>
{/* Main content grid */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="col-span-1 md:col-span-2">
<ProfileMetrics data={metrics.data} />
</div>
{/* Profile properties - full width */}
<div className="col-span-1 md:col-span-2">
<ProfileProperties profile={profile.data!} />
</div>
{/* Heatmap / Activity */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Latest events */}
<div className="col-span-1">
<LatestEvents
profileId={profileId}
projectId={projectId}
organizationId={organizationId}
/>
</div>
{/* Most events */}
<div className="col-span-1">
<MostEvents data={mostEvents.data} />
</div>
{/* Popular routes */}
<div className="col-span-1">
<PopularRoutes data={popularRoutes.data} />
</div>
{/* Charts - spans both columns */}
<div className="col-span-1 md:col-span-2">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<ProfileCharts profileId={profileId} projectId={projectId} />
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,124 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { useState } from 'react';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs',
)({
component: Component,
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.profile.byId.queryOptions({
profileId: params.profileId,
projectId: params.projectId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const router = useRouter();
const { profileId, projectId } = Route.useParams();
const trpc = useTRPC();
const profile = useSuspenseQuery(
trpc.profile.byId.queryOptions({
profileId,
projectId,
}),
);
const { activeTab, tabs } = usePageTabs([
{
id: '/$organizationId/$projectId/profiles/$profileId',
label: 'Overview',
},
{ id: 'events', label: 'Events' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
return (
<PageContainer>
<PageHeader
title={
<div className="row items-center gap-4">
<ProfileAvatar {...profile.data} />
{getProfileName(profile.data, false)}
</div>
}
>
<div className="row gap-4 mb-6">
{profile.data?.properties.country && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.data.properties.country} />
<span>
{profile.data.properties.country}
{profile.data.properties.city &&
` / ${profile.data.properties.city}`}
</span>
</div>
)}
{profile.data?.properties.device && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.data.properties.device} />
<span className="capitalize">
{profile.data.properties.device}
</span>
</div>
)}
{profile.data?.properties.os && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.data.properties.os} />
<span>{profile.data.properties.os}</span>
</div>
)}
{profile.data?.properties.model && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.data.properties.model} />
<span>{profile.data.properties.model}</span>
</div>
)}
{profile.data?.properties.browser && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.data.properties.browser} />
<span>{profile.data.properties.browser}</span>
</div>
)}
</div>
</PageHeader>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageContainer>
);
}

View File

@@ -0,0 +1,45 @@
import { ProfilesTable } from '@/components/profiles/table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createEntityTitle } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs/anonymous',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createEntityTitle('Anonymous', PAGE_TITLES.PROFILES),
},
],
};
},
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { page } = useDataTablePagination();
const { debouncedSearch } = useSearchQueryState();
const query = useQuery(
trpc.profile.list.queryOptions(
{
cursor: (page - 1) * 50,
projectId,
take: 50,
search: debouncedSearch,
isExternal: false,
},
{
placeholderData: keepPreviousData,
},
),
);
return <ProfilesTable query={query} type="profiles" />;
}

View File

@@ -0,0 +1,47 @@
import { ProfilesTable } from '@/components/profiles/table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createEntityTitle } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs/identified',
)({
head: () => {
return {
meta: [
{
title: createEntityTitle('Identified', PAGE_TITLES.PROFILES),
},
],
};
},
component: Component,
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { page } = useDataTablePagination(50);
const { debouncedSearch } = useSearchQueryState();
const query = useQuery(
trpc.profile.list.queryOptions(
{
cursor: (page - 1) * 50,
projectId,
take: 50,
search: debouncedSearch,
isExternal: true,
},
{
placeholderData: keepPreviousData,
},
),
);
return <ProfilesTable type="profiles" query={query} />;
}

View File

@@ -0,0 +1,17 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs/',
)({
component: Component,
beforeLoad({ params }) {
throw redirect({
to: '/$organizationId/$projectId/profiles/identified',
params,
});
},
});
function Component() {
return null;
}

View File

@@ -0,0 +1,41 @@
import { ProfilesTable } from '@/components/profiles/table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createEntityTitle } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs/power-users',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createEntityTitle('Power Users', PAGE_TITLES.PROFILES),
},
],
};
},
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { page } = useDataTablePagination();
const query = useQuery(
trpc.profile.powerUsers.queryOptions(
{
cursor: (page - 1) * 50,
projectId,
take: 50,
},
{
placeholderData: keepPreviousData,
},
),
);
return <ProfilesTable query={query} type="power-users" />;
}

View File

@@ -0,0 +1,62 @@
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.PROFILES),
},
],
};
},
});
function Component() {
const router = useRouter();
const { activeTab, tabs } = usePageTabs([
{ id: 'identified', label: 'Identified' },
{ id: 'anonymous', label: 'Anonymous' },
{ id: 'power-users', label: 'Power users' },
]);
const handleTabChange = (tabId: string) => {
console.log('tabId', tabId, tabs[0].id === tabId);
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
return (
<div className="container p-8">
<PageHeader
title="Profiles"
description="If you haven't called identify your profiles will be anonymous"
/>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { Fullscreen, FullscreenClose } from '@/components/fullscreen-toggle';
import RealtimeMap from '@/components/realtime/map';
import { RealtimeActiveSessions } from '@/components/realtime/realtime-active-sessions';
import { RealtimeGeo } from '@/components/realtime/realtime-geo';
import { RealtimeLiveHistogram } from '@/components/realtime/realtime-live-histogram';
import { RealtimePaths } from '@/components/realtime/realtime-paths';
import { RealtimeReferrals } from '@/components/realtime/realtime-referrals';
import RealtimeReloader from '@/components/realtime/realtime-reloader';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/realtime',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.REALTIME),
},
],
};
},
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const coordinatesQuery = useQuery(
trpc.realtime.coordinates.queryOptions(
{
projectId,
},
{
placeholderData: keepPreviousData,
},
),
);
return (
<>
<Fullscreen>
<FullscreenClose />
<RealtimeReloader projectId={projectId} />
<div className="row relative">
<div className="overflow-hidden aspect-[4/2] w-full">
<RealtimeMap
markers={coordinatesQuery.data ?? []}
sidebarConfig={{
width: 280, // w-96 = 384px
position: 'left',
}}
/>
</div>
<div className="absolute top-8 left-8 bottom-0 col gap-4">
<div className="card p-4 w-72 bg-background/90">
<RealtimeLiveHistogram projectId={projectId} />
</div>
<div className="w-72 flex-1 min-h-0 relative">
<RealtimeActiveSessions projectId={projectId} />
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-def-100 to-transparent" />
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-4 p-8 pt-0">
<div>
<RealtimeGeo projectId={projectId} />
</div>
<div>
<RealtimeReferrals projectId={projectId} />
</div>
<div>
<RealtimePaths projectId={projectId} />
</div>
</div>
</Fullscreen>
</>
);
}

View File

@@ -0,0 +1,188 @@
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/ui/data-table/data-table';
import {
createActionColumn,
createHeaderColumn,
} from '@/components/ui/data-table/data-table-helpers';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table';
import {
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
// import { Input } from '@/components/ui/input';
// import { TableButtons } from '@/components/ui/table';
// import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import { formatDate, formatDateTime } from '@/utils/date';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import type { IServiceReference } from '@openpanel/db';
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import type { ColumnDef } from '@tanstack/react-table';
import { PlusIcon } from 'lucide-react';
import { toast } from 'sonner';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/references',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.REFERENCES),
},
],
};
},
});
export const columnDefs: ColumnDef<IServiceReference>[] = [
{
accessorKey: 'title',
header: createHeaderColumn('Title'),
cell: ({ row }) => {
return (
<div>
<div className="font-medium">{row.original.title}</div>
{!!row.original.description && (
<div className="text-muted-foreground break-words whitespace-normal">
{row.original.description}
</div>
)}
</div>
);
},
meta: {
variant: 'text',
placeholder: 'Search',
label: 'Title',
},
},
{
accessorKey: 'date',
header: createHeaderColumn('Occurrence'),
cell({ row }) {
const date = row.original.date;
return formatDateTime(date);
},
filterFn: 'isWithinRange',
sortingFn: 'datetime',
meta: {
variant: 'dateRange',
placeholder: 'Occurrence',
label: 'Occurrence',
hidden: true,
},
},
{
accessorKey: 'createdAt',
header: createHeaderColumn('Created at'),
cell({ row }) {
const date = row.original.createdAt;
return formatDate(date);
},
filterFn: 'isWithinRange',
sortingFn: 'datetime',
meta: {
variant: 'dateRange',
placeholder: 'Created at',
label: 'Created at',
},
},
createActionColumn(({ row }) => {
const trpc = useTRPC();
const queryClient = useQueryClient();
const deletion = useMutation(
trpc.reference.delete.mutationOptions({
onSuccess() {
toast.success('Reference deleted');
queryClient.invalidateQueries(trpc.reference.pathFilter());
},
}),
);
const ref = row.original;
return (
<>
<DropdownMenuItem
onClick={() =>
pushModal('EditReference', {
id: ref.id,
title: ref.title,
description: ref.description,
date: ref.date,
})
}
>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() =>
showConfirm({
title: 'Delete reference',
text: 'Are you sure you want to delete this reference? This action cannot be undone.',
onConfirm() {
deletion.mutate({
id: ref.id,
});
},
})
}
>
Delete
</DropdownMenuItem>
</>
);
}),
];
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const query = useQuery(
trpc.reference.getReferences.queryOptions(
{
projectId,
},
{
placeholderData: keepPreviousData,
},
),
);
const data = query.data ?? [];
const { table, loading } = useTable({
columns: columnDefs,
data,
pageSize: 30,
loading: query.isLoading,
});
return (
<PageContainer>
<PageHeader
title="References"
description="References is a good way to keep track of important events. They will show up in your reports."
className="mb-8"
/>
<DataTableToolbar table={table}>
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
<span className="max-sm:hidden">Create reference</span>
<span className="sm:hidden">Reference</span>
</Button>
</DataTableToolbar>
<DataTable table={table} loading={loading} />
</PageContainer>
);
}

View File

@@ -0,0 +1,26 @@
import ReportEditor from '@/components/report-chart/report-editor';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/reports',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.REPORTS),
},
],
};
},
validateSearch: z.object({
dashboardId: z.string().optional(),
}),
});
function Component() {
return <ReportEditor report={null} />;
}

View File

@@ -0,0 +1,40 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import ReportEditor from '@/components/report-chart/report-editor';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/reports_/$reportId',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle('Report'),
},
],
};
},
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.report.get.queryOptions({
reportId: params.reportId,
}),
);
},
validateSearch: z.object({
dashboardId: z.string().optional(),
}),
pendingComponent: FullPageLoadingState,
});
function Component() {
const { reportId } = Route.useParams();
const trpc = useTRPC();
const query = useSuspenseQuery(trpc.report.get.queryOptions({ reportId }));
return <ReportEditor report={query.data} />;
}

View File

@@ -0,0 +1,58 @@
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { SessionsTable } from '@/components/sessions/table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/sessions',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.SESSIONS),
},
],
};
},
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const query = useInfiniteQuery(
trpc.session.list.infiniteQueryOptions(
{
projectId,
take: 50,
search: debouncedSearch,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
return (
<PageContainer>
<PageHeader
title="Sessions"
description="Access all your sessions here"
className="mb-8"
/>
<SessionsTable query={query} />
</PageContainer>
);
}

View File

@@ -0,0 +1,124 @@
import { EventsTable } from '@/components/events/table';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/sessions_/$sessionId',
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.session.byId.queryOptions({
sessionId: params.sessionId,
projectId: params.projectId,
}),
),
]);
},
head: () => {
return {
meta: [
{
title: createProjectTitle('Sessions'),
},
],
};
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const { projectId, sessionId } = Route.useParams();
const trpc = useTRPC();
const LIMIT = 50;
const { page } = useDataTablePagination(LIMIT);
const { debouncedSearch } = useSearchQueryState();
const { data: session } = useSuspenseQuery(
trpc.session.byId.queryOptions({
sessionId,
projectId,
}),
);
const [filters] = useEventQueryFilters();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
sessionId,
filters,
events: eventNames,
startDate: startDate || undefined,
endDate: endDate || undefined,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
return (
<PageContainer>
<PageHeader
title={`Session: ${session.id.slice(0, 4)}...${session.id.slice(-4)}`}
>
<div className="row gap-4 mb-6">
{session.country && (
<div className="row gap-2 items-center">
<SerieIcon name={session.country} />
<span>
{session.country}
{session.city && ` / ${session.city}`}
</span>
</div>
)}
{session.device && (
<div className="row gap-2 items-center">
<SerieIcon name={session.device} />
<span className="capitalize">{session.device}</span>
</div>
)}
{session.os && (
<div className="row gap-2 items-center">
<SerieIcon name={session.os} />
<span>{session.os}</span>
</div>
)}
{session.model && (
<div className="row gap-2 items-center">
<SerieIcon name={session.model} />
<span>{session.model}</span>
</div>
)}
{session.browser && (
<div className="row gap-2 items-center">
<SerieIcon name={session.browser} />
<span>{session.browser}</span>
</div>
)}
</div>
</PageHeader>
<EventsTable query={query} />
</PageContainer>
);
}

View File

@@ -0,0 +1,19 @@
import { ClientsTable } from '@/components/clients/table';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/clients',
)({
component: Component,
});
function Component() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(trpc.client.list.queryOptions({ projectId }));
return <ClientsTable query={query} />;
}

View File

@@ -0,0 +1,36 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import DeleteProject from '@/components/settings/delete-project';
import EditProjectDetails from '@/components/settings/edit-project-details';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/details',
)({
component: Component,
});
function Component() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.project.getProjectWithClients.queryOptions({ projectId }),
);
if (query.isLoading) {
return <FullPageLoadingState />;
}
if (!query.data) {
return <div>Project not found</div>;
}
return (
<div className="space-y-6">
<EditProjectDetails project={query.data} />
<DeleteProject project={query.data} />
</div>
);
}

View File

@@ -0,0 +1,30 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import EditProjectFilters from '@/components/settings/edit-project-filters';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/events',
)({
component: Component,
});
function Component() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.project.getProjectWithClients.queryOptions({ projectId }),
);
if (query.isLoading) {
return <FullPageLoadingState />;
}
if (!query.data) {
return <div>Project not found</div>;
}
return <EditProjectFilters project={query.data} />;
}

View File

@@ -0,0 +1,17 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/',
)({
component: Component,
beforeLoad: ({ params }) => {
throw redirect({
to: '/$organizationId/$projectId/settings/details',
params,
});
},
});
function Component() {
return null;
}

View File

@@ -0,0 +1,73 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import {
Outlet,
createFileRoute,
useLocation,
useRouter,
} from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs',
)({
component: ProjectDashboard,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.SETTINGS),
},
],
};
},
loader: async ({ context, params }) => {
const { trpc, queryClient } = context;
await queryClient.prefetchQuery(
trpc.project.getProjectWithClients.queryOptions({
projectId: params.projectId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function ProjectDashboard() {
const router = useRouter();
const location = useLocation();
const tab = location.pathname.split('/').pop();
const settingsTabs = [
{ id: 'details', label: 'Details' },
{ id: 'events', label: 'Events' },
{ id: 'clients', label: 'Clients' },
];
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: `/$organizationId/$projectId/settings/${tabId}`,
});
};
return (
<div className="container p-8">
<PageHeader
title="Project settings"
description="Manage your project settings here"
/>
<Tabs value={tab} onValueChange={handleTabChange} className="mt-2 mb-8">
<TabsList>
{settingsTabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import Billing from '@/components/organization/billing';
import { BillingFaq } from '@/components/organization/billing-faq';
import CurrentSubscription from '@/components/organization/current-subscription';
import Usage from '@/components/organization/usage';
import { PageHeader } from '@/components/page-header';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createOrganizationTitle } from '@/utils/title';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { BoxSelectIcon } from 'lucide-react';
export const Route = createFileRoute('/_app/$organizationId/billing')({
component: OrganizationPage,
head: () => {
return {
meta: [
{
title: createOrganizationTitle(PAGE_TITLES.BILLING),
},
],
};
},
});
function OrganizationPage() {
const { organizationId } = Route.useParams();
const trpc = useTRPC();
const { data: organization, isLoading } = useQuery(
trpc.organization.get.queryOptions({
organizationId,
}),
);
if (isLoading) {
return <FullPageLoadingState />;
}
if (!organization) {
return (
<FullPageEmptyState title="Organization not found" icon={BoxSelectIcon} />
);
}
return (
<div className="container p-8">
<PageHeader
title="Billing"
description="Manage your billing here"
className="mb-8"
/>
<div className="flex flex-col-reverse md:flex-row gap-8 max-w-screen-lg">
<div className="col gap-8 w-full">
<Billing organization={organization} />
<Usage organization={organization} />
<BillingFaq />
</div>
<CurrentSubscription organization={organization} />
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LazyComponent } from '@/components/lazy-component';
import { PageHeader } from '@/components/page-header';
import ProjectCard, {
ProjectCardSkeleton,
} from '@/components/projects/project-card';
import { LinkButton } from '@/components/ui/button';
import { AnimatedSearchInput } from '@/components/ui/data-table/data-table-toolbar';
import { TableButtons } from '@/components/ui/table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createOrganizationTitle } from '@/utils/title';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { BoxSelectIcon, PlusIcon } from 'lucide-react';
export const Route = createFileRoute('/_app/$organizationId/')({
component: OrganizationPage,
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.project.list.queryOptions({
organizationId: params.organizationId,
}),
);
},
pendingComponent: FullPageLoadingState,
head: () => {
return {
meta: [
{
title: createOrganizationTitle(PAGE_TITLES.PROJECTS),
},
],
};
},
});
function OrganizationPage() {
const { organizationId } = Route.useParams();
const trpc = useTRPC();
const { data: projects } = useQuery(
trpc.project.list.queryOptions({
organizationId,
}),
);
const { setSearch, search } = useSearchQueryState();
if (!projects?.length) {
return (
<FullPageEmptyState
title="No projects found"
description="Create your first project to get started with analytics."
icon={BoxSelectIcon}
>
<LinkButton icon={PlusIcon} to=".">
Create project
</LinkButton>
</FullPageEmptyState>
);
}
return (
<div className="container p-8">
<PageHeader
title="Projects"
description="All your projects in this workspace"
className="mb-8"
/>
<TableButtons>
<AnimatedSearchInput
placeholder="Search projects"
value={search}
onChange={setSearch}
/>
</TableButtons>
<div className="grid gap-6 md:grid-cols-2">
{projects
.filter((project) => {
if (!search) return true;
return project.name.toLowerCase().includes(search.toLowerCase());
})
.map((project, index) => (
<LazyComponent
lazy={index >= 6}
key={project.id}
fallback={<ProjectCardSkeleton />}
>
<ProjectCard {...project} organizationId={organizationId} />
</LazyComponent>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { AllIntegrations } from '@/components/integrations/all-integrations';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/integrations/_tabs/available',
)({
component: Component,
});
function Component() {
return <AllIntegrations />;
}

View File

@@ -0,0 +1,18 @@
import { redirect } from '@tanstack/react-router';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/integrations/_tabs/',
)({
component: Component,
beforeLoad: ({ params }) => {
throw redirect({
to: '/$organizationId/integrations/installed',
params,
});
},
});
function Component() {
return null;
}

View File

@@ -0,0 +1,12 @@
import { ActiveIntegrations } from '@/components/integrations/active-integrations';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/integrations/_tabs/installed',
)({
component: Component,
});
function Component() {
return <ActiveIntegrations />;
}

View File

@@ -0,0 +1,73 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { PAGE_TITLES, createOrganizationTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/integrations/_tabs',
)({
component: Component,
loader: async ({ context, params }) => {
const organization = await context.queryClient.fetchQuery(
context.trpc.organization.get.queryOptions({
organizationId: params.organizationId,
}),
);
return { organization };
},
head: ({ loaderData }) => {
return {
meta: [
{
title: createOrganizationTitle(
PAGE_TITLES.INTEGRATIONS,
loaderData?.organization?.name,
),
},
],
};
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const router = useRouter();
const { activeTab, tabs } = usePageTabs([
{ id: 'installed', label: 'Installed' },
{ id: 'available', label: 'Available' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
return (
<div className="container p-8">
<PageHeader
title="Integrations"
description="Manage your integrations here"
/>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/$organizationId/members/_tabs/')({
component: Component,
beforeLoad: ({ params }) => {
throw redirect({
to: '/$organizationId/members/members',
params,
});
},
});
function Component() {
return null;
}

View File

@@ -0,0 +1,23 @@
import { InvitesTable } from '@/components/settings/invites';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { PlusIcon } from 'lucide-react';
export const Route = createFileRoute(
'/_app/$organizationId/members/_tabs/invitations',
)({
component: Component,
});
function Component() {
const { organizationId } = Route.useParams();
const trpc = useTRPC();
const query = useQuery(
trpc.organization.invitations.queryOptions({ organizationId }),
);
return <InvitesTable query={query} />;
}

View File

@@ -0,0 +1,21 @@
import { MembersTable } from '@/components/settings/members';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/members/_tabs/members',
)({
component: Component,
});
function Component() {
const { organizationId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.organization.members.queryOptions({ organizationId }),
);
return <MembersTable query={query} />;
}

View File

@@ -0,0 +1,54 @@
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { PAGE_TITLES, createOrganizationTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/$organizationId/members/_tabs')({
component: Component,
head: () => {
return {
meta: [
{
title: createOrganizationTitle(PAGE_TITLES.MEMBERS),
},
],
};
},
});
function Component() {
const router = useRouter();
const { activeTab, tabs } = usePageTabs([
{ id: 'members', label: 'Members' },
{ id: 'invitations', label: 'Invitations' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
return (
<div className="container p-8">
<PageHeader title="Members" description="Manage your members here" />
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createOrganizationTitle } from '@/utils/title';
import { zEditOrganization } from '@openpanel/validation';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
const validator = zEditOrganization;
type IForm = z.infer<typeof validator>;
export const Route = createFileRoute('/_app/$organizationId/settings')({
component: Component,
head: () => {
return {
meta: [
{
title: createOrganizationTitle(PAGE_TITLES.SETTINGS),
},
],
};
},
});
function Component() {
const { organizationId } = Route.useParams();
const trpc = useTRPC();
const {
data: organization,
isLoading,
refetch,
} = useQuery(
trpc.organization.get.queryOptions({
organizationId,
}),
);
if (isLoading) {
return <FullPageLoadingState />;
}
if (!organization) {
return <FullPageEmptyState title="Organization not found" />;
}
const { register, handleSubmit, formState, reset, control } = useForm<IForm>({
defaultValues: {
id: organization.id,
name: organization.name,
timezone: organization.timezone ?? undefined,
},
});
const mutation = useMutation(
trpc.organization.update.mutationOptions({
onSuccess(res) {
toast('Organization updated', {
description: 'Your organization has been updated.',
});
reset({
...res,
timezone: res.timezone!,
});
refetch();
},
onError: handleError,
}),
);
return (
<div className="container p-8">
<PageHeader
title="Workspace settings"
description="Manage your workspace settings here"
className="mb-8"
/>
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Details</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<InputWithLabel
className="flex-1"
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
<Controller
name="timezone"
control={control}
render={({ field }) => (
<WithLabel label="Timezone">
<Combobox
placeholder="Select timezone"
items={Intl.supportedValuesOf('timeZone').map((item) => ({
value: item,
label: item,
}))}
value={field.value}
onChange={field.onChange}
className="w-full"
/>
</WithLabel>
)}
/>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty}
className="self-end"
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LinkButton } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
import { useSuspenseQuery } from '@tanstack/react-query';
import {
Outlet,
createFileRoute,
notFound,
useLocation,
} from '@tanstack/react-router';
import { format } from 'date-fns';
const IGNORE_ORGANIZATION_IDS = [
'.well-known',
'robots.txt',
'sitemap.xml',
'favicon.ico',
'manifest.json',
'sw.js',
'service-worker.js',
'onboarding',
];
export const Route = createFileRoute('/_app/$organizationId')({
component: Component,
beforeLoad: async ({ context, params }) => {
if (IGNORE_ORGANIZATION_IDS.includes(params.organizationId)) {
throw notFound();
}
},
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.organization.get.queryOptions({
organizationId: params.organizationId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function Alert({
title,
description,
children,
className,
}: {
title: string;
description: string;
children: React.ReactNode;
className?: string;
}) {
const location = useLocation();
// Hide on billing page
if (location.pathname.match(/\/.+\/billing/)) {
return null;
}
return (
<div className={cn('p-4 lg:p-8 bg-card border-b col gap-1', className)}>
<div className="text-lg font-medium">{title}</div>
<div className="mb-1">{description}</div>
<div className="row gap-2">{children}</div>
</div>
);
}
function Component() {
const { organizationId } = Route.useParams();
const trpc = useTRPC();
const { data: organization } = useSuspenseQuery(
trpc.organization.get.queryOptions({
organizationId,
}),
);
return (
<>
{organization.subscriptionEndsAt && organization.isTrial && (
<Alert
title="Free trial"
description={`Your organization is on a free trial. It ends on ${format(organization.subscriptionEndsAt, 'PPP')}`}
>
<LinkButton
to="/$organizationId/billing"
params={{
organizationId: organizationId,
}}
>
Upgrade from $2.5/month
</LinkButton>
</Alert>
)}
{organization.subscriptionEndsAt && organization.isExpired && (
<Alert
title="Subscription expired"
description={`Your subscription has expired. You can reactivate it by choosing a new plan below. It expired on ${format(organization.subscriptionEndsAt, 'PPP')}`}
>
<LinkButton
to="/$organizationId/billing"
params={{
organizationId: organizationId,
}}
>
Reactivate
</LinkButton>
</Alert>
)}
{organization.subscriptionEndsAt && organization.isWillBeCanceled && (
<Alert
title="Subscription will becanceled"
description={`You have canceled your subscription. You can reactivate it by choosing a new plan below. It'll expire on ${format(organization.subscriptionEndsAt, 'PPP')}`}
>
<LinkButton
to="/$organizationId/billing"
params={{
organizationId: organizationId,
}}
>
Reactivate
</LinkButton>
</Alert>
)}
{organization.subscriptionCanceledAt && organization.isCanceled && (
<Alert
title="Subscription canceled"
description={`Your subscription was canceled on ${format(organization.subscriptionCanceledAt, 'PPP')}`}
>
<LinkButton
to="/$organizationId/billing"
params={{
organizationId: organizationId,
}}
>
Reactivate
</LinkButton>
</Alert>
)}
{organization.subscriptionProductId &&
FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) && (
<Alert
title="Free plan is removed"
description="We've removed the free plan. You can upgrade to a paid plan to continue using OpenPanel."
className="bg-orange-400/40 border-orange-400/50"
>
<LinkButton
className="bg-orange-400 text-white hover:bg-orange-400/80"
to="/$organizationId/billing"
params={{
organizationId: organizationId,
}}
>
Upgrade
</LinkButton>
</Alert>
)}
<Outlet />
</>
);
}

View File

@@ -0,0 +1,24 @@
import { Sidebar } from '@/components/sidebar';
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_app')({
beforeLoad: async ({ context }) => {
if (!context.session.session) {
throw redirect({ to: '/login' });
}
},
component: AppLayout,
});
function AppLayout() {
return (
<div className="flex h-screen w-full">
<Sidebar />
<div className="lg:pl-72 w-full">
<div className="block lg:hidden bg-background h-16 w-full fixed top-0 z-10 border-b" />
<div className="block lg:hidden h-16" />
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { Or } from '@/components/auth/or';
import { SignInEmailForm } from '@/components/auth/sign-in-email-form';
import { SignInGithub } from '@/components/auth/sign-in-github';
import { SignInGoogle } from '@/components/auth/sign-in-google';
import { LogoSquare } from '@/components/logo';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { PAGE_TITLES, createTitle } from '@/utils/title';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { AlertCircle } from 'lucide-react';
import { z } from 'zod';
export const Route = createFileRoute('/_login/login')({
component: LoginPage,
head: () => ({
meta: [{ title: createTitle(PAGE_TITLES.LOGIN) }],
}),
validateSearch: z.object({
error: z.string().optional(),
correlationId: z.string().optional(),
}),
});
function LoginPage() {
const { error, correlationId } = Route.useSearch();
return (
<div className="col gap-8 w-full text-left">
<div>
<LogoSquare className="size-12 mb-8 md:hidden" />
<h1 className="text-3xl font-bold text-foreground mb-2">Sign in</h1>
<p className="text-muted-foreground">
Don't have an account?{' '}
<a href="/onboarding" className="underline">
Create one today
</a>
</p>
</div>
{error && (
<Alert
variant="destructive"
className="text-left bg-destructive/10 border-destructive/20 mb-6"
>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
<p>{error}</p>
{correlationId && (
<>
<p>Correlation ID: {correlationId}</p>
<p className="mt-2">
Contact us if you have any issues.{' '}
<a
className="underline font-medium"
href={`mailto:hello@openpanel.dev?subject=Login%20Issue%20-%20Correlation%20ID%3A%20${correlationId}`}
>
hello[at]openpanel.dev
</a>
</p>
</>
)}
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<SignInGoogle type="sign-in" />
<SignInGithub type="sign-in" />
</div>
<Or />
<SignInEmailForm />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { ResetPasswordForm } from '@/components/auth/reset-password-form';
import { FullPageErrorState } from '@/components/full-page-error-state';
import { PAGE_TITLES, createTitle } from '@/utils/title';
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
export const Route = createFileRoute('/_login/reset-password')({
head: () => ({
meta: [{ title: createTitle(PAGE_TITLES.RESET_PASSWORD) }],
}),
component: Component,
validateSearch: z.object({
token: z.string(),
}),
errorComponent: () => (
<FullPageErrorState description="Missing reset password token" />
),
});
function Component() {
const { token } = Route.useSearch();
return (
<div className="col gap-8 w-full text-left">
<ResetPasswordForm token={token} />
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { LoginLeftPanel } from '@/components/login-left-panel';
import { SkeletonDashboard } from '@/components/skeleton-dashboard';
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_login')({
beforeLoad: async ({ context }) => {
if (context.session.session) {
throw redirect({ to: '/' });
}
},
component: AuthLayout,
});
function AuthLayout() {
return (
<div className="relative min-h-screen grid md:grid-cols-2">
<div className="hidden md:block">
<LoginLeftPanel />
</div>
<div className="center-center w-full max-w-md mx-auto px-4">
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { Or } from '@/components/auth/or';
import { SignInGithub } from '@/components/auth/sign-in-github';
import { SignInGoogle } from '@/components/auth/sign-in-google';
import { SignUpEmailForm } from '@/components/auth/sign-up-email-form';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LogoSquare } from '@/components/logo';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createEntityTitle } from '@/utils/title';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { motion } from 'framer-motion';
import { MailIcon } from 'lucide-react';
import { z } from 'zod';
const validateSearch = z.object({
inviteId: z.string().optional(),
});
export const Route = createFileRoute('/_public/onboarding')({
head: () => ({
meta: [
{ title: createEntityTitle('Create an account', PAGE_TITLES.ONBOARDING) },
],
}),
beforeLoad: async ({ context }) => {
if (context.session.session) {
throw redirect({ to: '/' });
}
},
component: Component,
validateSearch,
loader: async ({ context, location }) => {
const search = validateSearch.safeParse(location.search);
if (search.success && search.data.inviteId) {
await context.queryClient.prefetchQuery(
context.trpc.organization.getInvite.queryOptions({
inviteId: search.data.inviteId,
}),
);
}
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const { inviteId } = Route.useSearch();
const trpc = useTRPC();
const { data: invite } = useQuery(
trpc.organization.getInvite.queryOptions(
{
inviteId: inviteId,
},
{
enabled: !!inviteId,
},
),
);
return (
<div className="col gap-8 w-full text-left">
<div>
<LogoSquare className="size-12 mb-8 md:hidden" />
<h1 className="text-3xl font-bold text-foreground mb-2">
Create an account
</h1>
<p className="text-muted-foreground">
Let's start with creating your account. By creating an account you
accept the{' '}
<a
target="_blank"
href="https://openpanel.dev/terms"
rel="noreferrer"
className="underline hover:text-foreground transition-colors"
>
Terms of Service
</a>{' '}
and{' '}
<a
target="_blank"
href="https://openpanel.dev/privacy"
rel="noreferrer"
className="underline hover:text-foreground transition-colors"
>
Privacy Policy
</a>
.
</p>
</div>
{invite && !invite.isExpired && (
<div className="bg-card border border-border rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold mb-2">
Invitation to {invite.organization?.name}
</h2>
<p className="text-muted-foreground">
After you have created your account, you will be added to the
organization.
</p>
</div>
)}
{invite?.isExpired && (
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold mb-2 text-destructive">
Invitation to {invite.organization?.name} has expired
</h2>
<p className="text-muted-foreground">
The invitation has expired. Please contact the organization owner to
get a new invitation.
</p>
</div>
)}
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SignInGithub type="sign-up" inviteId={inviteId} />
<SignInGoogle type="sign-up" inviteId={inviteId} />
</div>
<Or className="my-6" />
<div className="flex items-center gap-2 font-semibold mb-4 text-lg">
<MailIcon className="size-4" />
Sign up with email
</div>
<SignUpEmailForm inviteId={inviteId} />
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { OnboardingLeftPanel } from '@/components/onboarding-left-panel';
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_public')({
component: OnboardingLayout,
});
function OnboardingLayout() {
return (
<div className="relative min-h-screen grid md:grid-cols-2">
<div className="hidden md:block">
<OnboardingLeftPanel />
</div>
<div className="center-center w-full max-w-md mx-auto px-4">
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { ButtonContainer } from '@/components/button-container';
import CopyInput from '@/components/forms/copy-input';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import ConnectApp from '@/components/onboarding/connect-app';
import ConnectBackend from '@/components/onboarding/connect-backend';
import ConnectWeb from '@/components/onboarding/connect-web';
import { LinkButton } from '@/components/ui/button';
import { useClientSecret } from '@/hooks/use-client-secret';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createEntityTitle } from '@/utils/title';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { LockIcon, XIcon } from 'lucide-react';
export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({
head: () => ({
meta: [
{ title: createEntityTitle('Connect data', PAGE_TITLES.ONBOARDING) },
],
}),
beforeLoad: async ({ context }) => {
if (!context.session.session) {
throw redirect({ to: '/onboarding' });
}
},
component: Component,
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.project.getProjectWithClients.queryOptions({
projectId: params.projectId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { data: project } = useQuery(
trpc.project.getProjectWithClients.queryOptions({ projectId }),
);
const client = project?.clients[0];
const [secret] = useClientSecret();
if (!client) {
return (
<FullPageEmptyState
title="No project found"
description="The project you are looking for does not exist. Please reload the page."
icon={XIcon}
/>
);
}
return (
<div className="p-4 col gap-8">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-xl font-bold capitalize">
<LockIcon className="size-4" />
Credentials
</div>
<CopyInput label="Client ID" value={client.id} />
<CopyInput label="Secret" value={secret} />
</div>
<div className="h-px bg-muted -mx-4" />
{project?.types?.map((type) => {
const Component = {
website: ConnectWeb,
app: ConnectApp,
backend: ConnectBackend,
}[type];
return <Component key={type} client={{ ...client, secret }} />;
})}
<ButtonContainer>
<div />
<LinkButton
href={'/onboarding/$projectId/verify'}
params={{ projectId }}
size="lg"
className="min-w-28 self-start"
>
Next
</LinkButton>
</ButtonContainer>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { ButtonContainer } from '@/components/button-container';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { CurlPreview } from '@/components/onboarding/curl-preview';
import VerifyListener from '@/components/onboarding/onboarding-verify-listener';
import { LinkButton } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/lib/utils';
import { PAGE_TITLES, createEntityTitle } from '@/utils/title';
import { useQuery } from '@tanstack/react-query';
import { Link, createFileRoute, redirect } from '@tanstack/react-router';
import { BoxSelectIcon } from 'lucide-react';
export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
head: () => ({
meta: [{ title: createEntityTitle('Verify', PAGE_TITLES.ONBOARDING) }],
}),
beforeLoad: async ({ context }) => {
if (!context.session.session) {
throw redirect({ to: '/onboarding' });
}
},
component: Component,
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.event.events.queryOptions({
projectId: params.projectId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { data: events, refetch } = useQuery(
trpc.event.events.queryOptions({ projectId }),
);
const { data: project } = useQuery(
trpc.project.getProjectWithClients.queryOptions({ projectId }),
);
const isVerified = events && events.data.length > 0;
if (!project) {
return (
<FullPageEmptyState title="Project not found" icon={BoxSelectIcon} />
);
}
const client = project.clients[0];
if (!client) {
return <FullPageEmptyState title="Client not found" icon={BoxSelectIcon} />;
}
return (
<div className="p-4 col gap-8">
<VerifyListener
project={project}
client={client}
events={events?.data ?? []}
onVerified={() => refetch()}
/>
<CurlPreview project={project} />
<ButtonContainer>
<LinkButton
href={`/onboarding/${project.id}/connect`}
size="lg"
className="min-w-28 self-start"
variant={'secondary'}
>
Back
</LinkButton>
<div className="flex items-center gap-8">
{!isVerified && (
<Link
to={'/$organizationId/$projectId'}
params={{
organizationId: project!.organizationId,
projectId: project!.id,
}}
className=" text-muted-foreground underline"
>
Skip for now
</Link>
)}
<LinkButton
to={'/$organizationId/$projectId'}
params={{
organizationId: project!.organizationId,
projectId: project!.id,
}}
size="lg"
className={cn(
'min-w-28 self-start',
!isVerified && 'pointer-events-none select-none opacity-20',
)}
>
Your dashboard
</LinkButton>
</div>
</ButtonContainer>
</div>
);
}

View File

@@ -0,0 +1,307 @@
import AnimateHeight from '@/components/animate-height';
import { ButtonContainer } from '@/components/button-container';
import { CheckboxItem } from '@/components/forms/checkbox-item';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { useClientSecret } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { zOnboardingProject } from '@openpanel/validation';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
import {
BuildingIcon,
MonitorIcon,
ServerIcon,
SmartphoneIcon,
} from 'lucide-react';
import { useEffect } from 'react';
import {
Controller,
type SubmitHandler,
useForm,
useWatch,
} from 'react-hook-form';
import { z } from 'zod';
const validateSearch = z.object({
inviteId: z.string().optional(),
});
export const Route = createFileRoute('/_steps/onboarding/project')({
component: Component,
validateSearch,
beforeLoad: async ({ context }) => {
if (!context.session.session) {
throw redirect({ to: '/onboarding' });
}
},
loader: async ({ context, location }) => {
const search = validateSearch.safeParse(location.search);
if (search.success && search.data.inviteId) {
await context.queryClient.prefetchQuery(
context.trpc.organization.getInvite.queryOptions({
inviteId: search.data.inviteId,
}),
);
}
},
pendingComponent: FullPageLoadingState,
});
type IForm = z.infer<typeof zOnboardingProject>;
function Component() {
const trpc = useTRPC();
const { data: organizations } = useQuery(
trpc.organization.list.queryOptions(undefined, { initialData: [] }),
);
const [, setSecret] = useClientSecret();
const navigate = useNavigate();
const mutation = useMutation(
trpc.onboarding.project.mutationOptions({
onError: handleError,
onSuccess(res) {
setSecret(res.secret);
navigate({
to: '/onboarding/$projectId/connect',
params: {
projectId: res.projectId!,
},
});
},
}),
);
const form = useForm<IForm>({
resolver: zodResolver(zOnboardingProject),
defaultValues: {
organization: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
project: '',
domain: '',
cors: [],
website: false,
app: false,
backend: false,
},
});
const isWebsite = useWatch({
name: 'website',
control: form.control,
});
const isApp = useWatch({
name: 'app',
control: form.control,
});
const isBackend = useWatch({
name: 'backend',
control: form.control,
});
useEffect(() => {
if (!isWebsite) {
form.setValue('domain', null);
form.setValue('cors', []);
}
}, [isWebsite, form]);
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate(values);
};
useEffect(() => {
form.clearErrors();
}, [isWebsite, isApp, isBackend]);
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-4">
<div className="grid gap-4 md:grid-cols-2">
{organizations.length > 0 ? (
<Controller
control={form.control}
name="organizationId"
render={({ field, formState }) => {
return (
<div>
<Label>Workspace</Label>
<Combobox
className="w-full"
placeholder="Select workspace"
icon={BuildingIcon}
error={formState.errors.organizationId?.message}
value={field.value}
items={
organizations
.filter((item) => item.id)
.map((item) => ({
label: item.name,
value: item.id,
})) ?? []
}
onChange={field.onChange}
/>
</div>
);
}}
/>
) : (
<>
<InputWithLabel
label="Workspace name"
info="This is the name of your workspace. It can be anything you like."
placeholder="Eg. The Music Company"
error={form.formState.errors.organization?.message}
{...form.register('organization')}
/>
<Controller
name="timezone"
control={form.control}
render={({ field }) => (
<WithLabel label="Timezone">
<Combobox
placeholder="Select timezone"
items={Intl.supportedValuesOf('timeZone').map((item) => ({
value: item,
label: item,
}))}
value={field.value}
onChange={field.onChange}
className="w-full"
/>
</WithLabel>
)}
/>
</>
)}
<InputWithLabel
label="Project name"
placeholder="Eg. The Music App"
error={form.formState.errors.project?.message}
{...form.register('project')}
className="col-span-2"
/>
</div>
<div className="flex flex-col divide-y mt-4">
<Controller
name="website"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.website?.message}
Icon={MonitorIcon}
label="Website"
disabled={isApp}
description="Track events and conversion for your website"
{...field}
>
<AnimateHeight open={isWebsite && !isApp}>
<div className="p-4 pl-14">
<InputWithLabel
label="Domain"
placeholder="Your website address"
{...form.register('domain')}
className="mb-4"
error={form.formState.errors.domain?.message}
onBlur={(e) => {
const value = e.target.value.trim();
if (
value.includes('.') &&
form.getValues().cors.length === 0 &&
!form.formState.errors.domain
) {
form.setValue('cors', [value]);
}
}}
/>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel label="Allowed domains">
<TagInput
{...field}
error={form.formState.errors.cors?.message}
placeholder="Accept events from these domains"
value={field.value ?? []}
renderTag={(tag) =>
tag === '*'
? 'Accept events from any domains'
: tag
}
onChange={(newValue) => {
field.onChange(
newValue.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
}),
);
}}
/>
</WithLabel>
)}
/>
</div>
</AnimateHeight>
</CheckboxItem>
)}
/>
<Controller
name="app"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.app?.message}
disabled={isWebsite}
Icon={SmartphoneIcon}
label="App"
description="Track events and conversion for your app"
{...field}
/>
)}
/>
<Controller
name="backend"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.backend?.message}
Icon={ServerIcon}
label="Backend / API"
description="Track events and conversion for your backend / API"
{...field}
/>
)}
/>
</div>
</div>
<ButtonContainer className="p-4 border-t">
<div />
<Button
type="submit"
size="lg"
className="min-w-28 self-start"
loading={mutation.isPending}
>
Next
</Button>
</ButtonContainer>
</form>
);
}

View File

@@ -0,0 +1,75 @@
import { OnboardingLeftPanel } from '@/components/onboarding-left-panel';
import { SkeletonDashboard } from '@/components/skeleton-dashboard';
import { cn } from '@/utils/cn';
import { PAGE_TITLES, createEntityTitle } from '@/utils/title';
import {
Outlet,
createFileRoute,
redirect,
useLocation,
useMatchRoute,
} from '@tanstack/react-router';
export const Route = createFileRoute('/_steps')({
component: OnboardingLayout,
head: () => ({
meta: [{ title: createEntityTitle('Project', PAGE_TITLES.ONBOARDING) }],
}),
});
function OnboardingLayout() {
return (
<div className="relative min-h-screen pt-32 pb-8">
<div className="fixed inset-0 hidden md:block">
<SkeletonDashboard />
</div>
<div className="relative z-10 border bg-background rounded-lg shadow-xl shadow-muted/50 max-w-xl mx-auto">
<Progress />
<Outlet />
</div>
</div>
);
}
function Progress() {
const steps = [
{
name: 'Create project',
match: '/onboarding/project',
},
{
name: 'Connect data',
match: '/onboarding/$projectId/connect',
},
{
name: 'Verify',
match: '/onboarding/$projectId/verify',
},
];
const matchRoute = useMatchRoute();
const currentStep = steps.find((step) =>
matchRoute({
// @ts-expect-error
from: step.match,
fuzzy: false,
}),
);
return (
<div className="row gap-4 p-4 border-b justify-between items-center flex-1 w-full">
<div className="font-bold">{currentStep?.name ?? 'Onboarding'}</div>
<div className="row gap-4">
{steps.map((step) => (
<div
className={cn(
'w-10 h-2 rounded-full bg-muted',
currentStep === step && 'w-20 bg-primary',
)}
key={step.match}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LogoSquare } from '@/components/logo';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useLogout } from '@/hooks/use-logout';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { createTitle } from '@/utils/title';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { Link, createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
beforeLoad: async ({ context }) => {
if (!context.session.session) {
throw redirect({ to: '/login' });
}
},
component: LandingPage,
head: () => ({
meta: [{ title: createTitle('Welcome') }],
}),
loader: async ({ context }) => {
// Unsure why not using ensureQueryData here works
// We need to put staleTime and gcTime to 0 to get the latest data
// Even tho this query has never been called before
const organizations = await context.queryClient
.fetchQuery(
context.trpc.organization.list.queryOptions(undefined, {
staleTime: 0,
gcTime: 0,
}),
)
.catch(() => []);
if (organizations.length === 0) {
throw redirect({ to: '/onboarding/project' });
}
if (organizations.length === 1) {
throw redirect({
to: '/$organizationId',
params: { organizationId: organizations[0].id },
});
}
},
pendingComponent: FullPageLoadingState,
});
function LandingPage() {
const trpc = useTRPC();
const logout = useLogout();
const { data: organizations } = useSuspenseQuery(
trpc.organization.list.queryOptions(),
);
const number = useNumber();
return (
<div className="min-h-screen flex flex-col items-center justify-center">
<div className="max-w-2xl mx-auto px-4 col gap-12">
<div className="col gap-4">
<LogoSquare className="w-full max-w-24" />
<PageHeader
title="Welcome to OpenPanel.dev"
description="The best web and product analytics tool out there (our honest opinion)."
/>
</div>
<div className="col gap-2">
{organizations?.map((org) => (
<Link
key={org.id}
to={'/$organizationId'}
params={{ organizationId: org.id }}
className="row justify-between items-center p-3 rounded-lg border bg-card hover:border-primary hover:shadow-md transition-all hover:translate-x-1"
>
<div className="col gap-2">
<span className="font-medium text-lg">{org.name}</span>
<span className="text-muted-foreground text-sm">
({org.id})
</span>
</div>
<span>
{number.format(org.subscriptionPeriodEventsCount)}
<span className="mx-1 opacity-50">/</span>
{number.format(org.subscriptionPeriodEventsLimit)}
</span>
</Link>
))}
</div>
<div className="row gap-4">
<Button onClick={() => logout.mutate()} loading={logout.isPending}>
Log out
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { LiveCounter } from '@/components/overview/live-counter';
import OverviewMetrics from '@/components/overview/overview-metrics';
import { OverviewRange } from '@/components/overview/overview-range';
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 { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
import { z } from 'zod';
const shareSearchSchema = z.object({
header: z.optional(z.number().or(z.string().or(z.boolean()))),
});
export const Route = createFileRoute('/share/overview/$shareId')({
component: RouteComponent,
validateSearch: shareSearchSchema,
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.share.overview.queryOptions({
shareId: params.shareId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
function RouteComponent() {
const { shareId } = Route.useParams();
const { header } = useSearch({ from: '/share/overview/$shareId' });
const trpc = useTRPC();
const shareQuery = useQuery(
trpc.share.overview.queryOptions({
shareId,
}),
);
const hasAccess = shareQuery.data?.hasAccess;
// Check if share exists and is public
if (shareQuery.isLoading) {
return <div>Loading...</div>;
}
if (!shareQuery.data) {
throw notFound();
}
if (!shareQuery.data.public) {
throw notFound();
}
const share = shareQuery.data;
const projectId = share.projectId;
// Handle password protection
if (share.password && !hasAccess) {
return <ShareEnterPassword shareId={share.id} />;
}
const isHeaderVisible =
header !== '0' && header !== 0 && header !== 'false' && header !== false;
return (
<div>
{isHeaderVisible && (
<div className="flex items-center justify-between border-b border-border bg-background p-4">
<div className="col gap-1">
<span className="text-sm">{share.organization?.name}</span>
<h1 className="text-xl font-medium">{share.project?.name}</h1>
</div>
<a
href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share"
className="col gap-1 items-end"
>
<span className="text-xs">POWERED BY</span>
<span className="text-xl font-medium">openpanel.dev</span>
</a>
</div>
)}
<div className="">
<div className="mx-auto max-w-7xl justify-between row gap-4 p-4 pb-0">
<div className="flex gap-2">
<OverviewRange />
</div>
<div className="flex gap-2">
<LiveCounter projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
<div className="mx-auto grid max-w-7xl grid-cols-6 gap-4 p-4">
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</div>
</div>
);
}