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:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
249
apps/start/src/routes/__root.tsx
Normal file
249
apps/start/src/routes/__root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/start/src/routes/_app.$organizationId.$projectId.tsx
Normal file
57
apps/start/src/routes/_app.$organizationId.$projectId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
282
apps/start/src/routes/_app.$organizationId.$projectId_.pages.tsx
Normal file
282
apps/start/src/routes/_app.$organizationId.$projectId_.pages.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
64
apps/start/src/routes/_app.$organizationId.billing.tsx
Normal file
64
apps/start/src/routes/_app.$organizationId.billing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
apps/start/src/routes/_app.$organizationId.index.tsx
Normal file
97
apps/start/src/routes/_app.$organizationId.index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
54
apps/start/src/routes/_app.$organizationId.members._tabs.tsx
Normal file
54
apps/start/src/routes/_app.$organizationId.members._tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
apps/start/src/routes/_app.$organizationId.settings.tsx
Normal file
134
apps/start/src/routes/_app.$organizationId.settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
apps/start/src/routes/_app.$organizationId.tsx
Normal file
162
apps/start/src/routes/_app.$organizationId.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
apps/start/src/routes/_app.tsx
Normal file
24
apps/start/src/routes/_app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/start/src/routes/_login.login.tsx
Normal file
73
apps/start/src/routes/_login.login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/start/src/routes/_login.reset-password.tsx
Normal file
28
apps/start/src/routes/_login.reset-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/start/src/routes/_login.tsx
Normal file
25
apps/start/src/routes/_login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/start/src/routes/_public.onboarding.tsx
Normal file
126
apps/start/src/routes/_public.onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/start/src/routes/_public.tsx
Normal file
19
apps/start/src/routes/_public.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
110
apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
Normal file
110
apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
apps/start/src/routes/_steps.onboarding.project.tsx
Normal file
307
apps/start/src/routes/_steps.onboarding.project.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/start/src/routes/_steps.tsx
Normal file
75
apps/start/src/routes/_steps.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
apps/start/src/routes/index.tsx
Normal file
99
apps/start/src/routes/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
apps/start/src/routes/share.overview.$shareId.tsx
Normal file
107
apps/start/src/routes/share.overview.$shareId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user