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

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

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

View File

@@ -0,0 +1,21 @@
import ReactAnimateHeight from 'react-animate-height';
type Props = {
children: React.ReactNode;
className?: string;
open: boolean;
};
const AnimateHeight = ({ children, className, open }: Props) => {
return (
<ReactAnimateHeight
duration={300}
height={open ? 'auto' : 0}
className={className}
>
{children}
</ReactAnimateHeight>
);
};
export default AnimateHeight;

View File

@@ -0,0 +1,20 @@
import type { NumberFlowProps } from '@number-flow/react';
import { useEffect, useState } from 'react';
// NumberFlow is breaking ssr and forces loaders to fetch twice
export function AnimatedNumber(props: NumberFlowProps) {
const [Component, setComponent] =
useState<React.ComponentType<NumberFlowProps> | null>(null);
useEffect(() => {
import('@number-flow/react').then(({ default: NumberFlow }) => {
setComponent(NumberFlow);
});
}, []);
if (!Component) {
return <>{props.value}</>;
}
return <Component {...props} />;
}

View File

@@ -0,0 +1,11 @@
import { cn } from '@/utils/cn';
export function Or({ className }: { className?: string }) {
return (
<div className={cn('row items-center gap-4', className)}>
<div className="h-px w-full bg-def-300" />
<span className="text-muted-foreground text-sm font-medium px-2">OR</span>
<div className="h-px w-full bg-def-300" />
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { zResetPassword } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { InputWithLabel } from '../forms/input-with-label';
import { Button } from '../ui/button';
const validator = zResetPassword;
type IForm = z.infer<typeof validator>;
export function ResetPasswordForm({ token }: { token: string }) {
const navigate = useNavigate();
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.resetPassword.mutationOptions({
onSuccess() {
toast.success('Password reset successfully');
navigate({
to: '/login',
});
},
onError(error) {
toast.error(error.message);
},
}),
);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
token: token ?? '',
password: '',
},
});
const onSubmit = form.handleSubmit(async (data) => {
mutation.mutate(data);
});
return (
<div className="col gap-8">
<div>
<h1 className="text-3xl font-bold text-foreground mb-2">
Reset your password
</h1>
<p className="text-muted-foreground">
Already have an account?{' '}
<a href="/login" className="underline">
Sign in
</a>
</p>
</div>
<form onSubmit={onSubmit} className="col gap-6">
<InputWithLabel
label="New password"
placeholder="New password"
type="password"
{...form.register('password')}
/>
<Button type="submit">Reset password</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { type ISignInShare, zSignInShare } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { LogoSquare } from '../logo';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
export function ShareEnterPassword({ shareId }: { shareId: string }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInShare.mutationOptions({
onSuccess() {},
onError() {
toast.error('Incorrect password');
},
}),
);
const form = useForm<ISignInShare>({
resolver: zodResolver(zSignInShare),
defaultValues: {
password: '',
shareId,
},
});
const onSubmit = form.handleSubmit((data) => {
mutation.mutate({
password: data.password,
shareId,
});
});
return (
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">Overview is locked</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">
<Input
{...form.register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
<Button type="submit">Get access</Button>
</form>
</div>
<div className="p-6 text-sm max-w-sm col gap-0.5">
<p>
Powered by{' '}
<a href="https://openpanel.dev" className="font-medium">
OpenPanel.dev
</a>
</p>
<p>
The best web and product analytics tool out there (our honest
opinion).
</p>
<p>
<a href="https://dashboard.openpanel.dev/onboarding">
Try it for free today!
</a>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { zodResolver } from '@hookform/resolvers/zod';
import { zSignInEmail } from '@openpanel/validation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useRouter } from '@tanstack/react-router';
import { type SubmitHandler, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { InputWithLabel } from '../forms/input-with-label';
import { Button } from '../ui/button';
const validator = zSignInEmail;
type IForm = z.infer<typeof validator>;
export function SignInEmailForm() {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInEmail.mutationOptions({
async onSuccess() {
toast.success('Successfully signed in');
window.location.href = '/';
},
onError(error) {
toast.error(error.message);
},
}),
);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
email: 'lindesvard+22@gmail.com',
password: 'demodemo',
},
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
...values,
});
};
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
<InputWithLabel
{...form.register('email')}
error={form.formState.errors.email?.message}
label="Email"
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
/>
<InputWithLabel
{...form.register('password')}
error={form.formState.errors.password?.message}
label="Password"
type="password"
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
/>
<Button type="submit" size="lg">
Sign in
</Button>
<button
type="button"
onClick={() =>
pushModal('RequestPasswordReset', {
email: form.getValues('email'),
})
}
className="text-sm text-muted-foreground hover:text-highlight hover:underline transition-colors duration-200 text-center mt-2"
>
Forgot password?
</button>
</form>
);
}

View File

@@ -0,0 +1,47 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation } from '@tanstack/react-query';
import { Button } from '../ui/button';
export function SignInGithub({
type,
inviteId,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInOAuth.mutationOptions({
onSuccess(res) {
if (res.url) {
window.location.href = res.url;
}
},
}),
);
const title = () => {
if (type === 'sign-in') return 'Sign in with Github';
if (type === 'sign-up') return 'Sign up with Github';
};
return (
<Button
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
size="lg"
onClick={() =>
mutation.mutate({
provider: 'github',
inviteId: type === 'sign-up' ? inviteId : undefined,
})
}
>
<svg
className="size-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{title()}
</Button>
);
}

View File

@@ -0,0 +1,59 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation } from '@tanstack/react-query';
import { Button } from '../ui/button';
export function SignInGoogle({
type,
inviteId,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInOAuth.mutationOptions({
onSuccess(res) {
if (res.url) {
window.location.href = res.url;
}
},
}),
);
const title = () => {
if (type === 'sign-in') return 'Sign in with Google';
if (type === 'sign-up') return 'Sign up with Google';
};
return (
<Button
className="w-full bg-background hover:bg-def-100 border border-def-300 text-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
size="lg"
onClick={() =>
mutation.mutate({
provider: 'google',
inviteId: type === 'sign-up' ? inviteId : undefined,
})
}
>
<svg
className="size-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
{title()}
</Button>
);
}

View File

@@ -0,0 +1,87 @@
import { useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { zSignUpEmail } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { type SubmitHandler, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { InputWithLabel } from '../forms/input-with-label';
import { Button } from '../ui/button';
const validator = zSignUpEmail;
type IForm = z.infer<typeof validator>;
export function SignUpEmailForm({
inviteId,
}: { inviteId: string | undefined }) {
const router = useRouter();
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signUpEmail.mutationOptions({
onSuccess() {
toast.success('Successfully signed up');
window.location.href = '/onboarding/project';
},
onError(error) {
toast.error(error.message);
},
}),
);
const form = useForm<IForm>({
resolver: zodResolver(validator),
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
...values,
inviteId,
});
};
return (
<form className="col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<div className="row gap-4 w-full flex-1">
<InputWithLabel
label="First name"
className="flex-1"
type="text"
{...form.register('firstName')}
error={form.formState.errors.firstName?.message}
/>
<InputWithLabel
label="Last name"
className="flex-1"
type="text"
{...form.register('lastName')}
error={form.formState.errors.lastName?.message}
/>
</div>
<InputWithLabel
label="Email"
className="w-full"
type="email"
{...form.register('email')}
error={form.formState.errors.email?.message}
/>
<div className="row gap-4 w-full">
<InputWithLabel
label="Password"
className="flex-1"
type="password"
{...form.register('password')}
error={form.formState.errors.password?.message}
/>
<InputWithLabel
label="Confirm password"
className="flex-1"
type="password"
{...form.register('confirmPassword')}
error={form.formState.errors.confirmPassword?.message}
/>
</div>
<Button type="submit" className="w-full" size="lg">
Create account
</Button>
</form>
);
}

View File

@@ -0,0 +1,11 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
export function ButtonContainer({
className,
...props
}: HtmlProps<HTMLDivElement>) {
return (
<div className={cn('mt-6 flex justify-between', className)} {...props} />
);
}

View File

@@ -0,0 +1,48 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
import { MoreHorizontal } from 'lucide-react';
type CardProps = HtmlProps<HTMLDivElement> & {
hover?: boolean;
};
export function Card({ children, hover, className }: CardProps) {
return (
<div
className={cn(
'card relative',
hover && 'transition-all hover:-translate-y-0.5',
className,
)}
>
{children}
</div>
);
}
interface CardActionsProps {
children: React.ReactNode;
}
export function CardActions({ children }: CardActionsProps) {
return (
<div className="absolute right-2 top-2 z-10">
<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>{children}</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export const CardActionsItem = DropdownMenuItem;

View File

@@ -0,0 +1,112 @@
import * as d3 from 'd3';
export function ChartSSR({
data,
dots = false,
color = 'blue',
}: {
dots?: boolean;
color?: 'blue' | 'green' | 'red';
data: { value: number; date: Date }[];
}) {
if (data.length === 0) {
return null;
}
const xScale = d3
.scaleTime()
.domain([data[0]!.date, data[data.length - 1]!.date])
.range([0, 100]);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data.map((d) => d.value)) ?? 0])
.range([100, 0]);
const line = d3
.line<(typeof data)[number]>()
.curve(d3.curveMonotoneX)
.x((d) => xScale(d.date))
.y((d) => yScale(d.value));
const area = d3
.area<(typeof data)[number]>()
.curve(d3.curveMonotoneX)
.x((d) => xScale(d.date))
.y0(yScale(0))
.y1((d) => yScale(d.value));
const pathLine = line(data);
const pathArea = area(data);
if (!pathLine) {
return null;
}
const gradientId = `gradient-${color}`;
return (
<div className="relative h-full w-full">
{/* Chart area */}
<svg className="absolute inset-0 h-full w-full overflow-visible">
<svg
viewBox="0 0 100 100"
className="overflow-visible"
preserveAspectRatio="none"
>
<defs>
<linearGradient
id={gradientId}
x1="0"
y1="0"
x2="0"
y2="100%"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="50%" stopColor={color} stopOpacity={0.05} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{/* Gradient area */}
{pathArea && (
<path
d={pathArea}
fill={`url(#${gradientId})`}
vectorEffect="non-scaling-stroke"
/>
)}
{/* Line */}
<path
d={pathLine}
fill="none"
className={
color === 'green'
? 'text-green-600'
: color === 'red'
? 'text-red-600'
: 'text-highlight'
}
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
{/* Circles */}
{dots &&
data.map((d) => (
<path
key={d.date.toString()}
d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
vectorEffect="non-scaling-stroke"
strokeWidth="8"
strokeLinecap="round"
fill="none"
stroke="currentColor"
className="text-gray-400"
/>
))}
</svg>
</svg>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { createContext, useContext as useBaseContext } from 'react';
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
export function createChartTooltip<
PropsFromTooltip extends Record<string, unknown>,
PropsFromContext extends Record<string, unknown>,
>(
Tooltip: React.ComponentType<
{
context: PropsFromContext;
data: PropsFromTooltip[];
} & TooltipProps<number, string>
>,
) {
const context = createContext<PropsFromContext | null>(null);
const useContext = () => {
const value = useBaseContext(context);
if (!value) {
throw new Error('ChartTooltip context not found');
}
return value;
};
const InnerTooltip = (tooltip: TooltipProps<number, string>) => {
const context = useContext();
const data = tooltip.payload?.map((p) => p.payload) ?? [];
if (!data || !tooltip.active) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
<Tooltip data={data} context={context} {...tooltip} />
</div>
);
};
return {
TooltipProvider: ({
children,
...value
}: {
children: React.ReactNode;
} & PropsFromContext) => {
return (
<context.Provider value={value as unknown as PropsFromContext}>
{children}
</context.Provider>
);
},
Tooltip: (props: TooltipProps<number, string>) => {
return (
<RechartsTooltip {...props} content={<InnerTooltip {...props} />} />
);
},
};
}

View File

@@ -0,0 +1,47 @@
import { Bar } from 'recharts';
type Options = {
borderHeight: number;
border: string;
fill: string;
active: { border: string; fill: string };
};
export const BarWithBorder = (options: Options) => {
return (props: any) => {
const { x, y, width, height, value, isActive } = props;
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
stroke="none"
fill={isActive ? options.active.fill : options.fill}
/>
{value > 0 && (
<rect
x={x}
y={y - options.borderHeight - 2}
width={width}
height={options.borderHeight}
stroke="none"
fill={isActive ? options.active.border : options.border}
/>
)}
</g>
);
};
};
export const BarShapeBlue = BarWithBorder({
borderHeight: 2,
border: 'rgba(59, 121, 255, 1)',
fill: 'rgba(59, 121, 255, 0.3)',
active: {
border: 'rgba(59, 121, 255, 1)',
fill: 'rgba(59, 121, 255, 0.4)',
},
});

View File

@@ -0,0 +1,70 @@
import { cn } from '@/utils/cn';
import type { useChat } from '@ai-sdk/react';
import { useLocalStorage } from 'usehooks-ts';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
type Props = Pick<
ReturnType<typeof useChat>,
'handleSubmit' | 'handleInputChange' | 'input' | 'append'
> & {
projectId: string;
isLimited: boolean;
};
export function ChatForm({
handleSubmit: handleSubmitProp,
input,
handleInputChange,
append,
projectId,
isLimited,
}: Props) {
const [quickActions, setQuickActions] = useLocalStorage<string[]>(
`chat-quick-actions:${projectId}`,
[],
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
handleSubmitProp(e);
setQuickActions([input, ...quickActions].slice(0, 5));
};
return (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-def-100 to-def-100/50 backdrop-blur-sm z-20">
<ScrollArea orientation="horizontal">
<div className="row gap-2 px-4">
{quickActions.map((q) => (
<Button
disabled={isLimited}
key={q}
type="button"
variant="outline"
size="sm"
onClick={() => {
append({
role: 'user',
content: q,
});
}}
>
{q}
</Button>
))}
</div>
</ScrollArea>
<form onSubmit={handleSubmit} className="p-4 pt-2">
<input
disabled={isLimited}
className={cn(
'w-full h-12 px-4 outline-none border border-border text-foreground rounded-md font-mono placeholder:text-foreground/50 bg-background/50',
isLimited && 'opacity-50 cursor-not-allowed',
)}
value={input}
placeholder="Ask me anything..."
onChange={handleInputChange}
/>
</form>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { Markdown } from '@/components/markdown';
import { cn } from '@/utils/cn';
import { zChartInputAI } from '@openpanel/validation';
import type { UIMessage } from 'ai';
import { Loader2Icon, UserIcon } from 'lucide-react';
import { Fragment, memo } from 'react';
import { Card } from '../card';
import { LogoSquare } from '../logo';
import { Skeleton } from '../skeleton';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
import { ChatReport } from './chat-report';
export const ChatMessage = memo(
({
message,
isLast,
isStreaming,
debug,
}: {
message: UIMessage;
isLast: boolean;
isStreaming: boolean;
debug: boolean;
}) => {
const showIsStreaming = isLast && isStreaming;
return (
<div className="max-w-xl w-full">
<div className="row">
<div className="w-8 shrink-0">
<div className="size-6 relative">
{message.role === 'assistant' ? (
<LogoSquare className="size-full rounded-full" />
) : (
<div className="size-full bg-black text-white rounded-full center-center">
<UserIcon className="size-4" />
</div>
)}
<div
className={cn(
'absolute inset-0 bg-background rounded-full center-center opacity-0',
showIsStreaming && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin text-foreground" />
</div>
</div>
</div>
<div className="col gap-4 flex-1">
{message.parts.map((p, index) => {
const key = index.toString() + p.type;
const isToolInvocation = p.type === 'tool-invocation';
if (p.type === 'step-start') {
return null;
}
if (!isToolInvocation && p.type !== 'text') {
return <Debug enabled={debug} json={p} />;
}
if (p.type === 'text') {
return (
<div className="prose dark:prose-invert prose-sm" key={key}>
<Markdown>{p.text}</Markdown>
</div>
);
}
if (isToolInvocation && p.toolInvocation.state === 'result') {
const { result } = p.toolInvocation;
if (result.type === 'report') {
const report = zChartInputAI.safeParse(result.report);
if (report.success) {
return (
<Fragment key={key}>
<Debug json={result} enabled={debug} />
<ChatReport report={report.data} lazy={!isLast} />
</Fragment>
);
}
}
return (
<Debug
key={key}
json={p.toolInvocation.result}
enabled={debug}
/>
);
}
return null;
})}
{showIsStreaming && (
<div className="w-full col gap-2">
<Skeleton className="w-3/5 h-4" />
<Skeleton className="w-4/5 h-4" />
<Skeleton className="w-2/5 h-4" />
</div>
)}
</div>
</div>
{!isLast && (
<div className="w-full shrink-0 pl-8 mt-4">
<div className="h-px bg-border" />
</div>
)}
</div>
);
},
);
function Debug({ enabled, json }: { enabled?: boolean; json?: any }) {
if (!enabled) {
return null;
}
return (
<Accordion type="single" collapsible>
<Card>
<AccordionItem value={'json'}>
<AccordionTrigger className="text-left p-4 py-2 w-full font-medium font-mono row items-center">
<span className="flex-1">Show JSON result</span>
</AccordionTrigger>
<AccordionContent className="p-2">
<Syntax
wrapLines
language="json"
code={JSON.stringify(json, null, 2)}
/>
</AccordionContent>
</AccordionItem>
</Card>
</Accordion>
);
}

View File

@@ -0,0 +1,80 @@
import { useScrollAnchor } from '@/hooks/use-scroll-anchor';
import type { UIMessage } from 'ai';
import { Loader2Icon } from 'lucide-react';
import { useEffect } from 'react';
import { ProjectLink } from '../links';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { ScrollArea } from '../ui/scroll-area';
import { ChatMessage } from './chat-message';
export function ChatMessages({
messages,
debug,
status,
isLimited,
}: {
messages: UIMessage[];
debug: boolean;
status: 'submitted' | 'streaming' | 'ready' | 'error';
isLimited: boolean;
}) {
const { messagesRef, scrollRef, visibilityRef, scrollToBottom } =
useScrollAnchor();
useEffect(() => {
scrollToBottom();
}, []);
useEffect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === 'user') {
scrollToBottom();
}
}, [messages]);
return (
<ScrollArea className="h-full" ref={scrollRef}>
<div ref={messagesRef} className="p-8 col gap-2">
{messages.map((m, index) => {
return (
<ChatMessage
key={m.id}
message={m}
isStreaming={status === 'streaming'}
isLast={index === messages.length - 1}
debug={debug}
/>
);
})}
{status === 'submitted' && (
<div className="card p-4 center-center max-w-xl pl-8">
<Loader2Icon className="w-4 h-4 animate-spin" />
</div>
)}
{isLimited && (
<div className="max-w-xl pl-8 mt-8">
<Alert variant={'warning'}>
<AlertTitle>Upgrade your account</AlertTitle>
<AlertDescription>
<p>
To keep using this feature you need to upgrade your account.
</p>
<p>
<ProjectLink
href="/settings/organization?tab=billing"
className="font-medium underline"
>
Visit Billing
</ProjectLink>{' '}
to upgrade.
</p>
</AlertDescription>
</Alert>
</div>
)}
<div className="h-20 p-4 w-full" />
<div className="w-full h-px" ref={visibilityRef} />
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,85 @@
import { pushModal } from '@/modals';
import type {
IChartInputAi,
IChartRange,
IChartType,
IInterval,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { ReportChart } from '../report-chart';
import { ReportChartType } from '../report/ReportChartType';
import { ReportInterval } from '../report/ReportInterval';
import { TimeWindowPicker } from '../time-window-picker';
import { Button } from '../ui/button';
export function ChatReport({
lazy,
...props
}: { report: IChartInputAi; lazy: boolean }) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
);
const [startDate, setStartDate] = useState<string>(props.report.startDate);
const [endDate, setEndDate] = useState<string>(props.report.endDate);
const [range, setRange] = useState<IChartRange>(props.report.range);
const [interval, setInterval] = useState<IInterval>(props.report.interval);
const report = {
...props.report,
lineType: 'linear' as const,
chartType,
startDate: range === 'custom' ? startDate : null,
endDate: range === 'custom' ? endDate : null,
range,
interval,
};
return (
<div className="card">
<div className="text-center text-sm font-mono font-medium pt-4">
{props.report.name}
</div>
<div className="p-4">
<ReportChart lazy={lazy} report={report} />
</div>
<div className="row justify-between gap-1 border-t border-border p-2">
<div className="col md:row gap-1">
<TimeWindowPicker
className="min-w-0"
onChange={setRange}
value={report.range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval
className="min-w-0"
interval={interval}
range={range}
chartType={chartType}
onChange={setInterval}
/>
<ReportChartType
value={chartType}
onChange={(type) => {
setChartType(type);
}}
/>
</div>
<Button
icon={SaveIcon}
variant="outline"
size="sm"
onClick={() => {
pushModal('SaveReport', {
report,
disableRedirect: true,
});
}}
>
Save report
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { ChatForm } from '@/components/chat/chat-form';
import { ChatMessages } from '@/components/chat/chat-messages';
import { useAppContext } from '@/hooks/use-app-context';
import { useChat } from '@ai-sdk/react';
import type { IServiceOrganization } from '@openpanel/db';
import type { UIMessage } from 'ai';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { toast } from 'sonner';
const getErrorMessage = (error: Error) => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || error.message;
} catch (e) {
return error.message;
}
};
export default function Chat({
initialMessages,
projectId,
organization,
}: {
initialMessages?: UIMessage[];
projectId: string;
organization: IServiceOrganization;
}) {
const context = useAppContext();
const { messages, input, handleInputChange, handleSubmit, status, append } =
useChat({
onError(error) {
const message = getErrorMessage(error);
toast.error(message);
},
api: `${context.apiUrl}/ai/chat?projectId=${projectId}`,
initialMessages: (initialMessages ?? []) as any,
fetch: (url, options) => {
return fetch(url, {
...options,
credentials: 'include',
mode: 'cors',
});
},
});
const [debug] = useQueryState('debug', parseAsBoolean.withDefault(false));
const isLimited = Boolean(
messages.length > 5 &&
(organization.isCanceled ||
organization.isTrial ||
organization.isWillBeCanceled ||
organization.isExceeded ||
organization.isExpired),
);
return (
<div className="h-screen w-full col relative">
<ChatMessages
messages={messages}
debug={debug}
status={status}
isLimited={isLimited}
/>
<ChatForm
handleSubmit={handleSubmit}
input={input}
handleInputChange={handleInputChange}
append={append}
projectId={projectId}
isLimited={isLimited}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { clipboard } from '@/utils/clipboard';
import { toast } from 'sonner';
import { Tooltiper } from './ui/tooltip';
type Props = {
children: React.ReactNode;
className?: string;
value: string;
};
const ClickToCopy = ({ children, value }: Props) => {
return (
<Tooltiper
content="Click to copy"
asChild
className="cursor-pointer"
onClick={() => {
clipboard(value);
toast('Copied to clipboard');
}}
>
{children}
</Tooltiper>
);
};
export default ClickToCopy;

View File

@@ -0,0 +1,38 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { RocketIcon } from 'lucide-react';
import CopyInput from '../forms/copy-input';
type Props = { id: string; secret: string };
export function CreateClientSuccess({ id, secret }: Props) {
return (
<div className="grid gap-4">
<CopyInput label="Client ID" value={id} />
{secret && (
<div className="w-full">
<CopyInput label="Secret" value={secret} />
<p className="mt-1 text-sm text-muted-foreground">
You will only need the secret if you want to send server events.
</p>
</div>
)}
<Alert>
<RocketIcon className="h-4 w-4" />
<AlertTitle>Get started!</AlertTitle>
<AlertDescription>
Read our{' '}
<a
target="_blank"
href="https://openpanel.dev/docs"
className="underline"
rel="noreferrer"
>
documentation
</a>{' '}
to get started. Easy peasy!
</AlertDescription>
</Alert>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { formatDateTime, formatTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import CopyInput from '@/components/forms/copy-input';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import type { RouterOutputs } from '@/trpc/client';
import { clipboard } from '@/utils/clipboard';
import { DropdownMenuSeparator } from '@radix-ui/react-dropdown-menu';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
export function useColumns() {
const columns: ColumnDef<RouterOutputs['client']['list'][number]>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return <div className="font-medium">{row.original.name}</div>;
},
},
{
accessorKey: 'id',
header: 'Client ID',
cell: ({ row }) => <CopyInput label={null} value={row.original.id} />,
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
createActionColumn(({ row }) => {
const client = row.original;
const trpc = useTRPC();
const queryClient = useQueryClient();
const deletion = useMutation(
trpc.client.remove.mutationOptions({
onSuccess() {
toast('Success', {
description:
'Client revoked, incoming requests will be rejected.',
});
queryClient.invalidateQueries(trpc.client.list.pathFilter());
},
onError: handleError,
}),
);
return (
<>
<DropdownMenuItem onClick={() => clipboard(client.id)}>
Copy client ID
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
pushModal('EditClient', client);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
showConfirm({
title: 'Revoke client',
text: 'Are you sure you want to revoke this client? This action cannot be undone.',
onConfirm() {
deletion.mutate({
id: client.id,
});
},
});
}}
>
Revoke
</DropdownMenuItem>
</>
);
}),
];
return columns;
}

View File

@@ -0,0 +1,30 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { DataTable } from '@/components/ui/data-table/data-table';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table';
import type { RouterOutputs } from '@/trpc/client';
import { useColumns } from './columns';
type Props = {
query: UseQueryResult<RouterOutputs['client']['list'], unknown>;
};
export const ClientsTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
columns,
data: data ?? [],
loading: isLoading,
pageSize: 50,
});
return (
<>
<DataTableToolbar table={table} />
<DataTable table={table} loading={isLoading} />
</>
);
};

View File

@@ -0,0 +1,17 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
type ColorSquareProps = HtmlProps<HTMLDivElement>;
export function ColorSquare({ children, className }: ColorSquareProps) {
return (
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem] font-mono',
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/utils/cn';
interface DotProps {
className?: string;
size?: number;
animated?: boolean;
}
function filterCn(filter: string[], className: string | undefined) {
const split: string[] = className?.split(' ') || [];
return split
.filter((item) => !filter.some((filterItem) => item.startsWith(filterItem)))
.join(' ');
}
export function Dot({ className, size = 8, animated }: DotProps) {
const style = {
width: size,
height: size,
};
return (
<div
className={cn(
'relative',
filterCn(['bg-', 'animate-', 'group-hover/row'], className),
)}
style={style}
>
<div
className={cn(
'absolute !m-0 rounded-full',
animated !== false && 'animate-ping',
className,
)}
style={style}
/>
<div
className={cn(
'absolute !m-0 rounded-full',
filterCn(['animate-', 'group-hover/row'], className),
)}
style={style}
/>
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { cn } from '@/utils/cn';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import type { LucideIcon } from 'lucide-react';
import * as Icons from 'lucide-react';
import type { EventMeta } from '@openpanel/db';
const variants = cva('flex shrink-0 items-center justify-center rounded-full', {
variants: {
size: {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
default: 'h-10 w-10',
},
},
defaultVariants: {
size: 'default',
},
});
type EventIconProps = VariantProps<typeof variants> & {
name: string;
meta?: EventMeta;
className?: string;
};
export const EventIconRecords: Record<
string,
{
icon: string;
color: string;
}
> = {
default: {
icon: 'BotIcon',
color: 'slate',
},
screen_view: {
icon: 'MonitorPlayIcon',
color: 'blue',
},
session_start: {
icon: 'ActivityIcon',
color: 'teal',
},
link_out: {
icon: 'ExternalLinkIcon',
color: 'indigo',
},
};
export const EventIconMapper: Record<string, LucideIcon> = {
DownloadIcon: Icons.DownloadIcon,
BotIcon: Icons.BotIcon,
BoxIcon: Icons.BoxIcon,
AccessibilityIcon: Icons.AccessibilityIcon,
ActivityIcon: Icons.ActivityIcon,
AirplayIcon: Icons.AirplayIcon,
AlarmCheckIcon: Icons.AlarmCheckIcon,
AlertTriangleIcon: Icons.AlertTriangleIcon,
BellIcon: Icons.BellIcon,
BoltIcon: Icons.BoltIcon,
CandyIcon: Icons.CandyIcon,
ConeIcon: Icons.ConeIcon,
MonitorPlayIcon: Icons.MonitorPlayIcon,
PizzaIcon: Icons.PizzaIcon,
SearchIcon: Icons.SearchIcon,
HomeIcon: Icons.HomeIcon,
MailIcon: Icons.MailIcon,
AngryIcon: Icons.AngryIcon,
AnnoyedIcon: Icons.AnnoyedIcon,
ArchiveIcon: Icons.ArchiveIcon,
AwardIcon: Icons.AwardIcon,
BadgeCheckIcon: Icons.BadgeCheckIcon,
BeerIcon: Icons.BeerIcon,
BluetoothIcon: Icons.BluetoothIcon,
BookIcon: Icons.BookIcon,
BookmarkIcon: Icons.BookmarkIcon,
BookCheckIcon: Icons.BookCheckIcon,
BookMinusIcon: Icons.BookMinusIcon,
BookPlusIcon: Icons.BookPlusIcon,
CalendarIcon: Icons.CalendarIcon,
ClockIcon: Icons.ClockIcon,
CogIcon: Icons.CogIcon,
LoaderIcon: Icons.LoaderIcon,
CrownIcon: Icons.CrownIcon,
FileIcon: Icons.FileIcon,
KeyRoundIcon: Icons.KeyRoundIcon,
GemIcon: Icons.GemIcon,
GlobeIcon: Icons.GlobeIcon,
LightbulbIcon: Icons.LightbulbIcon,
LightbulbOffIcon: Icons.LightbulbOffIcon,
LockIcon: Icons.LockIcon,
MessageCircleIcon: Icons.MessageCircleIcon,
RadioIcon: Icons.RadioIcon,
RepeatIcon: Icons.RepeatIcon,
ShareIcon: Icons.ShareIcon,
ExternalLinkIcon: Icons.ExternalLinkIcon,
UserIcon: Icons.UserIcon,
UsersIcon: Icons.UsersIcon,
UserPlusIcon: Icons.UserPlusIcon,
UserMinusIcon: Icons.UserMinusIcon,
UserCheckIcon: Icons.UserCheckIcon,
UserXIcon: Icons.UserXIcon,
PlayIcon: Icons.PlayIcon,
PauseIcon: Icons.PauseIcon,
SkipForwardIcon: Icons.SkipForwardIcon,
SkipBackIcon: Icons.SkipBackIcon,
VolumeIcon: Icons.VolumeIcon,
VolumeOffIcon: Icons.VolumeOffIcon,
ImageIcon: Icons.ImageIcon,
VideoIcon: Icons.VideoIcon,
MusicIcon: Icons.MusicIcon,
CameraIcon: Icons.CameraIcon,
ClickIcon: Icons.MousePointerClickIcon,
ChevronDownIcon: Icons.ChevronDownIcon,
ChevronUpIcon: Icons.ChevronUpIcon,
ChevronLeftIcon: Icons.ChevronLeftIcon,
ChevronRightIcon: Icons.ChevronRightIcon,
ArrowUpIcon: Icons.ArrowUpIcon,
ArrowDownIcon: Icons.ArrowDownIcon,
ArrowLeftIcon: Icons.ArrowLeftIcon,
ArrowRightIcon: Icons.ArrowRightIcon,
PhoneIcon: Icons.PhoneIcon,
MessageSquareIcon: Icons.MessageSquareIcon,
SendIcon: Icons.SendIcon,
ShoppingCartIcon: Icons.ShoppingCartIcon,
ShoppingBagIcon: Icons.ShoppingBagIcon,
CreditCardIcon: Icons.CreditCardIcon,
DollarSignIcon: Icons.DollarSignIcon,
EuroIcon: Icons.EuroIcon,
HeartIcon: Icons.HeartIcon,
StarIcon: Icons.StarIcon,
ThumbsUpIcon: Icons.ThumbsUpIcon,
ThumbsDownIcon: Icons.ThumbsDownIcon,
SmileIcon: Icons.SmileIcon,
FrownIcon: Icons.FrownIcon,
BarChartIcon: Icons.BarChartIcon,
LineChartIcon: Icons.LineChartIcon,
PieChartIcon: Icons.PieChartIcon,
TrendingUpIcon: Icons.TrendingUpIcon,
TrendingDownIcon: Icons.TrendingDownIcon,
TargetIcon: Icons.TargetIcon,
ShieldIcon: Icons.ShieldIcon,
EyeIcon: Icons.EyeIcon,
EyeOffIcon: Icons.EyeOffIcon,
KeyIcon: Icons.KeyIcon,
UnlockIcon: Icons.UnlockIcon,
SettingsIcon: Icons.SettingsIcon,
RefreshCwIcon: Icons.RefreshCwIcon,
TrashIcon: Icons.TrashIcon,
EditIcon: Icons.EditIcon,
PlusIcon: Icons.PlusIcon,
MinusIcon: Icons.MinusIcon,
XIcon: Icons.XIcon,
CheckIcon: Icons.CheckIcon,
SaveIcon: Icons.SaveIcon,
UploadIcon: Icons.UploadIcon,
SmartphoneIcon: Icons.SmartphoneIcon,
TabletIcon: Icons.TabletIcon,
LaptopIcon: Icons.LaptopIcon,
MonitorIcon: Icons.MonitorIcon,
WifiIcon: Icons.WifiIcon,
MapPinIcon: Icons.MapPinIcon,
NavigationIcon: Icons.NavigationIcon,
CompassIcon: Icons.CompassIcon,
FolderIcon: Icons.FolderIcon,
FileTextIcon: Icons.FileTextIcon,
FilePlusIcon: Icons.FilePlusIcon,
FileMinusIcon: Icons.FileMinusIcon,
DatabaseIcon: Icons.DatabaseIcon,
AlertCircleIcon: Icons.AlertCircleIcon,
InfoIcon: Icons.InfoIcon,
HelpCircleIcon: Icons.HelpCircleIcon,
CheckCircleIcon: Icons.CheckCircleIcon,
XCircleIcon: Icons.XCircleIcon,
CalendarDaysIcon: Icons.CalendarDaysIcon,
CalendarPlusIcon: Icons.CalendarPlusIcon,
TimerIcon: Icons.TimerIcon,
FilterIcon: Icons.FilterIcon,
SortAscIcon: Icons.ArrowUpAZIcon,
SortDescIcon: Icons.ArrowDownZAIcon,
CopyIcon: Icons.CopyIcon,
LinkIcon: Icons.LinkIcon,
QrCodeIcon: Icons.QrCodeIcon,
ScanIcon: Icons.ScanIcon,
ZapIcon: Icons.ZapIcon,
FlameIcon: Icons.FlameIcon,
RocketIcon: Icons.RocketIcon,
TrophyIcon: Icons.TrophyIcon,
};
export const EventIconColors = [
'rose',
'pink',
'fuchsia',
'purple',
'violet',
'indigo',
'blue',
'sky',
'cyan',
'teal',
'emerald',
'green',
'lime',
'yellow',
'amber',
'orange',
'red',
'stone',
'neutral',
'zinc',
'grey',
'slate',
];
export function EventIcon({ className, name, size, meta }: EventIconProps) {
const Icon =
EventIconMapper[
meta?.icon ??
EventIconRecords[name]?.icon ??
EventIconRecords.default?.icon ??
''
]!;
const color =
meta?.color ??
EventIconRecords[name]?.color ??
EventIconRecords.default?.color ??
'';
return (
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
<Icon
size={size === 'xs' ? 12 : size === 'sm' ? 14 : 20}
className={`text-${color}-700`}
/>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { Link } from '@tanstack/react-router';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
export function EventListItem(props: EventListItemProps) {
const { organizationId, projectId } = useAppParams();
const { createdAt, name, path, duration, meta } = props;
const profile = 'profile' in props ? props.profile : null;
const number = useNumber();
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
return path;
}
return `Route: ${path}`;
}
return name.replace(/_/g, ' ');
};
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
const isMinimal = 'minimal' in props;
return (
<>
<button
type="button"
onClick={() => {
if (!isMinimal) {
pushModal('EventDetails', {
id: props.id,
projectId,
createdAt,
});
}
}}
className={cn(
'card hover:bg-light-background flex w-full items-center justify-between rounded-lg p-4 transition-colors',
meta?.conversion &&
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`,
)}
>
<div>
<div className="flex items-center gap-4 text-left ">
<EventIcon size="sm" name={name} meta={meta} />
<span>
<span className="font-medium">{renderName()}</span>
{' '}
{renderDuration()}
</span>
</div>
<div className="pl-10">
<div className="flex origin-left scale-75 gap-1">
<SerieIcon name={props.country} />
<SerieIcon name={props.os} />
<SerieIcon name={props.browser} />
</div>
</div>
</div>
<div className="flex gap-4">
{profile && (
<Tooltiper asChild content={getProfileName(profile)}>
<Link
onClick={(e) => {
e.stopPropagation();
}}
to={'/$organizationId/$projectId/profiles/$profileId'}
params={{
organizationId,
projectId,
profileId: profile.id,
}}
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
>
{getProfileName(profile)}
</Link>
</Tooltiper>
)}
<Tooltiper asChild content={createdAt.toLocaleString()}>
<div className=" text-muted-foreground">
{createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
</div>
</button>
</>
);
}

View File

@@ -0,0 +1,74 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceState } from '@/hooks/use-debounce-state';
import useWS from '@/hooks/use-ws';
import { cn } from '@/utils/cn';
import type { IServiceEventMinimal } from '@openpanel/db';
import { AnimatedNumber } from '../animated-number';
export default function EventListener({
onRefresh,
}: {
onRefresh: () => void;
}) {
const { projectId } = useAppParams();
const counter = useDebounceState(0, 1000);
useWS<IServiceEventMinimal>(
`/live/events/${projectId}`,
(event) => {
if (event?.name) {
counter.set((prev) => prev + 1);
}
},
{
debounce: {
delay: 1000,
maxWait: 5000,
},
},
);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
counter.set(0);
onRefresh();
}}
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
>
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
)}
/>
</div>
{counter.debounced === 0 ? (
'Listening'
) : (
<AnimatedNumber value={counter.debounced} suffix=" new events" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter.debounced === 0
? 'Listening to new events'
: 'Click to refresh'}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,49 @@
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
interface Props {
country?: string;
city?: string;
os?: string;
os_version?: string;
browser?: string;
browser_version?: string;
referrer_name?: string;
referrer_type?: string;
}
export function ListPropertiesIcon({
country,
city,
os,
os_version,
browser,
browser_version,
referrer_name,
referrer_type,
}: Props) {
return (
<div className="flex gap-1.5">
{country && (
<Tooltiper content={[country, city].filter(Boolean).join(', ')}>
<SerieIcon name={country} />
</Tooltiper>
)}
{os && (
<Tooltiper content={`${os} (${os_version})`}>
<SerieIcon name={os} />
</Tooltiper>
)}
{browser && (
<Tooltiper content={`${browser} (${browser_version})`}>
<SerieIcon name={browser} />
</Tooltiper>
)}
{referrer_name && (
<Tooltiper content={`${referrer_name} (${referrer_type})`}>
<SerieIcon name={referrer_name} />
</Tooltiper>
)}
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { EventIcon } from '@/components/events/event-icon';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import type { IServiceEvent } from '@openpanel/db';
export function useColumns() {
const number = useNumber();
const columns: ColumnDef<IServiceEvent>[] = [
{
size: 300,
accessorKey: 'name',
header: 'Name',
cell({ row }) {
const { name, path, duration } = row.original;
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
return <span className="max-w-md truncate">{path}</span>;
}
return (
<>
<span className="text-muted-foreground">Screen: </span>
<span className="max-w-md truncate">{path}</span>
</>
);
}
return name.replace(/_/g, ' ');
};
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
return (
<div className="flex items-center gap-2">
<button
type="button"
className="transition-transform hover:scale-105"
onClick={() => {
pushModal('EditEvent', {
id: row.original.id,
});
}}
>
<EventIcon
size="sm"
name={row.original.name}
meta={row.original.meta}
/>
</button>
<span className="flex gap-2">
<button
type="button"
onClick={() => {
pushModal('EventDetails', {
id: row.original.id,
createdAt: row.original.createdAt,
projectId: row.original.projectId,
});
}}
className="font-medium"
>
{renderName()}
</button>
{renderDuration()}
</span>
</div>
);
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
size: 170,
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
{
accessorKey: 'profileId',
header: 'Profile',
cell({ row }) {
const { profile, profileId, deviceId } = row.original;
if (profile) {
return (
<ProjectLink
href={`/profiles/${profile.id}`}
className="whitespace-nowrap font-medium hover:underline"
>
{getProfileName(profile)}
</ProjectLink>
);
}
if (profileId && profileId !== deviceId) {
return (
<ProjectLink
href={`/profiles/${profileId}`}
className="whitespace-nowrap font-medium hover:underline"
>
Unknown
</ProjectLink>
);
}
if (deviceId) {
return (
<ProjectLink
href={`/profiles/${deviceId}`}
className="whitespace-nowrap font-medium hover:underline"
>
Anonymous
</ProjectLink>
);
}
return null;
},
},
{
accessorKey: 'sessionId',
header: 'Session ID',
size: 320,
},
{
accessorKey: 'deviceId',
header: 'Device ID',
size: 320,
},
{
accessorKey: 'country',
header: 'Country',
size: 150,
cell({ row }) {
const { country, city } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={country} />
<span className="truncate">{city}</span>
</div>
);
},
},
{
accessorKey: 'os',
header: 'OS',
size: 130,
cell({ row }) {
const { os } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={os} />
<span className="truncate">{os}</span>
</div>
);
},
},
{
accessorKey: 'browser',
header: 'Browser',
size: 110,
cell({ row }) {
const { browser } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={browser} />
<span className="truncate">{browser}</span>
</div>
);
},
},
{
accessorKey: 'properties',
header: 'Properties',
size: 400,
cell({ row }) {
const { properties } = row.original;
const filteredProperties = Object.fromEntries(
Object.entries(properties || {}).filter(
([key]) => !key.startsWith('__'),
),
);
const items = Object.entries(filteredProperties);
return (
<div className="row flex-wrap gap-x-4 gap-y-1 overflow-hidden text-sm">
{items.slice(0, 4).map(([key, value]) => (
<div key={key} className="row items-center gap-1 min-w-0">
<span className="text-muted-foreground">{key}</span>
<span className="truncate font-medium">{String(value)}</span>
</div>
))}
{items.length > 5 && (
<span className="truncate">{items.length - 5} more</span>
)}
</div>
);
},
},
];
return columns;
}

View File

@@ -0,0 +1,298 @@
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from '@/components/ui/popover';
import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { Button } from '@/components/ui/button';
import { DataTableToolbarContainer } from '@/components/ui/data-table/data-table-toolbar';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
import { cn } from '@/utils/cn';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns';
import throttle from 'lodash.throttle';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda';
import { memo, useEffect, useRef, useState } from 'react';
import { useInViewport } from 'react-in-viewport';
import { useLocalStorage } from 'usehooks-ts';
import EventListener from '../event-listener';
import { EventItem, EventItemSkeleton } from './item';
export const useEventsViewOptions = () => {
return useLocalStorage<Record<string, boolean | undefined>>(
'@op:events-table-view-options',
{
properties: false,
},
);
};
type Props = {
query: UseInfiniteQueryResult<
TRPCInfiniteData<
RouterInputs['event']['events'],
RouterOutputs['event']['events']
>,
unknown
>;
};
export const EventsTable = memo(
({ query }: Props) => {
const [viewOptions] = useEventsViewOptions();
const { isLoading } = query;
const parentRef = useRef<HTMLDivElement>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const inViewportRef = useRef<HTMLDivElement>(null);
const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, {
disconnectOnLeave: true,
});
const data = query.data?.pages?.flatMap((p) => p.data) ?? [];
const virtualizer = useWindowVirtualizer({
count: data.length,
estimateSize: () => 55,
scrollMargin,
overscan: 10,
});
useEffect(() => {
const updateScrollMargin = throttle(() => {
if (parentRef.current) {
setScrollMargin(
parentRef.current.getBoundingClientRect().top + window.scrollY,
);
}
}, 500);
// Initial calculation
updateScrollMargin();
// Listen for resize events
window.addEventListener('resize', updateScrollMargin);
return () => {
window.removeEventListener('resize', updateScrollMargin);
};
}, []);
useEffect(() => {
virtualizer.measure();
}, [viewOptions, virtualizer]);
const hasNextPage = last(query.data?.pages ?? [])?.meta.next;
useEffect(() => {
if (
hasNextPage &&
data.length > 0 &&
inViewport &&
enterCount > 0 &&
query.isFetchingNextPage === false
) {
query.fetchNextPage();
}
}, [inViewport, enterCount, hasNextPage]);
const visibleItems = virtualizer.getVirtualItems();
return (
<>
<EventsTableToolbar query={query} />
<div ref={parentRef} className="w-full">
{isLoading && (
<div className="w-full gap-2 col">
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
</div>
)}
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No events"
description={"Start sending events and you'll see them here"}
/>
)}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{visibleItems.map((virtualRow) => (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
paddingBottom: '8px', // Gap between items
}}
>
<EventItem
event={data[virtualRow.index]!}
viewOptions={viewOptions}
/>
</div>
))}
</div>
</div>
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div
className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin" />
</div>
</div>
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'query.isFetchingNextPage']),
);
function EventsTableToolbar({
query,
}: {
query: Props['query'];
}) {
const { projectId } = useAppParams();
const [startDate, setStartDate] = useQueryState(
'startDate',
parseAsIsoDateTime,
);
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
return (
<DataTableToolbarContainer>
<div className="flex flex-1 flex-wrap items-center gap-2">
<EventListener onRefresh={() => query.refetch()} />
<Button
variant="outline"
size="sm"
icon={CalendarIcon}
onClick={() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
setStartDate(startDate);
setEndDate(endDate);
},
startDate: startDate || undefined,
endDate: endDate || undefined,
});
}}
>
{startDate && endDate
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
: 'Date range'}
</Button>
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
</div>
<EventsViewOptions />
</DataTableToolbarContainer>
);
}
export function EventsViewOptions() {
const [viewOptions, setViewOptions] = useEventsViewOptions();
const columns = {
origin: 'Show origin',
queryString: 'Show query string',
referrer: 'Referrer',
country: 'Country',
os: 'OS',
browser: 'Browser',
profileId: 'Profile',
createdAt: 'Created at',
properties: 'Properties',
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
aria-label="Toggle columns"
role="combobox"
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex"
>
<Settings2Icon className="size-4 mr-2" />
View
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent align="end" className="w-44 p-0">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
{Object.entries(columns).map(([column, label]) => (
<CommandItem
key={column}
onSelect={() =>
setViewOptions({
...viewOptions,
// biome-ignore lint/complexity/noUselessTernary: we need this this viewOptions[column] can be undefined
[column]: viewOptions[column] === false ? true : false,
})
}
>
<span className="truncate">{label}</span>
<Check
className={cn(
'ml-auto size-4 shrink-0',
viewOptions[column] !== false
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -0,0 +1,178 @@
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { formatTimeAgoOrDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { IServiceEvent } from '@openpanel/db';
import { memo } from 'react';
import { Skeleton } from '../../skeleton';
import { EventIcon } from '../event-icon';
interface EventItemProps {
event: IServiceEvent | Record<string, never>;
viewOptions: Record<string, boolean | undefined>;
className?: string;
}
export const EventItem = memo<EventItemProps>(
({ event, viewOptions, className }) => {
let url: string | null = '';
if (event.path && event.origin) {
if (viewOptions.origin !== false && event.origin) {
url += event.origin;
}
url += event.path;
const query = Object.entries(event.properties || {})
.filter(([key]) => key.startsWith('__query'))
.map(([key, value]) => [key.replace('__query.', ''), value]);
if (viewOptions.queryString !== false && query.length) {
query.forEach(([key, value], index) => {
url += `${index === 0 ? '?' : '&'}${key}=${value}`;
});
}
}
return (
<div className={cn('group card @container overflow-hidden', className)}>
<div
onClick={() => {
pushModal('EventDetails', {
id: event.id,
projectId: event.projectId,
createdAt: event.createdAt,
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
pushModal('EventDetails', {
id: event.id,
projectId: event.projectId,
createdAt: event.createdAt,
});
}
}}
data-slot="inner"
className={cn(
'col gap-2 flex-1 p-2',
// Desktop
'@lg:row @lg:items-center',
'cursor-pointer',
event.meta?.color
? `hover:bg-${event.meta.color}-50 dark:hover:bg-${event.meta.color}-900`
: 'hover:bg-def-200',
)}
>
<div className="min-w-0 flex-1 row items-center gap-4">
<button
type="button"
className="transition-transform hover:scale-105"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
pushModal('EditEvent', {
id: event.id,
});
}}
>
<EventIcon name={event.name} size="sm" meta={event.meta} />
</button>
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all">
{event.name === 'screen_view' ? (
<>
<span className="text-muted-foreground mr-2">Visit:</span>
<span className="font-medium min-w-0">
{url ? url : event.path}
</span>
</>
) : (
<>
<span className="text-muted-foreground mr-2">Event:</span>
<span className="font-medium">{event.name}</span>
</>
)}
</span>
</div>
<div className="row gap-2 items-center @max-lg:pl-10">
{event.referrerName && viewOptions.referrerName !== false && (
<Pill
icon={<SerieIcon className="mr-2" name={event.referrerName} />}
>
<span>{event.referrerName}</span>
</Pill>
)}
{event.os && viewOptions.os !== false && (
<Pill icon={<SerieIcon name={event.os} />}>{event.os}</Pill>
)}
{event.browser && viewOptions.browser !== false && (
<Pill icon={<SerieIcon name={event.browser} />}>
{event.browser}
</Pill>
)}
{event.country && viewOptions.country !== false && (
<Pill icon={<SerieIcon name={event.country} />}>
{event.country}
</Pill>
)}
{viewOptions.profileId !== false && (
<Pill
className="@max-xl:ml-auto @max-lg:[&>span]:inline mx-4"
icon={<ProfileAvatar size="xs" {...event.profile} />}
>
{getProfileName(event.profile)}
</Pill>
)}
{viewOptions.createdAt !== false && (
<span className="text-sm text-neutral-500">
{formatTimeAgoOrDateTime(event.createdAt)}
</span>
)}
</div>
</div>
{viewOptions.properties !== false && (
<div
data-slot="extra"
className="border-t border-neutral-200 p-4 py-2 bg-def-100"
>
<pre className="text-sm leading-tight">
{JSON.stringify(event.properties, null, 2)}
</pre>
</div>
)}
</div>
);
},
);
export const EventItemSkeleton = () => {
return (
<div className="card h-10 p-2 gap-4 row items-center">
<Skeleton className="size-6 rounded-full" />
<Skeleton className="w-1/2 h-3" />
<div className="row gap-2 ml-auto">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 w-14" />
</div>
</div>
);
};
function Pill({
children,
icon,
className,
}: { children: React.ReactNode; icon?: React.ReactNode; className?: string }) {
return (
<div
className={cn(
'shrink-0 whitespace-nowrap inline-flex gap-2 items-center rounded-full @3xl:text-muted-foreground h-6 text-xs font-mono',
className,
)}
>
{icon && <div className="size-4 center-center">{icon}</div>}
<div className="hidden @3xl:inline">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { cn } from '@/utils/cn';
import { useEffect, useRef } from 'react';
type Props = {
className?: string;
children: React.ReactNode;
};
export function FadeIn({ className, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
ref.current.classList.remove('opacity-0');
ref.current.classList.add('opacity-100');
}
}, []);
return (
<div
className={cn('opacity-0 transition-opacity duration-500', className)}
ref={ref}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { op } from '@/utils/op';
import { useLocation, useRouteContext } from '@tanstack/react-router';
import { SparklesIcon } from 'lucide-react';
import { Button } from './ui/button';
export function FeedbackButton() {
const context = useRouteContext({ strict: false });
return (
<Button
variant={'outline'}
className="w-full text-left justify-start [&_svg]:mx-2"
icon={SparklesIcon}
onClick={() => {
op.track('feedback_button_clicked');
if ('uj' in window) {
(window.uj as any).identify({
id: context.session?.userId,
firstName: context.session?.user?.firstName,
});
(window.uj as any).showWidget();
}
}}
>
Give feedback
</Button>
);
}

View File

@@ -0,0 +1,54 @@
import { cn } from '@/utils/cn';
import { slug } from '@/utils/slug';
import type { LucideIcon } from 'lucide-react';
import { forwardRef } from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Switch } from '../ui/switch';
type Props = {
label: string;
description: string;
Icon: LucideIcon;
children?: React.ReactNode;
error?: string;
} & ControllerRenderProps;
export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
(
{ label, description, Icon, children, onChange, value, disabled, error },
ref,
) => {
const id = slug(label);
return (
<div>
<label
className={cn(
'flex items-center gap-4 px-4 py-6 transition-colors hover:bg-def-200',
disabled && 'cursor-not-allowed opacity-50',
)}
htmlFor={id}
>
{Icon && <div className="w-6 shrink-0">{<Icon />}</div>}
<div className="flex-1">
<div className="font-medium">{label}</div>
<div className=" text-muted-foreground">{description}</div>
{error && <div className="text-sm text-red-600">{error}</div>}
</div>
<div>
<Switch
ref={ref}
disabled={disabled}
checked={!!value}
onCheckedChange={onChange}
id={id}
/>
</div>
</label>
{children}
</div>
);
},
);
CheckboxItem.displayName = 'CheckboxItem';

View File

@@ -0,0 +1,29 @@
import { clipboard } from '@/utils/clipboard';
import { cn } from '@/utils/cn';
import { CopyIcon } from 'lucide-react';
import { Label } from '../ui/label';
type Props = {
label: React.ReactNode;
value: string;
className?: string;
};
const CopyInput = ({ label, value, className }: Props) => {
return (
<button
type="button"
className={cn('w-full text-left', className)}
onClick={() => clipboard(value)}
>
{!!label && <Label>{label}</Label>}
<div className="font-mono flex items-center justify-between rounded bg-muted p-2 px-3 ">
{value}
<CopyIcon size={16} />
</div>
</button>
);
};
export default CopyInput;

View File

@@ -0,0 +1,68 @@
import { BanIcon, InfoIcon } from 'lucide-react';
import { forwardRef } from 'react';
import { Input } from '../ui/input';
import type { InputProps } from '../ui/input';
import { Label } from '../ui/label';
import { Tooltiper } from '../ui/tooltip';
type WithLabel = {
children: React.ReactNode;
label: string;
error?: string | undefined;
info?: React.ReactNode;
className?: string;
};
type InputWithLabelProps = InputProps & Omit<WithLabel, 'children'>;
export const WithLabel = ({
children,
className,
label,
info,
error,
}: WithLabel) => {
return (
<div className={className}>
<div className="mb-2 flex items-end justify-between">
<Label
className="mb-0 flex flex-1 shrink-0 items-center gap-1 whitespace-nowrap"
htmlFor={label}
>
{label}
{info && (
<Tooltiper content={info}>
<InfoIcon size={14} />
</Tooltiper>
)}
</Label>
{error && (
<Tooltiper
asChild
content={error}
tooltipClassName="max-w-80 leading-normal"
align="end"
>
<div className="flex items-center gap-1 leading-none text-destructive">
Issues
<BanIcon size={14} />
</div>
</Tooltiper>
)}
</div>
{children}
</div>
);
};
export const InputWithLabel = forwardRef<HTMLInputElement, InputWithLabelProps>(
(props, ref) => {
return (
<WithLabel {...props}>
<Input ref={ref} id={props.label} {...props} />
</WithLabel>
);
},
);
InputWithLabel.displayName = 'InputWithLabel';

View File

@@ -0,0 +1,152 @@
// Based on Christin Alares tag input component (https://github.com/christianalares/seventy-seven)
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { useAnimate } from 'framer-motion';
import { XIcon } from 'lucide-react';
import type { ElementRef } from 'react';
import { useEffect, useRef, useState } from 'react';
type Props = {
placeholder: string;
value: string[];
error?: string;
className?: string;
onChange: (value: string[]) => void;
renderTag?: (tag: string) => string;
id?: string;
};
const TagInput = ({
value: propValue,
onChange,
renderTag,
placeholder,
error,
id,
}: Props) => {
const value = (
Array.isArray(propValue) ? propValue : propValue ? [propValue] : []
).filter(Boolean);
const [isMarkedForDeletion, setIsMarkedForDeletion] = useState(false);
const inputRef = useRef<ElementRef<'input'>>(null);
const [inputValue, setInputValue] = useState('');
const [scope, animate] = useAnimate();
const appendTag = (tag: string) => {
onChange([...value, tag.trim()]);
};
const removeTag = (tag: string) => {
onChange(value.filter((t) => t !== tag));
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
const tagAlreadyExists = value.some(
(tag) => tag.toLowerCase() === inputValue.toLowerCase(),
);
if (inputValue) {
if (tagAlreadyExists) {
animate(
`span[data-tag="${inputValue.toLowerCase()}"]`,
{
scale: [1, 1.3, 1],
},
{
duration: 0.3,
},
);
return;
}
appendTag(inputValue);
setInputValue('');
}
}
if (e.key === 'Backspace' && inputValue === '') {
if (!isMarkedForDeletion) {
setIsMarkedForDeletion(true);
return;
}
const last = value[value.length - 1];
if (last) {
removeTag(last);
}
setIsMarkedForDeletion(false);
setInputValue('');
}
};
const handleBlur = () => {
if (inputValue) {
appendTag(inputValue);
setInputValue('');
}
};
useEffect(() => {
if (inputValue.length > 0) {
setIsMarkedForDeletion(false);
}
}, [inputValue]);
return (
<div
ref={scope}
className={cn(
'inline-flex w-full flex-wrap items-center gap-2 rounded-md border border-input p-1 px-3 ring-offset-background has-[input:focus]:ring-2 has-[input:focus]:ring-ring has-[input:focus]:ring-offset-1 bg-card',
!!error && 'border-destructive',
)}
>
{value.map((tag, i) => {
const isCreating = false;
return (
<span
data-tag={tag}
key={tag}
className={cn(
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 ',
isMarkedForDeletion &&
i === value.length - 1 &&
'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1',
isCreating && 'opacity-60',
)}
>
{renderTag ? renderTag(tag) : tag}
<Button
size="icon"
variant="outline"
className="h-4 w-4 rounded-full"
onClick={() => removeTag(tag)}
>
<span className="sr-only">Remove tag</span>
<XIcon name="close" className="size-3" />
</Button>
</span>
);
})}
<input
ref={inputRef}
placeholder={`${placeholder}`}
className="min-w-20 flex-1 py-1 focus-visible:outline-none bg-card"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
id={id}
/>
</div>
);
};
export default TagInput;

View File

@@ -0,0 +1,39 @@
import { cn } from '@/utils/cn';
import { BoxSelectIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { PageHeader } from './page-header';
interface FullPageEmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
children?: React.ReactNode;
className?: string;
}
export function FullPageEmptyState({
icon: Icon = BoxSelectIcon,
title,
description,
children,
className,
}: FullPageEmptyStateProps) {
return (
<div
className={cn(
'flex items-center justify-center p-4 text-center',
className,
)}
>
<div className="flex w-full max-w-xl flex-col items-center justify-center p-8">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-card shadow-sm">
<Icon size={60} strokeWidth={1} />
</div>
<PageHeader title={title} description={description} className="mb-4" />
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { ServerCrashIcon } from 'lucide-react';
import { FullPageEmptyState } from './full-page-empty-state';
export const FullPageErrorState = ({
title = 'Error...',
description = 'Something went wrong...',
children,
}: { title?: string; description?: string; children?: React.ReactNode }) => {
return (
<FullPageEmptyState
className="min-h-[calc(100vh-theme(spacing.16))]"
title={title}
icon={ServerCrashIcon}
>
{description}
{children && <div className="mt-4">{children}</div>}
</FullPageEmptyState>
);
};

View File

@@ -0,0 +1,25 @@
import type { LucideIcon } from 'lucide-react';
import { Loader2Icon } from 'lucide-react';
import { FullPageEmptyState } from './full-page-empty-state';
const FullPageLoadingState = ({
title = 'Fetching...',
description = 'Please wait while we fetch your data...',
}: { title?: string; description?: string }) => {
return (
<FullPageEmptyState
className="min-h-[calc(100vh-theme(spacing.16))]"
title={title}
icon={
((props) => (
<Loader2Icon {...props} className="animate-spin" />
)) as LucideIcon
}
>
{description}
</FullPageEmptyState>
);
};
export default FullPageLoadingState;

View File

@@ -0,0 +1,21 @@
import { cn } from '@/utils/cn';
import { LogoSquare } from './logo';
type Props = {
children: React.ReactNode;
className?: string;
};
const FullWidthNavbar = ({ children, className }: Props) => {
return (
<div className={cn('border-b border-border bg-card', className)}>
<div className="mx-auto flex h-14 w-full items-center justify-between px-4 md:w-[95vw] lg:w-[80vw] max-w-screen-2xl">
<LogoSquare className="size-8" />
{children}
</div>
</div>
);
};
export default FullWidthNavbar;

View File

@@ -0,0 +1,109 @@
import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { ChevronLeftIcon, FullscreenIcon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useEffect, useRef, useState } from 'react';
import { useDebounce } from 'usehooks-ts';
import { Button } from './ui/button';
import { Tooltiper } from './ui/tooltip';
type Props = {
children: React.ReactNode;
className?: string;
};
export const useFullscreen = () =>
useQueryState(
'fullscreen',
parseAsBoolean.withDefault(false).withOptions({
history: 'push',
}),
);
export const Fullscreen = (props: Props) => {
const [isFullscreen] = useFullscreen();
return (
<div
className={cn(
isFullscreen
? 'fixed inset-0 z-50 overflow-auto bg-def-200'
: 'w-full min-h-full col',
)}
>
{props.children}
</div>
);
};
export const FullscreenOpen = () => {
const [fullscreen, setIsFullscreen] = useFullscreen();
if (fullscreen) {
return null;
}
return (
<Tooltiper content="Toggle fullscreen" asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
setIsFullscreen((p) => !p);
}}
>
<FullscreenIcon className="size-4" />
</Button>
</Tooltiper>
);
};
export const FullscreenClose = () => {
const [fullscreen, setIsFullscreen] = useFullscreen();
const isFullscreenDebounced = useDebounce(fullscreen, 1000);
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
let timer: any;
const unsub = bind(window, {
type: 'mousemove',
listener(ev) {
if (fullscreen) {
setVisible(true);
clearTimeout(timer);
timer = setTimeout(() => {
if (!ref.current?.contains(ev.target as Node)) {
setVisible(false);
}
}, 500);
}
},
});
return () => {
unsub();
clearTimeout(timer);
};
}, [fullscreen]);
if (!fullscreen) {
return null;
}
return (
<div className="fixed bottom-0 top-0 z-50 flex items-center">
<Tooltiper content="Exit full screen" asChild>
<button
type="button"
ref={ref}
className={cn(
'flex h-20 w-20 -translate-x-20 items-center justify-center rounded-full bg-foreground transition-transform',
visible && isFullscreenDebounced && '-translate-x-10',
)}
onClick={() => {
setIsFullscreen(false);
}}
>
<ChevronLeftIcon className="ml-6 text-background" />
</button>
</Tooltiper>
</div>
);
};

View File

@@ -0,0 +1,87 @@
import { cn } from '@/utils/cn';
export const Grid: React.FC<
React.HTMLAttributes<HTMLDivElement> & { columns: number }
> = ({ className, columns, children, ...props }) => (
<div className={cn('card', className)}>
<div className="relative w-full overflow-auto rounded-md">
<div
className={cn('grid w-full')}
style={{
gridTemplateColumns: `repeat(${columns}, auto)`,
width: 'max-content',
minWidth: '100%',
}}
{...props}
>
{children}
</div>
</div>
</div>
);
export const GridHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div className={cn('contents', className)} {...props}>
{children}
</div>
);
export const GridBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div
className={cn('contents [&>*:last-child]:border-0', className)}
{...props}
>
{children}
</div>
);
export const GridCell: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
as?: React.ElementType;
colSpan?: number;
isHeader?: boolean;
}
> = ({
className,
children,
as: Component = 'div',
colSpan,
isHeader,
...props
}) => (
<Component
className={cn(
'flex min-h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
isHeader && 'h-10 bg-def-100 font-semibold text-muted-foreground',
colSpan && `col-span-${colSpan}`,
className,
)}
{...props}
>
<div className="truncate w-full">{children}</div>
</Component>
);
export const GridRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div
className={cn(
'contents transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
>
{children}
</div>
);

View File

@@ -0,0 +1,121 @@
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { BoxSelectIcon } from 'lucide-react';
import { useMemo } from 'react';
import { PingBadge } from '../ping';
import { Button } from '../ui/button';
import {
IntegrationCard,
IntegrationCardFooter,
IntegrationCardLogo,
IntegrationCardSkeleton,
} from './integration-card';
import { INTEGRATIONS } from './integrations';
export function ActiveIntegrations() {
const { organizationId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.integration.list.queryOptions({
organizationId: organizationId!,
}),
);
const client = useQueryClient();
const deletion = useMutation(
trpc.integration.delete.mutationOptions({
onSuccess() {
client.refetchQueries(
trpc.integration.list.queryFilter({
organizationId,
}),
);
},
}),
);
const data = useMemo(() => {
return (query.data || [])
.map((item) => {
const integration = INTEGRATIONS.find(
(integration) => integration.type === item.config.type,
)!;
return {
...item,
integration,
};
})
.filter((item) => item.integration);
}, [query.data]);
const isLoading = query.isLoading;
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 auto-rows-auto">
{isLoading && (
<>
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
</>
)}
{!isLoading && data.length === 0 && (
<IntegrationCard
icon={
<IntegrationCardLogo className="bg-def-200 text-foreground">
<BoxSelectIcon className="size-10" strokeWidth={1} />
</IntegrationCardLogo>
}
name="No integrations yet"
description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section."
/>
)}
<AnimatePresence mode="popLayout">
{data.map((item) => {
return (
<motion.div key={item.id} layout="position">
<IntegrationCard {...item.integration} name={item.name}>
<IntegrationCardFooter className="row justify-between items-center">
<PingBadge>Connected</PingBadge>
<div className="row gap-2">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
showConfirm({
title: `Delete ${item.name}?`,
text: 'This action cannot be undone.',
onConfirm: () => {
deletion.mutate({
id: item.id,
});
},
});
}}
>
Delete
</Button>
<Button
variant="ghost"
onClick={() => {
pushModal('AddIntegration', {
id: item.id,
type: item.config.type,
});
}}
>
Edit
</Button>
</div>
</IntegrationCardFooter>
</IntegrationCard>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlugIcon } from 'lucide-react';
import { IntegrationCard, IntegrationCardFooter } from './integration-card';
import { INTEGRATIONS } from './integrations';
export function AllIntegrations() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{INTEGRATIONS.map((integration) => (
<IntegrationCard
key={integration.name}
icon={integration.icon}
name={integration.name}
description={integration.description}
>
<IntegrationCardFooter className="row justify-end">
<Button
variant="outline"
onClick={() => {
pushModal('AddIntegration', {
type: integration.type,
});
}}
>
<PlugIcon className="size-4 mr-2" />
Connect
</Button>
</IntegrationCardFooter>
</IntegrationCard>
))}
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { sendTestDiscordNotification } from '@openpanel/integrations/src/discord';
import { zCreateDiscordIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { path, mergeDeepRight } from 'ramda';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateDiscordIntegration>;
export function DiscordIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
organizationId,
projectId,
config: {
type: 'discord' as const,
url: '',
headers: {},
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateDiscordIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
const handleTest = async () => {
const webhookUrl = form.getValues('config.url');
if (!webhookUrl) {
return toast.error('Webhook URL is required');
}
const res = await sendTestDiscordNotification(webhookUrl);
if (res.ok) {
toast.success('Test notification sent');
} else {
toast.error('Failed to send test notification');
}
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. My personal discord"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="Discord Webhook URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<div className="row gap-4">
<Button type="button" variant="outline" onClick={handleTest}>
Test connection
</Button>
<Button type="submit" className="flex-1">
Create
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,68 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateSlackIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateSlackIntegration>;
export function SlackIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: {
id: defaultValues?.id,
organizationId,
projectId,
name: defaultValues?.name ?? '',
},
resolver: zodResolver(zCreateSlackIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdateSlack.mutationOptions({
async onSuccess(res) {
window.location.href = res.slackInstallUrl;
onSuccess();
},
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. My personal slack"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -0,0 +1,77 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateWebhookIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { path, mergeDeepRight } from 'ramda';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateWebhookIntegration>;
export function WebhookIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
organizationId,
projectId,
config: {
type: 'webhook' as const,
url: '',
headers: {},
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateWebhookIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. Zapier webhook"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -0,0 +1,144 @@
import { Skeleton } from '@/components/skeleton';
import { cn } from '@/utils/cn';
export function IntegrationCardFooter({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('row p-4 border-t rounded-b', className)}>
{children}
</div>
);
}
export function IntegrationCardHeader({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('relative row p-4 border-b rounded-t', className)}>
{children}
</div>
);
}
export function IntegrationCardHeaderButtons({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'absolute right-4 top-0 bottom-0 row items-center gap-2',
className,
)}
>
{children}
</div>
);
}
export function IntegrationCardLogoImage({
src,
backgroundColor,
}: {
src: string;
backgroundColor: string;
}) {
return (
<IntegrationCardLogo
style={{
backgroundColor,
}}
>
<img src={src} alt="Integration Logo" />
</IntegrationCardLogo>
);
}
export function IntegrationCardLogo({
children,
className,
...props
}: {
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'size-14 rounded overflow-hidden shrink-0 center-center',
className,
)}
{...props}
>
{children}
</div>
);
}
export function IntegrationCard({
icon,
name,
description,
children,
}: {
icon: React.ReactNode;
name: string;
description: string;
children?: React.ReactNode;
}) {
return (
<div className="card self-start">
<IntegrationCardContent
icon={icon}
name={name}
description={description}
/>
{children}
</div>
);
}
export function IntegrationCardContent({
icon,
name,
description,
}: {
icon: React.ReactNode;
name: string;
description: string;
}) {
return (
<div className="row gap-4 p-4">
{icon}
<div className="col gap-1">
<h2 className="title">{name}</h2>
<p className="text-muted-foreground leading-tight">{description}</p>
</div>
</div>
);
}
export function IntegrationCardSkeleton() {
return (
<div className="card self-start">
<div className="row gap-4 p-4">
<Skeleton className="size-14 rounded shrink-0" />
<div className="col gap-1 flex-grow">
<Skeleton className="h-5 w-1/2 mb-2" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { IIntegrationConfig } from '@openpanel/validation';
import { WebhookIcon } from 'lucide-react';
import {
IntegrationCardLogo,
IntegrationCardLogoImage,
} from './integration-card';
export const INTEGRATIONS: {
type: IIntegrationConfig['type'];
name: string;
description: string;
icon: React.ReactNode;
}[] = [
{
type: 'slack',
name: 'Slack',
description:
'Connect your Slack workspace to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://play-lh.googleusercontent.com/mzJpTCsTW_FuR6YqOPaLHrSEVCSJuXzCljdxnCKhVZMcu6EESZBQTCHxMh8slVtnKqo"
backgroundColor="#481449"
/>
),
},
{
type: 'discord',
name: 'Discord',
description:
'Connect your Discord server to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://static.vecteezy.com/system/resources/previews/006/892/625/non_2x/discord-logo-icon-editorial-free-vector.jpg"
backgroundColor="#5864F2"
/>
),
},
{
type: 'webhook',
name: 'Webhook',
description:
'Create a webhook to take actions in your own systems when new events are created.',
icon: (
<IntegrationCardLogo className="bg-foreground text-background">
<WebhookIcon className="size-10" />
</IntegrationCardLogo>
),
},
];

View File

@@ -0,0 +1,79 @@
import { type ReactNode, type RefObject, useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
export interface LazyComponentProps {
/**
* Whether to enable lazy loading. If false, component renders immediately.
* @default true
*/
lazy?: boolean;
/**
* Content to render when the component is in viewport (or immediately if lazy=false)
*/
children: ReactNode;
/**
* Optional loading placeholder to show while waiting for viewport intersection
*/
fallback?: ReactNode;
/**
* Additional className for the wrapper div
*/
className?: string;
/**
* Custom viewport options for intersection observer
*/
viewportOptions?: {
rootMargin?: string;
threshold?: number | number[];
};
/**
* Whether to disconnect the intersection observer after first load
* @default true
*/
disconnectOnLeave?: boolean;
}
/**
* A reusable lazy loading component that renders its children only when
* they come into the viewport (or immediately if lazy=false).
*
* Uses intersection observer under the hood for efficient viewport detection.
*/
export const LazyComponent = ({
lazy = true,
children,
fallback = null,
className,
viewportOptions,
disconnectOnLeave = true,
}: LazyComponentProps) => {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(
ref as RefObject<HTMLElement>,
viewportOptions,
{
disconnectOnLeave,
},
);
useEffect(() => {
if (inViewport) {
once.current = true;
}
}, [inViewport]);
const shouldRender = lazy ? once.current || inViewport : true;
return (
<div ref={ref} className={className}>
{shouldRender ? children : (fallback ?? <div />)}
</div>
);
};

View File

@@ -0,0 +1,36 @@
import { useAppParams } from '@/hooks/use-app-params';
import { Link, type LinkComponentProps } from '@tanstack/react-router';
import { omit } from 'ramda';
export function ProjectLink({
children,
...props
}: LinkComponentProps & {
children: React.ReactNode;
className?: string;
title?: string;
exact?: boolean;
}) {
const { organizationId, projectId } = useAppParams();
if (typeof props.href === 'string') {
return (
<Link
to={
`/$organizationId/$projectId/${props.href.replace(/^\//, '')}` as any
}
activeOptions={{ exact: props.exact ?? true }}
params={
{
organizationId,
projectId,
} as any
}
{...omit(['href'], props)}
>
{children}
</Link>
);
}
return <p>ProjectLink</p>;
}

View File

@@ -0,0 +1,104 @@
import { LogoSquare } from '@/components/logo';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { SellingPoint } from './selling-points';
const sellingPoints = [
{
key: 'welcome',
render: () => (
<SellingPoint
bgImage="/img-1.png"
title="Best open-source alternative"
description="Mixpanel to expensive, Google Analytics has no privacy, Amplitude old and boring"
/>
),
},
{
key: 'selling-point-2',
render: () => (
<SellingPoint
bgImage="/img-2.png"
title="Fast and reliable"
description="Never miss a beat with our real-time analytics"
/>
),
},
{
key: 'selling-point-3',
render: () => (
<SellingPoint
bgImage="/img-3.png"
title="Easy to use"
description="Compared to other tools we have kept it simple"
/>
),
},
{
key: 'selling-point-4',
render: () => (
<SellingPoint
bgImage="/img-4.png"
title="Privacy by default"
description="We have built our platform with privacy at its heart"
/>
),
},
{
key: 'selling-point-5',
render: () => (
<SellingPoint
bgImage="/img-5.png"
title="Open source"
description="You can inspect the code and self-host if you choose"
/>
),
},
];
export function LoginLeftPanel() {
return (
<div className="relative h-screen overflow-hidden">
<div className="row justify-between items-center p-8">
<LogoSquare className="h-8 w-8" />
<a
href="https://openpanel.dev"
className="text-sm text-muted-foreground"
>
Back to website
</a>
</div>
{/* Carousel */}
<div className="flex items-center justify-center h-full">
<Carousel
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
opts={{
loop: true,
align: 'center',
}}
>
<CarouselContent className="h-full">
{sellingPoints.map((point, index) => (
<CarouselItem
key={`selling-point-${point.key}`}
className="p-8 pb-32 pt-0"
>
<div className="rounded-xl min-h-full h-full overflow-hidden bg-card border border-border shadow-lg">
{point.render()}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-12 bottom-30 top-auto" />
<CarouselNext className="right-12 bottom-30 top-auto" />
</Carousel>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from '@/utils/cn';
interface LogoProps {
className?: string;
}
export function LogoSquare({ className }: LogoProps) {
return (
<img
src="/logo.svg"
className={cn('rounded-md', className)}
alt="Openpanel logo"
/>
);
}
export function Logo({ className }: LogoProps) {
return (
<div
className={cn('flex items-center gap-2 text-xl font-medium', className)}
>
<LogoSquare className="max-h-8" />
<span>openpanel.dev</span>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { memo } from 'react';
import ReactMarkdown, { type Options } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkHighlight from 'remark-highlight';
import remarkMath from 'remark-math';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import 'katex/dist/katex.min.css';
export const Markdown = memo<Options>(
(props) => (
<ReactMarkdown
{...props}
remarkPlugins={[remarkParse, remarkHighlight, remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, remarkRehype]}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
'className' in prevProps &&
'className' in nextProps &&
prevProps.className === nextProps.className,
);
Markdown.displayName = 'Markdown';

View File

@@ -0,0 +1,142 @@
import { EventListItem } from '@/components/events/event-list-item';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import type { IServiceEventMinimal } from '@openpanel/db';
const useWebEventGenerator = () => {
const [events, setEvents] = useState<IServiceEventMinimal[]>([]);
const eventNames = [
'screen_view',
'session_start',
'session_end',
'submit_form',
'sign_in',
'sign_up',
'purchase_flow',
'purchase_flow_completed',
'subscription_started',
];
const browsers = [
'Chrome WebView',
'Firefox',
'Safari',
'Edge',
'Chrome',
'Opera',
'Internet Explorer',
];
const paths = [
'/features/',
'/contact/',
'/about/',
'/pricing/',
'/blog/',
'/signup/',
'/login/',
];
const countries = [
'BY',
'US',
'FR',
'IN',
'DE',
'JP',
'BR',
'ZA',
'EG',
'AU',
'RU',
'CN',
'IT',
'GB',
'CA',
];
const os = [
'Windows',
'MacOS',
'iOS',
'Android',
'Linux',
'Chrome OS',
'Windows Phone',
];
// Function to generate a random event
const generateEvent = (index?: number): IServiceEventMinimal => {
const event = {
id: Math.random().toString(36).substring(2, 15),
name: eventNames[Math.floor(Math.random() * eventNames.length)]!,
projectId: 'marketing-site',
sessionId: Math.random().toString(36).substring(2, 15),
createdAt: new Date(new Date().getTime() - (index || 0) * 1000),
country: countries[Math.floor(Math.random() * countries.length)],
longitude: 27.5709,
latitude: 53.9007,
os: os[Math.floor(Math.random() * os.length)],
browser: browsers[Math.floor(Math.random() * browsers.length)],
device: 'mobile',
brand: 'Xiaomi',
duration: 0,
path: paths[Math.floor(Math.random() * paths.length)]!,
origin: 'https://www.voxie.com',
referrer: 'https://syndicatedsearch.goog',
meta: undefined,
minimal: true,
};
return event;
};
useEffect(() => {
let timer: NodeJS.Timeout;
// Generate initial 30 events
const initialEvents = Array.from({ length: 30 }).map((_, index) => {
return generateEvent(index);
});
setEvents(initialEvents);
function createNewEvent() {
const newEvent = generateEvent();
setEvents((prevEvents) => [newEvent, ...prevEvents]);
timer = setTimeout(() => createNewEvent(), Math.random() * 3000);
}
createNewEvent();
return () => clearInterval(timer);
}, []);
return events;
};
export const MockEventList = () => {
const state = useWebEventGenerator();
return (
<div className="hide-scrollbar h-screen overflow-y-auto">
<div className="text-background-foreground py-16 text-center text-2xl font-bold">
Real time data
<br />
at your fingertips
</div>
<AnimatePresence mode="popLayout" initial>
<div className="flex flex-col gap-4 p-4">
{state.map((event) => (
<motion.div
key={event.id}
layout
initial={{ opacity: 0, x: -400, scale: 0.5 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 200, scale: 1.2 }}
transition={{ duration: 0.6, type: 'spring' }}
>
<EventListItem {...event} minimal />
</motion.div>
))}
</div>
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { useAppParams } from '@/hooks/use-app-params';
import useWS from '@/hooks/use-ws';
import type { Notification } from '@openpanel/db';
import { BellIcon } from 'lucide-react';
import { toast } from 'sonner';
export function NotificationProvider() {
const { projectId } = useAppParams();
if (!projectId) return null;
return <InnerNotificationProvider projectId={projectId} />;
}
export function InnerNotificationProvider({
projectId,
}: { projectId: string }) {
useWS<Notification>(`/live/notifications/${projectId}`, (notification) => {
toast(notification.title, {
description: notification.message,
icon: <BellIcon className="size-4" />,
});
});
return null;
}

View File

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

View File

@@ -0,0 +1,16 @@
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { NotificationsTable } from './table';
export function Notifications() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.notification.list.queryOptions({
projectId,
}),
);
return <NotificationsTable query={query} />;
}

View File

@@ -0,0 +1,133 @@
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import type { RouterOutputs } from '@/trpc/client';
import type { NotificationRule } from '@openpanel/db';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FilterIcon } from 'lucide-react';
import { toast } from 'sonner';
import { ColorSquare } from '../color-square';
import {
IntegrationCardFooter,
IntegrationCardHeader,
} from '../integrations/integration-card';
import { PingBadge } from '../ping';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Tooltiper } from '../ui/tooltip';
function EventBadge({
event,
}: { event: NotificationRule['config']['events'][number] }) {
return (
<Tooltiper
disabled={!event.filters.length}
content={
<div className="col gap-2 font-mono">
{event.filters.map((filter) => (
<div key={filter.id}>
{filter.name} {filter.operator} {JSON.stringify(filter.value)}
</div>
))}
</div>
}
>
<Badge variant="outline" className="inline-flex">
{event.name === '*' ? 'Any event' : event.name}
{Boolean(event.filters.length) && (
<FilterIcon className="size-2 ml-1" />
)}
</Badge>
</Tooltiper>
);
}
export function RuleCard({
rule,
}: { rule: RouterOutputs['notification']['rules'][number] }) {
const trpc = useTRPC();
const client = useQueryClient();
const deletion = useMutation(
trpc.notification.deleteRule.mutationOptions({
onSuccess() {
toast.success('Rule deleted');
client.refetchQueries(trpc.notification.rules.pathFilter());
},
}),
);
const renderConfig = () => {
switch (rule.config.type) {
case 'events':
return (
<div className="row gap-2 items-baseline flex-wrap">
<div>Get notified when</div>
{rule.config.events.map((event) => (
<EventBadge key={event.id} event={event} />
))}
<div>occurs</div>
</div>
);
case 'funnel':
return (
<div className="col gap-4">
<div>Get notified when a session has completed this funnel</div>
<div className="col gap-2">
{rule.config.events.map((event, index) => (
<div
key={event.id}
className="row gap-2 items-center font-mono"
>
<ColorSquare>{index + 1}</ColorSquare>
<EventBadge key={event.id} event={event} />
</div>
))}
</div>
</div>
);
}
};
return (
<div className="card">
<IntegrationCardHeader>
<div className="title">{rule.name}</div>
</IntegrationCardHeader>
<div className="p-4 col gap-2">{renderConfig()}</div>
<IntegrationCardFooter className="row gap-2 justify-between items-center">
<div className="row gap-2 flex-wrap">
{rule.integrations.map((integration) => (
<PingBadge key={integration.id}>{integration.name}</PingBadge>
))}
</div>
<div className="row gap-2">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
showConfirm({
title: `Delete ${rule.name}?`,
text: 'This action cannot be undone.',
onConfirm: () => {
deletion.mutate({
id: rule.id,
});
},
});
}}
>
Delete
</Button>
<Button
variant="ghost"
onClick={() => {
pushModal('AddNotificationRule', {
rule,
});
}}
>
Edit
</Button>
</div>
</IntegrationCardFooter>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { formatDateTime, formatTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
import type { RouterOutputs } from '@/trpc/client';
import type { INotificationPayload } from '@openpanel/db';
function getEventFromPayload(payload: INotificationPayload | null) {
if (payload?.type === 'event') {
return payload.event;
}
if (payload?.type === 'funnel') {
return payload.funnel[0] || null;
}
return null;
}
export function useColumns() {
const columns: ColumnDef<RouterOutputs['notification']['list'][number]>[] = [
{
accessorKey: 'title',
header: 'Title',
cell({ row }) {
const { title } = row.original;
return (
<div className="row gap-2 items-center">
{/* {isReadAt === null && <PingBadge>Unread</PingBadge>} */}
<span className="max-w-md truncate font-medium">{title}</span>
</div>
);
},
meta: {
variant: 'text',
placeholder: 'Search',
label: 'Title',
},
},
{
accessorKey: 'message',
header: 'Message',
cell({ row }) {
const { message } = row.original;
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
{message}
</div>
);
},
meta: {
label: 'Message',
hidden: true,
},
},
{
accessorKey: 'integration',
header: 'Integration',
cell({ row }) {
const integration = row.original.integration;
return <div>{integration?.name}</div>;
},
meta: {
label: 'Integration',
},
},
{
accessorKey: 'notificationRule',
header: 'Rule',
cell({ row }) {
const rule = row.original.notificationRule;
return <div>{rule?.name}</div>;
},
meta: {
label: 'Rule',
hidden: true,
},
},
{
accessorKey: 'country',
header: 'Country',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={event.country} />
<span>{event.city}</span>
</div>
);
},
meta: {
label: 'Country',
},
},
{
accessorKey: 'os',
header: 'OS',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="flex min-w-full items-center gap-2">
<SerieIcon name={event.os} />
<span>{event.os}</span>
</div>
);
},
meta: {
label: 'OS',
},
},
{
accessorKey: 'browser',
header: 'Browser',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={event.browser} />
<span>{event.browser}</span>
</div>
);
},
meta: {
label: 'Browser',
},
},
{
accessorKey: 'profile',
header: createHeaderColumn('Profile'),
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<ProjectLink
href={`/profiles/${event.profileId}`}
className="inline-flex min-w-full flex-none items-center gap-2"
>
{event.profileId}
</ProjectLink>
);
},
meta: {
label: 'Profile',
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
if (!date) {
return null;
}
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
filterFn: 'isWithinRange',
meta: {
variant: 'dateRange',
placeholder: 'Created at',
label: 'Created at',
},
},
];
return columns;
}

View File

@@ -0,0 +1,32 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { DataTable } from '@/components/ui/data-table/data-table';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table';
import type { RouterOutputs } from '@/trpc/client';
import { useColumns } from './columns';
type Props = {
query: UseQueryResult<
RouterOutputs['notification']['list'][number][],
unknown
>;
};
export const NotificationsTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
columns,
data: data ?? [],
loading: isLoading,
pageSize: 50,
});
return (
<>
<DataTableToolbar table={table} />
<DataTable table={table} loading={isLoading} />;
</>
);
};

View File

@@ -0,0 +1,132 @@
import { LogoSquare } from '@/components/logo';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Link } from '@tanstack/react-router';
import { CodeIcon, CreditCardIcon, DollarSignIcon } from 'lucide-react';
import { SellingPoint } from './selling-points';
const onboardingSellingPoints = [
{
key: 'get-started',
render: () => (
<SellingPoint
bgImage="/img-6.png"
title="Get started in minutes"
description={
<>
<p>
<DollarSignIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Free trial
</p>
<p>
<CreditCardIcon className="size-4 inline-block mr-1 relative -top-0.5" />
No credit card required
</p>
<p>
<CodeIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Add our tracking code and get insights in real-time.
</p>
</>
}
/>
),
},
{
key: 'welcome',
render: () => (
<SellingPoint
bgImage="/img-1.png"
title="Best open-source alternative"
description="Mixpanel to expensive, Google Analytics has no privacy, Amplitude old and boring"
/>
),
},
{
key: 'selling-point-2',
render: () => (
<SellingPoint
bgImage="/img-2.png"
title="Fast and reliable"
description="Never miss a beat with our real-time analytics"
/>
),
},
{
key: 'selling-point-3',
render: () => (
<SellingPoint
bgImage="/img-3.png"
title="Easy to use"
description="Compared to other tools we have kept it simple"
/>
),
},
{
key: 'selling-point-4',
render: () => (
<SellingPoint
bgImage="/img-4.png"
title="Privacy by default"
description="We have built our platform with privacy at its heart"
/>
),
},
{
key: 'selling-point-5',
render: () => (
<SellingPoint
bgImage="/img-5.png"
title="Open source"
description="You can inspect the code and self-host if you choose"
/>
),
},
];
export function OnboardingLeftPanel() {
return (
<div className="sticky top-0 h-screen overflow-hidden">
<div className="row justify-between items-center p-8">
<LogoSquare className="h-8 w-8" />
<div className="text-sm text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="text-sm text-muted-foreground underline">
Sign in
</Link>
</div>
</div>
{/* Carousel */}
<div className="flex items-center justify-center h-full">
<Carousel
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
opts={{
loop: true,
align: 'center',
}}
>
<CarouselContent className="h-full">
{onboardingSellingPoints.map((point, index) => (
<CarouselItem
key={`onboarding-point-${point.key}`}
className="p-8 pb-32 pt-0"
>
<div className="rounded-xl min-h-full h-full overflow-hidden bg-card border border-border shadow-lg">
{point.render()}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-12 bottom-30 top-auto" />
<CarouselNext className="right-12 bottom-30 top-auto" />
</Carousel>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { pushModal } from '@/modals';
import { SmartphoneIcon } from 'lucide-react';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectApp = ({ client }: Props) => {
return (
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<SmartphoneIcon className="size-4" />
App
</div>
<div className="text-muted-foreground mb-2">
Pick a framework below to get started.
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('app'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
);
};
export default ConnectApp;

View File

@@ -0,0 +1,86 @@
import { pushModal } from '@/modals';
import { ServerIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import { useAppContext } from '@/hooks/use-app-context';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectBackend = ({ client }: Props) => {
const context = useAppContext();
return (
<>
<div>
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<ServerIcon className="size-4" />
Backend
</div>
<div className="text-muted-foreground mb-2">
Try with a basic curl command
</div>
</div>
<Syntax
language="bash"
className="border"
code={`curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client?.id}" \\
-H "openpanel-client-secret: ${client?.secret}" \\
-d '{
"type": "track",
"payload": {
"name": "test_event",
"properties": {
"test": "property"
}
}
}'`}
/>
</div>
<div>
<p className="text-muted-foreground mb-2">
Pick a framework below to get started.
</p>
<div className="grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('backend'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
</>
);
};
export default ConnectBackend;

View File

@@ -0,0 +1,78 @@
import { pushModal } from '@/modals';
import { MonitorIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectWeb = ({ client }: Props) => {
return (
<>
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<MonitorIcon className="size-4" />
Website
</div>
<div className="text-muted-foreground mb-2">
Paste the script to your website
</div>
<Syntax
className="border"
code={`<script>
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
window.op('init', {
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>`}
/>
</div>
<div>
<p className="text-muted-foreground mb-2">
Or pick a framework below to get started.
</p>
<div className="grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('website'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
</>
);
};
export default ConnectWeb;

View File

@@ -0,0 +1,72 @@
import { useAppContext } from '@/hooks/use-app-context';
import { useClientSecret } from '@/hooks/use-client-secret';
import { clipboard } from '@/utils/clipboard';
import type { IServiceProjectWithClients } from '@openpanel/db';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
export function CurlPreview({
project,
}: { project: IServiceProjectWithClients }) {
const context = useAppContext();
const [secret] = useClientSecret();
const client = project.clients[0];
if (!client) {
return null;
}
const payload: Record<string, any> = {
type: 'track',
payload: {
name: 'screen_view',
properties: {
__title: `Testing OpenPanel - ${project.name}`,
__path: `${project.domain}`,
__referrer: `${context.dashboardUrl}`,
},
},
};
if (project.types.includes('app')) {
payload.payload.properties.__path = '/';
delete payload.payload.properties.__referrer;
}
if (project.types.includes('backend')) {
payload.payload.name = 'test_event';
payload.payload.properties = {};
}
const code = `curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client.id}" \\
-H "openpanel-client-secret: ${secret}" \\
-H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\
-d '${JSON.stringify(payload)}'`;
return (
<div className="card">
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger
className="px-6"
onClick={() => {
clipboard(code, null);
}}
>
Try out the curl command
</AccordionTrigger>
<AccordionContent className="p-0">
<Syntax code={code} language="bash" />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import useWS from '@/hooks/use-ws';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { timeAgo } from '@/utils/date';
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
import { useState } from 'react';
import type {
IServiceClient,
IServiceEvent,
IServiceProject,
} from '@openpanel/db';
type Props = {
project: IServiceProject;
client: IServiceClient | null;
events: IServiceEvent[];
onVerified: (verified: boolean) => void;
};
const VerifyListener = ({
client,
events: _events,
onVerified,
project,
}: Props) => {
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
useWS<IServiceEvent>(
`/live/events/${client?.projectId}?type=received`,
(data) => {
setEvents((prev) => [...prev, data]);
onVerified(true);
},
);
const isConnected = events.length > 0;
const renderIcon = () => {
if (isConnected) {
return (
<CheckCircle2Icon
strokeWidth={1.2}
size={40}
className="shrink-0 text-emerald-600"
/>
);
}
return (
<Loader2 size={40} className="shrink-0 animate-spin text-highlight" />
);
};
return (
<div>
<div
className={cn(
'flex gap-6 rounded-xl p-4 md:p-6',
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10',
)}
>
{renderIcon()}
<div className="flex-1">
<div className="text-lg font-semibold leading-normal text-foreground/90">
{isConnected ? 'Success' : 'Waiting for events'}
</div>
{isConnected ? (
<div className="flex flex-col-reverse">
{events.length > 5 && (
<div className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '}
<span>{events.length - 5} more events</span>
</div>
)}
{events.slice(-5).map((event) => (
<div key={event.id} className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '}
<span className="font-medium">{event.name}</span>{' '}
<span className="ml-auto text-emerald-800">
{timeAgo(event.createdAt, 'round')}
</span>
</div>
))}
</div>
) : (
<div className="text-foreground/50">
Verify that your implementation works.
</div>
)}
</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
You can{' '}
<button
type="button"
className="underline"
onClick={() => {
pushModal('OnboardingTroubleshoot', {
client,
type: 'app',
});
}}
>
troubleshoot
</button>{' '}
if you are having issues connecting your app.
</div>
</div>
);
};
export default VerifyListener;

View File

@@ -0,0 +1,71 @@
import { useLogout } from '@/hooks/use-logout';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useLocation, useNavigate } from '@tanstack/react-router';
import { ChevronLastIcon, LogInIcon } from 'lucide-react';
import { useEffect } from 'react';
const PUBLIC_SEGMENTS = [['onboarding']];
export const SkipOnboarding = () => {
const trpc = useTRPC();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const segments = location.pathname.split('/').filter(Boolean);
const isPublic = PUBLIC_SEGMENTS.some((segment) =>
segments.every((s, index) => s === segment[index]),
);
const res = useQuery(
trpc.onboarding.skipOnboardingCheck.queryOptions(undefined, {
enabled: !isPublic,
}),
);
const logout = useLogout();
useEffect(() => {
res.refetch();
}, [pathname]);
// Do not show skip onboarding for the first step (register account)
if (isPublic) {
return (
<Link
to="/login"
className="flex items-center gap-2 text-muted-foreground"
>
Login
<LogInIcon size={16} />
</Link>
);
}
if (res.isLoading || res.isError) {
return null;
}
return (
<button
type="button"
onClick={() => {
if (res.data?.canSkip) {
navigate({ to: '/' });
} else {
showConfirm({
title: 'Skip onboarding?',
text: 'Are you sure you want to skip onboarding? Since you do not have any projects, you will be logged out.',
onConfirm() {
logout.mutate();
},
});
}
}}
className="flex items-center gap-2 text-muted-foreground"
>
Skip onboarding
<ChevronLastIcon size={16} />
</button>
);
};

View File

@@ -0,0 +1,117 @@
import { cn } from '@/lib/utils';
import { useLocation } from '@tanstack/react-router';
import { CheckCheckIcon } from 'lucide-react';
type Step = {
name: string;
status: 'completed' | 'current' | 'pending';
match: string;
};
type Props = {
className?: string;
};
function useSteps(path: string) {
const steps: Step[] = [
{
name: 'Create an account',
status: 'pending',
match: '/onboarding',
},
{
name: 'Create a project',
status: 'pending',
match: '/onboarding/project',
},
{
name: 'Connect your data',
status: 'pending',
match: '/onboarding/(.+)/connect',
},
{
name: 'Verify',
status: 'pending',
match: '/onboarding/(.+)/verify',
},
];
// @ts-ignore
const matchIndex = steps.findLastIndex((step) =>
path.match(new RegExp(step.match)),
);
return steps.map((step, index) => {
if (index < matchIndex) {
return { ...step, status: 'completed' };
}
if (index === matchIndex) {
return { ...step, status: 'current' };
}
return step;
});
}
export const OnboardingSteps = ({ className }: Props) => {
const location = useLocation();
const path = location.pathname;
const steps = useSteps(path);
const currentIndex = steps.findIndex((i) => i.status === 'current');
return (
<div className="relative">
<div className="absolute bottom-4 left-4 top-4 w-px bg-def-200" />
<div
className="absolute left-4 top-4 w-px bg-highlight"
style={{
height: `calc(${((currentIndex + 1) / steps.length) * 100}% - 3.5rem)`,
}}
/>
<div
className={cn(
'relative flex gap-4 overflow-hidden md:-ml-3 md:flex-col md:gap-8',
className,
)}
>
{steps.map((step, index) => (
<div
className={cn(
'flex flex-shrink-0 items-center gap-4 self-start px-3 py-1.5',
step.status === 'current' &&
'rounded-xl border border-border bg-card',
step.status === 'completed' &&
index !== currentIndex - 1 &&
'max-md:hidden',
)}
key={step.name}
>
<div
className={cn(
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white',
)}
>
<div
className={cn(
'absolute inset-0 z-0 rounded-full bg-highlight',
step.status === 'pending' && 'bg-def-400',
)}
/>
{step.status === 'current' && (
<div className="absolute inset-1 z-0 animate-ping-slow rounded-full bg-highlight" />
)}
<div className="relative">
{step.status === 'completed' && <CheckCheckIcon size={14} />}
{/* {step.status === 'current' && (
<ArrowRightCircleIcon size={14} />
)} */}
{(step.status === 'pending' || step.status === 'current') &&
index + 1}
</div>
</div>
<div className="font-medium">{step.name}</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,70 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Widget, WidgetHead } from '@/components/widget';
const questions = [
{
question: 'Does OpenPanel have a free tier?',
answer: [
'For our Cloud plan we offer a 14 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan.',
'OpenPanel is also open-source and you can self-host it for free!',
'',
'Why does OpenPanel not have a free tier?',
'We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
],
},
{
question: 'What happens if my site exceeds the limit?',
answer: [
"You will not see any new events in OpenPanel until your next billing period. If this happens 2 months in a row, we'll advice you to upgrade your plan.",
],
},
{
question: 'What happens if I cancel my subscription?',
answer: [
'If you cancel your subscription, you will still have access to OpenPanel until the end of your current billing period. You can reactivate your subscription at any time.',
'After your current billing period ends, you will not get access to new data.',
"NOTE: If your account has been inactive for 3 months, we'll delete your events.",
],
},
{
question: 'How do I change my billing information?',
answer: [
'You can change your billing information by clicking the "Manage your subscription" button in the billing section.',
],
},
];
export function BillingFaq() {
return (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center"
>
{questions.map((q) => (
<AccordionItem value={q.question} key={q.question}>
<AccordionTrigger className="text-left px-4">
{q.question}
</AccordionTrigger>
<AccordionContent>
<div className="col gap-2 p-4 pt-2">
{q.answer.map((a) => (
<p key={a}>{a}</p>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Widget>
);
}

View File

@@ -0,0 +1,432 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/use-app-params';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { op } from '@/utils/op';
import type { IServiceOrganization } from '@openpanel/db';
import type { IPolarPrice } from '@openpanel/payments';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function Billing({ organization }: Props) {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const trpc = useTRPC();
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
'customer_session_token',
);
const productsQuery = useQuery(
trpc.subscription.products.queryOptions({
organizationId: organization.id,
}),
);
useWS(`/live/organization/${organization.id}`, () => {
queryClient.invalidateQueries(trpc.organization.pathFilter());
});
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month',
);
const products = useMemo(() => {
return (productsQuery.data || [])
.filter((product) => product.recurringInterval === recurringInterval)
.filter((product) => product.prices.some((p) => p.amountType !== 'free'));
}, [productsQuery.data, recurringInterval]);
useEffect(() => {
if (organization.subscriptionInterval) {
setRecurringInterval(
organization.subscriptionInterval as 'year' | 'month',
);
}
}, [organization.subscriptionInterval]);
useEffect(() => {
if (customerSessionToken) {
op.track('subscription_created');
}
}, [customerSessionToken]);
const [selectedProductIndex, setSelectedProductIndex] = useState<number>(0);
// Check if organization has a custom product
const hasCustomProduct = useMemo(() => {
return products.some((product) => product.metadata?.custom === true);
}, [products]);
// Find current subscription index
const currentSubscriptionIndex = useMemo(() => {
if (!organization.subscriptionProductId) {
// Default to 100K events plan if no subscription
const defaultIndex = products.findIndex(
(product) => product.metadata?.eventsLimit === 100_000,
);
return defaultIndex >= 0 ? defaultIndex : 0;
}
return products.findIndex(
(product) => product.id === organization.subscriptionProductId,
);
}, [products, organization.subscriptionProductId]);
// Check if selected index is the "custom" option (beyond available products)
const isCustomOption = selectedProductIndex >= products.length;
// Find the highest event limit to make the custom option dynamic
const highestEventLimit = useMemo(() => {
const limits = products
.map((product) => product.metadata?.eventsLimit)
.filter((limit): limit is number => typeof limit === 'number');
return Math.max(...limits, 0);
}, [products]);
// Format the custom option label dynamically
const customOptionLabel = useMemo(() => {
if (highestEventLimit >= 1_000_000) {
return `+${(highestEventLimit / 1_000_000).toFixed(0)}M`;
}
if (highestEventLimit >= 1_000) {
return `+${(highestEventLimit / 1_000).toFixed(0)}K`;
}
return `+${highestEventLimit}`;
}, [highestEventLimit]);
// Set initial slider position to current subscription
useEffect(() => {
if (currentSubscriptionIndex >= 0) {
setSelectedProductIndex(currentSubscriptionIndex);
}
}, [currentSubscriptionIndex]);
const selectedProduct = products[selectedProductIndex];
const isUpgrade = selectedProductIndex > currentSubscriptionIndex;
const isDowngrade = selectedProductIndex < currentSubscriptionIndex;
const isCurrentPlan = selectedProductIndex === currentSubscriptionIndex;
function renderBillingSlider() {
if (productsQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productsQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (hasCustomProduct) {
return (
<div className="p-8 text-center">
<div className="text-muted-foreground">
Not applicable since custom product
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Select your plan</span>
<span className="text-sm text-muted-foreground">
{selectedProduct?.name || 'No plan selected'}
</span>
</div>
<Slider
value={[selectedProductIndex]}
onValueChange={([value]) => setSelectedProductIndex(value)}
min={0}
max={products.length} // +1 for the custom option
step={1}
className="w-full"
disabled={hasCustomProduct}
/>
<div className="flex justify-between text-xs text-muted-foreground">
{products.map((product, index) => {
const eventsLimit = product.metadata?.eventsLimit;
return (
<div key={product.id} className="text-center">
<div className="font-medium">
{eventsLimit && typeof eventsLimit === 'number'
? `${(eventsLimit / 1000).toFixed(0)}K`
: 'Free'}
</div>
<div className="text-xs">events</div>
</div>
);
})}
{/* Add the custom option label */}
<div className="text-center">
<div className="font-medium">{customOptionLabel}</div>
<div className="text-xs">events</div>
</div>
</div>
</div>
{(selectedProduct || isCustomOption) && (
<div className="border rounded-lg p-4 space-y-4">
{isCustomOption ? (
// Custom option content
<>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">Custom Plan</h3>
<p className="text-sm text-muted-foreground">
{customOptionLabel} events per {recurringInterval}
</p>
</div>
<div className="text-right">
<span className="text-lg font-semibold">
Custom Pricing
</span>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-center">
<p className="text-sm text-muted-foreground mb-2">
Need higher limits?
</p>
<p className="text-sm">
Reach out to{' '}
<a
className="underline font-medium"
href="mailto:hello@openpanel.dev"
>
hello@openpanel.dev
</a>{' '}
and we'll help you with a custom quota.
</p>
</div>
</>
) : (
// Regular product content
<>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">{selectedProduct.name}</h3>
<p className="text-sm text-muted-foreground">
{selectedProduct.metadata?.eventsLimit
? `${selectedProduct.metadata.eventsLimit.toLocaleString()} events per ${recurringInterval}`
: 'Free tier'}
</p>
</div>
<div className="text-right">
{selectedProduct.prices[0]?.amountType === 'free' ? (
<span className="text-lg font-semibold">Free</span>
) : (
<span className="text-lg font-semibold">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency:
selectedProduct.prices[0]?.priceCurrency || 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(
(selectedProduct.prices[0] &&
'priceAmount' in selectedProduct.prices[0]
? selectedProduct.prices[0].priceAmount
: 0) / 100,
)}
<span className="text-sm text-muted-foreground">
{' / '}
{recurringInterval === 'year' ? 'year' : 'month'}
</span>
</span>
)}
</div>
</div>
{!isCurrentPlan && selectedProduct.prices[0] && (
<div className="flex justify-end">
<CheckoutButton
disabled={selectedProduct.disabled}
key={selectedProduct.prices[0].id}
price={selectedProduct.prices[0]}
organization={organization}
projectId={projectId}
buttonText={
isUpgrade
? 'Upgrade'
: isDowngrade
? 'Downgrade'
: 'Activate'
}
/>
</div>
)}
{isCurrentPlan && (
<div className="flex justify-end">
<Button variant="outline" disabled>
Current Plan
</Button>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
return (
<>
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Billing</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{recurringInterval === 'year'
? 'Yearly (2 months free)'
: 'Monthly'}
</span>
<Switch
checked={recurringInterval === 'year'}
onCheckedChange={(checked) =>
setRecurringInterval(checked ? 'year' : 'month')
}
/>
</div>
</WidgetHead>
<WidgetBody>
<div className="-m-4">{renderBillingSlider()}</div>
</WidgetBody>
</Widget>
<Dialog
open={!!customerSessionToken}
onOpenChange={(open) => {
setCustomerSessionToken(null);
if (!open) {
queryClient.invalidateQueries(trpc.organization.pathFilter());
}
}}
>
<DialogContent>
<DialogTitle>Subscription created</DialogTitle>
<DialogDescription>
We have registered your subscription. It'll be activated within a
couple of seconds.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button>OK</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function CheckoutButton({
price,
organization,
projectId,
disabled,
buttonText,
}: {
price: IPolarPrice;
organization: IServiceOrganization;
projectId: string;
disabled?: string | null;
buttonText?: string;
}) {
const trpc = useTRPC();
const isCurrentPrice = organization.subscriptionPriceId === price.id;
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
const isCanceled =
organization.subscriptionStatus === 'active' &&
isCurrentPrice &&
organization.subscriptionCanceledAt;
const isActive =
organization.subscriptionStatus === 'active' && isCurrentPrice;
return (
<Tooltiper
content={disabled}
tooltipClassName="max-w-xs"
side="left"
disabled={!disabled}
>
<Button
disabled={disabled !== null || (isActive && !isCanceled)}
key={price.id}
onClick={() => {
const createCheckout = () =>
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
if (organization.subscriptionStatus === 'active') {
showConfirm({
title: 'Are you sure?',
text: `You're about the change your subscription.`,
onConfirm: () => {
op.track('subscription_change');
createCheckout();
},
});
} else {
op.track('subscription_checkout', {
product: price.productId,
});
createCheckout();
}
}}
loading={checkout.isPending}
className="w-28"
variant={isActive ? 'outline' : 'default'}
>
{buttonText ||
(isCanceled ? 'Reactivate' : isActive ? 'Active' : 'Activate')}
</Button>
</Tooltiper>
);
}

View File

@@ -0,0 +1,285 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { cn } from '@/utils/cn';
import type { IServiceOrganization } from '@openpanel/db';
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Loader2Icon } from 'lucide-react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function CurrentSubscription({ organization }: Props) {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const number = useNumber();
const trpc = useTRPC();
const productQuery = useQuery(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
const cancelSubscription = useMutation(
trpc.subscription.cancelSubscription.mutationOptions({
onSuccess() {
toast.success('Subscription cancelled', {
description: 'It might take a few seconds to update',
});
},
onError(error) {
toast.error(error.message);
},
}),
);
const portalMutation = useMutation(
trpc.subscription.portal.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
}
},
}),
);
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
useWS(`/live/organization/${organization.id}`, () => {
queryClient.invalidateQueries(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
});
function render() {
if (productQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (!productQuery.data) {
return (
<div className="center-center p-8 font-medium">
No subscription found
</div>
);
}
const product = productQuery.data;
const price = product.prices[0]!;
return (
<>
<div className="gap-4 col">
{price.amountType === 'free' && (
<Alert variant="warning">
<AlertTitle>Free plan is removed</AlertTitle>
<AlertDescription>
We've removed the free plan. You can upgrade to a paid plan to
continue using OpenPanel.
</AlertDescription>
</Alert>
)}
<div className="row justify-between">
<div>Name</div>
<div className="text-right font-medium">{product.name}</div>
</div>
{price.amountType === 'fixed' ? (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">
{number.currency(price.priceAmount / 100)}
</div>
</div>
</>
) : (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">FREE</div>
</div>
</>
)}
<div className="row justify-between">
<div>Billing Cycle</div>
<div className="text-right font-medium">
{price.recurringInterval === 'month' ? 'Monthly' : 'Yearly'}
</div>
</div>
{typeof product.metadata.eventsLimit === 'number' && (
<div className="row justify-between">
<div>Events per mount</div>
<div className="text-right font-medium font-mono">
{number.format(product.metadata.eventsLimit)}
</div>
</div>
)}
</div>
{organization.subscriptionProductId &&
!FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) && (
<div className="col gap-2">
{organization.isWillBeCanceled || organization.isCanceled ? (
<Button
loading={checkout.isPending}
onClick={() => {
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
}}
>
Reactivate subscription
</Button>
) : (
<Button
variant="destructive"
loading={cancelSubscription.isPending}
onClick={() => {
showConfirm({
title: 'Cancel subscription',
text: 'Are you sure you want to cancel your subscription?',
onConfirm() {
cancelSubscription.mutate({
organizationId: organization.id,
});
},
});
}}
>
Cancel subscription
</Button>
)}
</div>
)}
</>
);
}
return (
<div className="col gap-2 md:w-72 shrink-0">
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Current Subscription</span>
<div className="flex items-center gap-2">
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
</div>
</div>
</WidgetHead>
<WidgetBody className="col gap-8">
{organization.isTrial && organization.subscriptionEndsAt && (
<Alert variant="warning">
<AlertTitle>Free trial</AlertTitle>
<AlertDescription>
Your organization is on a free trial. It ends on{' '}
{format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isExpired && organization.subscriptionEndsAt && (
<Alert variant="destructive">
<AlertTitle>Subscription expired</AlertTitle>
<AlertDescription>
Your subscription has expired. You can reactivate it by choosing
a new plan below.
</AlertDescription>
<AlertDescription>
It expired on {format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isWillBeCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
You have canceled your subscription. You can reactivate it by
choosing a new plan below.
</AlertDescription>
<AlertDescription className="font-medium">
It'll expire on{' '}
{format(organization.subscriptionEndsAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
Your subscription was canceled on{' '}
{format(organization.subscriptionCanceledAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{render()}
</WidgetBody>
</Widget>
{organization.hasSubscription && (
<button
className="text-center mt-2 w-2/3 hover:underline self-center"
type="button"
onClick={() =>
portalMutation.mutate({
organizationId: organization.id,
})
}
>
Manage your subscription with
<span className="font-medium ml-1">Polar Customer Portal</span>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/trpc/client';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { Combobox } from '@/components/ui/combobox';
import type { IServiceOrganization } from '@openpanel/db';
import { zEditOrganization } from '@openpanel/validation';
const validator = zEditOrganization;
type IForm = z.infer<typeof validator>;
interface EditOrganizationProps {
organization: IServiceOrganization;
}
export default function EditOrganization({
organization,
}: EditOrganizationProps) {
const router = useRouter();
const { register, handleSubmit, formState, reset, control } = useForm<IForm>({
defaultValues: {
id: organization.id,
name: organization.name,
timezone: organization.timezone ?? undefined,
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.organization.update.mutationOptions({
onSuccess(res: any) {
toast('Organization updated', {
description: 'Your organization has been updated.',
});
reset({
...res,
timezone: res.timezone!,
});
router.invalidate();
},
onError: handleError,
}),
);
return (
<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>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import type { IServiceOrganization } from '@openpanel/db';
import EditOrganization from './edit-organization';
interface OrganizationProps {
organization: IServiceOrganization;
}
export default function Organization({ organization }: OrganizationProps) {
return (
<section className="max-w-screen-sm col gap-8">
<EditOrganization organization={organization} />
</section>
);
}

View File

@@ -0,0 +1,374 @@
'use client';
import {
X_AXIS_STYLE_PROPS,
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { sum } from '@openpanel/common';
import type { IServiceOrganization } from '@openpanel/db';
import { useQuery } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react';
import { pick } from 'ramda';
import {
Bar,
BarChart,
CartesianGrid,
Tooltip as RechartTooltip,
ReferenceLine,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
type Props = {
organization: IServiceOrganization;
};
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}>
<div className="text-muted-foreground truncate">{title}</div>
<div className="font-mono text-xl font-bold truncate">{value}</div>
</div>
);
}
export default function Usage({ organization }: Props) {
const number = useNumber();
const trpc = useTRPC();
const usageQuery = useQuery(
trpc.subscription.usage.queryOptions({
organizationId: organization.id,
}),
);
// Determine interval based on data range - use weekly if more than 30 days
const getDataInterval = () => {
if (!usageQuery.data || usageQuery.data.length === 0) return 'day';
const dates = usageQuery.data.map((item) => new Date(item.day));
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
const daysDiff = Math.ceil(
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24),
);
return daysDiff > 30 ? 'week' : 'day';
};
const interval = getDataInterval();
const useWeeklyIntervals = interval === 'week';
const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps({});
const wrapper = (node: React.ReactNode) => (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<WidgetBody>{node}</WidgetBody>
</Widget>
);
if (usageQuery.isLoading) {
return wrapper(
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>,
);
}
if (usageQuery.isError) {
return wrapper(
<div className="center-center p-8 font-medium">
Issues loading usage data
</div>,
);
}
const subscriptionPeriodEventsLimit = organization.hasSubscription
? organization.subscriptionPeriodEventsLimit
: 0;
const subscriptionPeriodEventsCount = organization.hasSubscription
? organization.subscriptionPeriodEventsCount
: 0;
// Group daily data into weekly intervals if data spans more than 30 days
const processChartData = () => {
if (!usageQuery.data) return [];
if (useWeeklyIntervals) {
// Group daily data into weekly intervals
const weeklyData: {
[key: string]: { count: number; startDate: Date; endDate: Date };
} = {};
usageQuery.data.forEach((item) => {
const date = new Date(item.day);
// Get the start of the week (Monday)
const startOfWeek = new Date(date);
const dayOfWeek = date.getDay();
const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
const weekKey = startOfWeek.toISOString().split('T')[0];
if (!weeklyData[weekKey]) {
weeklyData[weekKey] = {
count: 0,
startDate: new Date(startOfWeek),
endDate: new Date(startOfWeek),
};
}
weeklyData[weekKey].count += item.count;
weeklyData[weekKey].endDate = new Date(date);
});
return Object.values(weeklyData).map((week) => ({
date: week.startDate.getTime(),
count: week.count,
weekRange: `${formatDate(week.startDate)} - ${formatDate(week.endDate)}`,
}));
}
// Use daily data for monthly subscriptions
return usageQuery.data.map((item) => ({
date: new Date(item.day).getTime(),
count: item.count,
}));
};
const chartData = processChartData();
const domain = [
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
...chartData.map((item) => item.count),
),
] as [number, number];
domain[1] += domain[1] * 0.05;
return wrapper(
<>
<div className="border-b divide-x divide-border -m-4 mb-4 grid grid-cols-2 md:grid-cols-4">
{organization.hasSubscription ? (
<>
<Card
title="Period"
value={
organization.subscriptionCurrentPeriodStart &&
organization.subscriptionCurrentPeriodEnd
? `${formatDate(organization.subscriptionCurrentPeriodStart)}-${formatDate(organization.subscriptionCurrentPeriodEnd)}`
: '🤷‍♂️'
}
/>
<Card
title="Limit"
value={number.format(subscriptionPeriodEventsLimit)}
/>
<Card
title="Events count"
value={number.format(subscriptionPeriodEventsCount)}
/>
<Card
title="Left to use"
value={
subscriptionPeriodEventsLimit === 0
? '👀'
: number.formatWithUnit(
1 -
subscriptionPeriodEventsCount /
subscriptionPeriodEventsLimit,
'%',
)
}
/>
</>
) : (
<>
<div className="col-span-2">
<Card title="Subscription" value={'No active subscription'} />
</div>
<div className="col-span-2">
<Card
title="Events from last 30 days"
value={number.format(
sum(usageQuery.data?.map((item) => item.count) ?? []),
)}
/>
</div>
</>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Events Chart */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}
</h3>
<div className="max-h-[300px] h-[250px] w-full p-4">
<ResponsiveContainer>
<BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}>
<RechartTooltip
content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
/>
<Bar
dataKey="count"
isAnimationActive={false}
shape={BarShapeBlue}
/>
<XAxis {...xAxisProps} dataKey="date" />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Total Events vs Limit Chart */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
Total Events vs Limit
</h3>
<div className="max-h-[300px] h-[250px] w-full p-4">
<ResponsiveContainer>
<BarChart
data={[
{
name: 'Total Events',
count: subscriptionPeriodEventsCount,
limit: subscriptionPeriodEventsLimit,
},
]}
>
<RechartTooltip content={<TotalTooltip />} cursor={false} />
{organization.hasSubscription &&
subscriptionPeriodEventsLimit > 0 && (
<ReferenceLine
y={subscriptionPeriodEventsLimit}
stroke={getChartColor(1)}
strokeWidth={2}
strokeDasharray="3 3"
strokeOpacity={0.8}
strokeLinecap="round"
label={{
value: `Limit (${number.format(subscriptionPeriodEventsLimit)})`,
fill: getChartColor(1),
position: 'insideTopRight',
fontSize: 12,
}}
/>
)}
<Bar
dataKey="count"
isAnimationActive={false}
shape={BarShapeBlue}
/>
<XAxis {...X_AXIS_STYLE_PROPS} dataKey="name" />
<YAxis
{...yAxisProps}
domain={[
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
) * 1.1,
]}
/>
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</>,
);
}
function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
const number = useNumber();
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">
{useWeekly && payload.weekRange
? payload.weekRange
: payload?.date
? formatDate(new Date(payload.date))
: 'Unknown date'}
</div>
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-0" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">
Events {useWeekly ? 'this week' : 'this day'}
</div>
<div className="text-lg font-semibold text-chart-0">
{number.format(payload.count)}
</div>
</div>
</div>
</div>
);
}
function TotalTooltip(props: any) {
const number = useNumber();
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">Total Events</div>
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-2" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Your events count</div>
<div className="text-lg font-semibold text-chart-2">
{number.format(payload.count)}
</div>
</div>
</div>
{payload.limit > 0 && (
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Your tier limit</div>
<div className="text-lg font-semibold text-chart-1">
{number.format(payload.limit)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import { GlobeIcon } from 'lucide-react';
export function OriginFilter() {
const { projectId } = useAppParams();
const [filters, setFilter] = useEventQueryFilters();
const originFilter = filters.find((item) => item.name === 'origin');
const trpc = useTRPC();
const { data } = useQuery(
trpc.event.origin.queryOptions(
{
projectId: projectId,
},
{
staleTime: 1000 * 60 * 60,
},
),
);
if (!data || data.length === 0) {
return null;
}
return (
<div className="flex flex-wrap gap-2">
{data?.map((item) => {
return (
<Button
key={item.origin}
variant="outline"
icon={GlobeIcon}
className={cn(
originFilter?.value.includes(item.origin) && 'border-foreground',
)}
onClick={() => setFilter('origin', [item.origin], 'is')}
>
{item.origin}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Button } from '@/components/ui/button';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { getPropertyLabel } from '@/translations/properties';
import { cn } from '@/utils/cn';
import { operators } from '@openpanel/constants';
import { X } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
interface OverviewFiltersButtonsProps {
className?: string;
nuqsOptions?: NuqsOptions;
}
export function OverviewFiltersButtons({
className,
nuqsOptions,
}: OverviewFiltersButtonsProps) {
const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions);
const [filters, _setFilter, _setFilters, removeFilter] =
useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{events.map((event) => (
<Button
key={event}
size="sm"
variant="outline"
icon={X}
onClick={() => setEvents((p) => p.filter((e) => e !== event))}
>
<strong className="font-semibold">{event}</strong>
</Button>
))}
{filters.map((filter) => {
return (
<Button
key={filter.name}
size="sm"
variant="outline"
icon={X}
onClick={() => removeFilter(filter.name)}
>
<span>{getPropertyLabel(filter.name)}</span>
<span className="opacity-40 ml-2 lowercase">
{operators[filter.operator]}
</span>
{filter.value.length > 0 && (
<strong className="font-semibold ml-2">
{filter.value.join(', ')}
</strong>
)}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useEventNames } from '@/hooks/use-event-names';
import { useEventProperties } from '@/hooks/use-event-properties';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { useProfileProperties } from '@/hooks/use-profile-properties';
import { useProfileValues } from '@/hooks/use-profile-values';
import { usePropertyValues } from '@/hooks/use-property-values';
import { XIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@openpanel/validation';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { OriginFilter } from './origin-filter';
export interface OverviewFiltersDrawerContentProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
mode: 'profiles' | 'events';
}
const excludePropertyFilter = (name: string) => {
return ['*', 'duration', 'created_at', 'has_profile'].includes(name);
};
export function OverviewFiltersDrawerContent({
projectId,
nuqsOptions,
enableEventsFilter,
mode,
}: OverviewFiltersDrawerContentProps) {
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames({ projectId });
const eventProperties = useEventProperties({ projectId, event: event[0] });
const profileProperties = useProfileProperties(projectId);
const properties = mode === 'events' ? eventProperties : profileProperties;
return (
<div>
<SheetHeader className="mb-8">
<SheetTitle>Overview filters</SheetTitle>
</SheetHeader>
<div className="mt-8 flex flex-col rounded-md border bg-def-100">
<div className="flex flex-col gap-4 p-4">
<OriginFilter />
{enableEventsFilter && (
<ComboboxEvents
className="w-full"
value={event}
onChange={setEvent}
multiple
items={eventNames.filter(
(item) => !excludePropertyFilter(item.name),
)}
placeholder="Select event"
maxDisplayItems={2}
/>
)}
<Combobox
className="w-full"
onChange={(value) => {
setFilter(value, [], 'is');
}}
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={properties
.filter((item) => item !== 'name')
.map((item) => ({
label: item,
value: item,
}))}
searchable
size="lg"
/>
</div>
{filters
.filter((filter) => filter.value[0] !== null)
.map((filter) => {
return mode === 'events' ? (
<PureFilterItem
className="border-t p-4 first:border-0"
eventName="screen_view"
key={filter.name}
filter={filter}
onRemove={() => {
setFilter(filter.name, [], filter.operator);
}}
onChangeValue={(value) => {
setFilter(filter.name, value, filter.operator);
}}
onChangeOperator={(operator) => {
setFilter(filter.name, filter.value, operator);
}}
/>
) : /* TODO: Implement profile filters */
null;
})}
</div>
</div>
);
}
export function FilterOptionEvent({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator,
) => void;
}) {
const values = usePropertyValues({
projectId,
event: filter.name === 'path' ? 'screen_view' : 'session_start',
property: filter.name,
});
return (
<div className="flex items-center gap-2">
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>
);
}
export function FilterOptionProfile({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator,
) => void;
}) {
const values = useProfileValues(projectId, filter.name);
return (
<div className="flex items-center gap-2">
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
import type { OverviewFiltersDrawerContentProps } from './overview-filters-drawer-content';
import { OverviewFiltersDrawerContent } from './overview-filters-drawer-content';
export function OverviewFiltersDrawer(
props: OverviewFiltersDrawerContentProps,
) {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
<SheetContent className="w-full !max-w-lg" side="right">
<OverviewFiltersDrawerContent {...props} />
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,81 @@
import { TooltipComplete } from '@/components/tooltip-complete';
import { useDebounceState } from '@/hooks/use-debounce-state';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { AnimatedNumber } from '../animated-number';
export interface LiveCounterProps {
projectId: string;
}
const FIFTEEN_SECONDS = 1000 * 30;
export function LiveCounter({ projectId }: LiveCounterProps) {
const trpc = useTRPC();
const client = useQueryClient();
const counter = useDebounceState(0, 1000);
const lastRefresh = useRef(Date.now());
const query = useQuery(
trpc.overview.liveVisitors.queryOptions({
projectId,
}),
);
useEffect(() => {
if (query.data) {
counter.set(query.data);
}
}, [query.data]);
useWS<number>(
`/live/visitors/${projectId}`,
(value) => {
if (!Number.isNaN(value)) {
counter.set(value);
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
lastRefresh.current = Date.now();
if (!document.hidden) {
toast('Refreshed data');
client.refetchQueries({
type: 'active',
});
}
}
}
},
{
debounce: {
delay: 1000,
maxWait: 5000,
},
},
);
return (
<TooltipComplete
content={`${counter.debounced} unique visitors last 5 minutes`}
>
<div className="flex h-8 items-center gap-2 rounded border border-border px-3 font-medium leading-none">
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
counter.debounced === 0 && 'bg-destructive opacity-0',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
counter.debounced === 0 && 'bg-destructive',
)}
/>
</div>
<AnimatedNumber value={counter.debounced} />
</div>
</TooltipComplete>
);
}

View File

@@ -0,0 +1,28 @@
import { BarChartIcon, LineChartIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import type { IChartType } from '@openpanel/validation';
import { Button } from '../ui/button';
interface Props {
chartType: IChartType;
setChartType: Dispatch<SetStateAction<IChartType>>;
}
export function OverviewChartToggle({ chartType, setChartType }: Props) {
return (
<Button
size={'icon'}
variant={'ghost'}
onClick={() => {
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
}}
>
{chartType === 'bar' ? (
<LineChartIcon size={16} />
) : (
<BarChartIcon size={16} />
)}
</Button>
);
}

View File

@@ -0,0 +1,49 @@
import type { IGetTopGenericInput } from '@openpanel/db';
export const OVERVIEW_COLUMNS_NAME: Record<
IGetTopGenericInput['column'],
string
> = {
country: 'Country',
region: 'Region',
city: 'City',
browser: 'Browser',
brand: 'Brand',
os: 'OS',
device: 'Device',
browser_version: 'Browser version',
os_version: 'OS version',
model: 'Model',
referrer: 'Referrer',
referrer_name: 'Referrer name',
referrer_type: 'Referrer type',
utm_source: 'UTM source',
utm_medium: 'UTM medium',
utm_campaign: 'UTM campaign',
utm_term: 'UTM term',
utm_content: 'UTM content',
};
export const OVERVIEW_COLUMNS_NAME_PLURAL: Record<
IGetTopGenericInput['column'],
string
> = {
country: 'Countries',
region: 'Regions',
city: 'Cities',
browser: 'Browsers',
brand: 'Brands',
os: 'OSs',
device: 'Devices',
browser_version: 'Browser versions',
os_version: 'OS versions',
model: 'Models',
referrer: 'Referrers',
referrer_name: 'Referrer names',
referrer_type: 'Referrer types',
utm_source: 'UTM sources',
utm_medium: 'UTM mediums',
utm_campaign: 'UTM campaigns',
utm_term: 'UTM terms',
utm_content: 'UTM contents',
};

View File

@@ -0,0 +1,15 @@
import { ScanEyeIcon } from 'lucide-react';
import { Button, type ButtonProps } from '../ui/button';
type Props = Omit<ButtonProps, 'children'>;
const OverviewDetailsButton = (props: Props) => {
return (
<Button size="icon" variant="ghost" {...props}>
<ScanEyeIcon size={18} />
</Button>
);
};
export default OverviewDetailsButton;

View File

@@ -0,0 +1,49 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import { ClockIcon } from 'lucide-react';
import { Combobox } from '../ui/combobox';
export function OverviewInterval() {
const { interval, setInterval, range } = useOverviewOptions();
return (
<Combobox
className="hidden md:flex"
icon={ClockIcon}
placeholder="Interval"
onChange={(value) => {
setInterval(value);
}}
value={interval}
items={[
{
value: 'minute',
label: 'Minute',
disabled: !isMinuteIntervalEnabledByRange(range),
},
{
value: 'hour',
label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',
label: 'Day',
},
{
value: 'week',
label: 'Week',
},
{
value: 'month',
label: 'Month',
disabled:
range === 'today' || range === 'lastHour' || range === '30min',
},
]}
/>
);
}

View File

@@ -0,0 +1,251 @@
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import type { IChartProps } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Bar,
BarChart,
CartesianGrid,
Customized,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
interface OverviewLiveHistogramProps {
projectId: string;
}
export function OverviewLiveHistogram({
projectId,
}: OverviewLiveHistogramProps) {
const report: IChartProps = {
projectId,
events: [
{
segment: 'user',
filters: [
{
id: '1',
name: 'name',
operator: 'is',
value: ['screen_view', 'session_start'],
},
],
id: 'A',
name: '*',
displayName: 'Active users',
},
],
chartType: 'histogram',
interval: 'minute',
range: '30min',
name: '',
metric: 'sum',
breakdowns: [],
lineType: 'monotone',
previous: false,
};
const countReport: IChartProps = {
name: '',
projectId,
events: [
{
segment: 'user',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval: 'minute',
range: '30min',
previous: false,
metric: 'sum',
};
const trpc = useTRPC();
const res = useQuery(trpc.chart.chart.queryOptions(report));
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
const metrics = res.data?.series[0]?.metrics;
const minutes = (res.data?.series[0]?.data || []).slice(-30);
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
// Transform data for Recharts
const chartData = minutes.map((minute) => ({
...minute,
timestamp: new Date(minute.date).getTime(),
time: new Date(minute.date).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}));
if (res.isInitialLoading || countRes.isInitialLoading) {
return (
<Wrapper count={0}>
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
</Wrapper>
);
}
if (!res.isSuccess && !countRes.isSuccess) {
return null;
}
return (
<Wrapper count={liveCount}>
<div className="h-full w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Tooltip
content={CustomTooltip}
cursor={{
fill: 'var(--def-200)',
}}
/>
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
<YAxis hide />
<Bar
dataKey="count"
fill="rgba(59, 121, 255, 0.2)"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>
</div>
</Wrapper>
);
}
interface WrapperProps {
children: React.ReactNode;
count: number;
}
function Wrapper({ children, count }: WrapperProps) {
return (
<div className="flex h-full flex-col">
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
{count} unique vistors last 30 minutes
</div>
<div className="relative flex h-full w-full flex-1 items-end justify-center gap-2">
{children}
</div>
</div>
);
}
// Custom tooltip component that uses portals to escape overflow hidden
const CustomTooltip = ({ active, payload, coordinate }: any) => {
const [tooltipContainer] = useState(() => document.createElement('div'));
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const number = useNumber();
useEffect(() => {
document.body.appendChild(tooltipContainer);
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
};
document.addEventListener('mousemove', handleMouseMove);
return () => {
if (document.body.contains(tooltipContainer)) {
document.body.removeChild(tooltipContainer);
}
document.removeEventListener('mousemove', handleMouseMove);
};
}, [tooltipContainer]);
if (!active || !payload || !payload.length) {
return null;
}
const data = payload[0].payload;
// Smart positioning to avoid going out of bounds
const tooltipWidth = 180; // min-w-[180px]
const tooltipHeight = 80; // approximate height
const offset = 10;
let left = mousePosition.x + offset;
let top = mousePosition.y - offset;
// Check if tooltip would go off the right edge
if (left + tooltipWidth > window.innerWidth) {
left = mousePosition.x - tooltipWidth - offset;
}
// Check if tooltip would go off the left edge
if (left < 0) {
left = offset;
}
// Check if tooltip would go off the top edge
if (top < 0) {
top = mousePosition.y + offset;
}
// Check if tooltip would go off the bottom edge
if (top + tooltipHeight > window.innerHeight) {
top = window.innerHeight - tooltipHeight - offset;
}
const tooltipContent = (
<div
className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
style={{
position: 'fixed',
top,
left,
pointerEvents: 'none',
zIndex: 1000,
}}
>
<div className="flex justify-between gap-8 text-muted-foreground">
<div>
{new Date(data.date).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
<React.Fragment>
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: getChartColor(0) }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">Active users</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count)}
</div>
</div>
</div>
</div>
</React.Fragment>
</div>
);
return createPortal(tooltipContent, tooltipContainer);
};

View File

@@ -0,0 +1,200 @@
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date';
import { getPreviousMetric } from '@openpanel/common';
import {
PreviousDiffIndicatorPure,
getDiffIndicator,
} from '../report-chart/common/previous-diff-indicator';
import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip';
interface MetricCardProps {
id: string;
data: {
current: number;
previous?: number;
}[];
metric: {
current: number;
previous?: number | null;
};
unit?: '' | 'date' | 'timeAgo' | 'min' | '%';
label: string;
onClick?: () => void;
active?: boolean;
inverted?: boolean;
isLoading?: boolean;
}
export function OverviewMetricCard({
id,
data,
metric,
unit,
label,
onClick,
active,
inverted = false,
isLoading = false,
}: MetricCardProps) {
const number = useNumber();
const { current, previous } = metric;
const renderValue = (value: number, unitClassName?: string, short = true) => {
if (unit === 'date') {
return <>{formatDate(new Date(value))}</>;
}
if (unit === 'timeAgo') {
return <>{timeAgo(new Date(value))}</>;
}
if (unit === 'min') {
return <>{fancyMinutes(value)}</>;
}
return (
<>
{short ? number.short(value) : number.format(value)}
{unit && <span className={unitClassName}>{unit}</span>}
</>
);
};
const graphColors = getDiffIndicator(
inverted,
getPreviousMetric(current, previous)?.state,
'#6ee7b7', // green
'#fda4af', // red
'#93c5fd', // blue
);
return (
<Tooltiper
content={
<span>
{label}:{' '}
<span className="font-semibold">
{renderValue(current, 'ml-1 font-light text-xl', false)}
</span>
</span>
}
asChild
sideOffset={-20}
>
<button
type="button"
className={cn(
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
active && 'bg-def-100',
)}
onClick={onClick}
>
<div className={cn('group relative p-4')}>
<div
className={cn(
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
)}
>
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 4}
data={data}
style={{ marginTop: (height / 4) * 3 }}
>
<defs>
<linearGradient
id={`colorUv${id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={graphColors}
stopOpacity={0.2}
/>
<stop
offset="100%"
stopColor={graphColors}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<Area
dataKey={'current'}
type="step"
fill={`url(#colorUv${id})`}
fillOpacity={1}
stroke={graphColors}
strokeWidth={1}
isAnimationActive={false}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<OverviewMetricCardNumber
label={label}
value={renderValue(current, 'ml-1 font-light text-xl')}
enhancer={
<PreviousDiffIndicatorPure
className="text-sm"
size="sm"
inverted={inverted}
{...getPreviousMetric(current, previous)}
/>
}
isLoading={isLoading}
/>
</div>
</button>
</Tooltiper>
);
}
export function OverviewMetricCardNumber({
label,
value,
enhancer,
className,
isLoading,
}: {
label: React.ReactNode;
value: React.ReactNode;
enhancer?: React.ReactNode;
className?: string;
isLoading?: boolean;
}) {
return (
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
</span>
</div>
</div>
{isLoading ? (
<div className="flex items-end justify-between gap-4">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-12" />
</div>
) : (
<div className="flex items-end justify-between gap-4">
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value}
</div>
{enhancer}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,275 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import type { IInterval } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last, omit } from 'ramda';
import React, { useState } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { createChartTooltip } from '../charts/chart-tooltip';
import { BarShapeBlue } from '../charts/common-bar';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator';
import { Skeleton } from '../skeleton';
import { OverviewLiveHistogram } from './overview-live-histogram';
import { OverviewMetricCard } from './overview-metric-card';
interface OverviewMetricsProps {
projectId: string;
}
const TITLES = [
{
title: 'Unique Visitors',
key: 'unique_visitors',
unit: '',
inverted: false,
},
{
title: 'Sessions',
key: 'total_sessions',
unit: '',
inverted: false,
},
{
title: 'Pageviews',
key: 'total_screen_views',
unit: '',
inverted: false,
},
{
title: 'Pages per session',
key: 'views_per_session',
unit: '',
inverted: false,
},
{
title: 'Bounce Rate',
key: 'bounce_rate',
unit: '%',
inverted: true,
},
{
title: 'Session Duration',
key: 'avg_session_duration',
unit: 'min',
inverted: false,
},
] as const;
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { range, interval, metric, setMetric, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const trpc = useTRPC();
const activeMetric = TITLES[metric]!;
const overviewQuery = useQuery(
trpc.overview.stats.queryOptions({
projectId,
range,
interval,
filters,
startDate,
endDate,
}),
);
const data =
overviewQuery.data?.series?.map((item) => ({
...item,
timestamp: new Date(item.date).getTime(),
})) || [];
return (
<>
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 mt-0 md:m-0">
<div className="card mb-2 grid grid-cols-4 overflow-hidden rounded-md">
{TITLES.map((title, index) => (
<OverviewMetricCard
key={title.key}
id={title.key}
onClick={() => setMetric(index)}
label={title.title}
metric={{
current: overviewQuery.data?.metrics[title.key] ?? 0,
previous: overviewQuery.data?.metrics[`prev_${title.key}`],
}}
unit={title.unit}
data={data.map((item) => ({
current: item[title.key],
previous: item[`prev_${title.key}`],
}))}
active={metric === index}
isLoading={overviewQuery.isLoading}
/>
))}
<div
className={cn(
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-2',
)}
>
<OverviewLiveHistogram projectId={projectId} />
</div>
</div>
<div className="card p-4">
<div className="text-center mb-3 -mt-1 text-sm font-medium text-muted-foreground">
{activeMetric.title}
</div>
<div className="w-full h-[150px]">
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
<Chart
activeMetric={activeMetric}
interval={interval}
data={data}
/>
</div>
</div>
</div>
</>
);
}
const { Tooltip, TooltipProvider } = createChartTooltip<
RouterOutputs['overview']['stats']['series'][number],
{
metric: (typeof TITLES)[number];
interval: IInterval;
}
>(({ context: { metric, interval }, data: dataArray }) => {
const data = dataArray[0];
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
if (!data) {
return null;
}
return (
<>
<div className="flex justify-between gap-8 text-muted-foreground">
<div>{formatDate(new Date(data.date))}</div>
</div>
<React.Fragment>
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: getChartColor(0) }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">{metric.title}</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data[metric.key])}
{!!data[`prev_${metric.key}`] && (
<span className="text-muted-foreground">
({number.formatWithUnit(data[`prev_${metric.key}`])})
</span>
)}
</div>
<PreviousDiffIndicatorPure
{...getPreviousMetric(
data[metric.key],
data[`prev_${metric.key}`],
)}
/>
</div>
</div>
</div>
</React.Fragment>
</>
);
});
function Chart({
activeMetric,
interval,
data,
}: {
activeMetric: (typeof TITLES)[number];
interval: IInterval;
data: RouterOutputs['overview']['stats']['series'];
}) {
const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps();
const [activeBar, setActiveBar] = useState(-1);
return (
<TooltipProvider metric={activeMetric} interval={interval}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{ top: 0, right: 0, left: 0, bottom: 10 }}
onMouseMove={(e) => {
setActiveBar(e.activeTooltipIndex ?? -1);
}}
barCategoryGap={2}
>
<Tooltip
cursor={{
stroke: 'var(--def-200)',
fill: 'var(--def-200)',
}}
/>
<YAxis
{...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'auto']}
width={25}
/>
<XAxis {...omit(['scale', 'type'], xAxisProps)} />
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
<Line
key={`prev_${activeMetric.key}`}
type="step"
dataKey={`prev_${activeMetric.key}`}
stroke={'var(--border)'}
strokeWidth={2}
isAnimationActive={false}
dot={false}
activeDot={{
stroke: 'var(--foreground)',
fill: 'var(--def-100)',
strokeWidth: 1,
r: 2,
}}
/>
<Bar
key={activeMetric.key}
dataKey={activeMetric.key}
isAnimationActive={false}
shape={(props: any) => (
<BarShapeBlue isActive={activeBar === props.index} {...props} />
)}
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,18 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { TimeWindowPicker } from '@/components/time-window-picker';
export function OverviewRange() {
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
useOverviewOptions();
return (
<TimeWindowPicker
onChange={setRange}
value={range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={endDate}
startDate={startDate}
/>
);
}

View File

@@ -0,0 +1,86 @@
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
interface OverviewShareProps {
projectId: string;
}
export function OverviewShare({ projectId }: OverviewShareProps) {
const trpc = useTRPC();
const query = useQuery(
trpc.share.overview.queryOptions(
{
projectId,
},
{
retry(failureCount, error) {
return error.message !== 'Share not found' && failureCount < 3;
},
},
),
);
const data = query.data;
const mutation = useMutation(
trpc.share.createOverview.mutationOptions({
onSuccess() {
query.refetch();
},
}),
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={data?.public ? Globe2Icon : LockIcon} responsive>
{data?.public ? 'Public' : 'Private'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{(!data || data.public === false) && (
<DropdownMenuItem onClick={() => pushModal('ShareOverviewModal')}>
<Globe2Icon size={16} className="mr-2" />
Make public
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem asChild>
<Link
to={'/share/overview/$shareId'}
params={{ shareId: data.id }}
>
<EyeIcon size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem
onClick={() => {
mutation.mutate({
...data,
public: false,
password: null,
});
}}
>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,385 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopDevicesProps {
projectId: string;
}
export default function OverviewTopDevices({
projectId,
}: OverviewTopDevicesProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [chartType] = useState<IChartType>('bar');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
device: {
title: 'Top devices',
btn: 'Devices',
chart: {
options: {
columns: ['Device', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'device',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top devices',
range: range,
previous: previous,
metric: 'sum',
},
},
},
browser: {
title: 'Top browser',
btn: 'Browser',
chart: {
options: {
columns: ['Browser', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top browser',
range: range,
previous: previous,
metric: 'sum',
},
},
},
browser_version: {
title: 'Top Browser Version',
btn: 'Browser Version',
chart: {
options: {
columns: ['Version', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
{
id: 'B',
name: 'browser_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Browser Version',
range: range,
previous: previous,
metric: 'sum',
},
},
},
os: {
title: 'Top OS',
btn: 'OS',
chart: {
options: {
columns: ['OS', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top OS',
range: range,
previous: previous,
metric: 'sum',
},
},
},
os_version: {
title: 'Top OS version',
btn: 'OS Version',
chart: {
options: {
columns: ['Version', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
{
id: 'B',
name: 'os_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top OS version',
range: range,
previous: previous,
metric: 'sum',
},
},
},
brand: {
title: 'Top Brands',
btn: 'Brands',
chart: {
options: {
columns: ['Brand', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'brand',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Brands',
range: range,
previous: previous,
metric: 'sum',
},
},
},
model: {
title: 'Top Models',
btn: 'Models',
chart: {
options: {
columns: ['Model', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'brand',
},
{
id: 'B',
name: 'model',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Models',
range: range,
previous: previous,
metric: 'sum',
},
},
},
});
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
}),
);
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.name || NOT_SET_VALUE} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter(widget.key, item.name);
}}
>
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
)}
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>
);
}

View File

@@ -0,0 +1,184 @@
import { ReportChart } from '@/components/report-chart';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export interface OverviewTopEventsProps {
projectId: string;
}
export default function OverviewTopEvents({
projectId,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const trpc = useTRPC();
const { data: conversions } = useQuery(
trpc.event.conversionNames.queryOptions({ projectId }),
);
const [chartType, setChartType] = useState<IChartType>('bar');
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
your: {
title: 'Top events',
btn: 'Your',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Your top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
all: {
title: 'Top events',
btn: 'All',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [...filters],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'All top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: !conversions || conversions.length === 0,
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Conversions',
range: range,
previous: previous,
metric: 'sum',
},
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-3">
<ReportChart
options={{ hideID: true, columns: ['Event', 'Count'] }}
report={{
...widget.chart.report,
previous: false,
}}
/>
</WidgetBody>
<WidgetFooter>
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>
</>
);
}

View File

@@ -0,0 +1,106 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import type { IGetTopGenericInput } from '@openpanel/db';
import { useInfiniteQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import {
OVERVIEW_COLUMNS_NAME,
OVERVIEW_COLUMNS_NAME_PLURAL,
} from './overview-constants';
import { OverviewWidgetTableGeneric } from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopGenericModalProps {
projectId: string;
column: IGetTopGenericInput['column'];
}
export default function OverviewTopGenericModal({
projectId,
column,
}: OverviewTopGenericModalProps) {
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useInfiniteQuery(
trpc.overview.topGeneric.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
range,
limit: 50,
column,
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length === 0) {
return null;
}
return pages.length + 1;
},
},
),
);
const data = query.data?.pages.flat() || [];
const isEmpty = !query.hasNextPage && !query.isFetching;
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
const columnName = OVERVIEW_COLUMNS_NAME[column];
return (
<ModalContent>
<ModalHeader title={`Top ${columnNamePlural}`} />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTableGeneric
data={data}
column={{
name: columnName,
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter(column, item.name);
}}
>
{item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1">
<span>{item.prefix}</span>
<span>
<ChevronRightIcon className="size-3" />
</span>
</span>
)}
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
disabled={isEmpty}
>
Load more
</Button>
</div>
</ScrollArea>
</ModalContent>
);
}

View File

@@ -0,0 +1,176 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { ReportChart } from '../report-chart';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopGeoProps {
projectId: string;
}
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
country: {
title: 'Top countries',
btn: 'Countries',
},
region: {
title: 'Top regions',
btn: 'Regions',
},
city: {
title: 'Top cities',
btn: 'Cities',
},
});
const number = useNumber();
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
}),
);
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon
name={item.prefix || item.name || NOT_SET_VALUE}
/>
<button
type="button"
className="truncate"
onClick={() => {
if (widget.key === 'country') {
setWidget('region');
} else if (widget.key === 'region') {
setWidget('city');
}
setFilter(widget.key, item.name);
}}
>
{item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1">
<span>{item.prefix}</span>
<span>
<ChevronRightIcon className="size-3" />
</span>
</span>
)}
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
)}
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ReportChart
options={{ hideID: true }}
report={{
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,61 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { OverviewWidgetTablePages } from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopPagesProps {
projectId: string;
}
export default function OverviewTopPagesModal({
projectId,
}: OverviewTopPagesProps) {
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useInfiniteQuery(
trpc.overview.topPages.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
limit: 50,
},
{
getNextPageParam: (_, pages) => pages.length + 1,
},
),
);
const data = query.data?.pages.flat();
return (
<ModalContent>
<ModalHeader title="Top Pages" />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={'Sessions'}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
loading={query.isFetching}
>
Load more
</Button>
</div>
</ScrollArea>
</ModalContent>
);
}

View File

@@ -0,0 +1,127 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { Globe2Icon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { Widget, WidgetBody } from '../widget';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableLoading,
OverviewWidgetTablePages,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopPagesProps {
projectId: string;
}
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
page: {
title: 'Top pages',
btn: 'Top pages',
meta: {
columns: {
sessions: 'Sessions',
},
},
},
entry: {
title: 'Entry Pages',
btn: 'Entries',
meta: {
columns: {
sessions: 'Entries',
},
},
},
exit: {
title: 'Exit Pages',
btn: 'Exits',
meta: {
columns: {
sessions: 'Exits',
},
},
},
// bot: {
// title: 'Bots',
// btn: 'Bots',
// },
});
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topPages.queryOptions({
projectId,
filters,
startDate,
endDate,
mode: widget.key,
range,
}),
);
const data = query.data;
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<>
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
</>
)}
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<Button
variant={'ghost'}
onClick={() => {
setDomain((p) => !p);
}}
icon={Globe2Icon}
>
{domain ? 'Hide domain' : 'Show domain'}
</Button>
</WidgetFooter>
</Widget>
</>
);
}

View File

@@ -0,0 +1,145 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { useQuery } from '@tanstack/react-query';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopSourcesProps {
projectId: string;
}
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
referrer_name: {
title: 'Top sources',
btn: 'All',
},
referrer: {
title: 'Top urls',
btn: 'URLs',
},
referrer_type: {
title: 'Top types',
btn: 'Types',
},
utm_source: {
title: 'UTM Source',
btn: 'Source',
},
utm_medium: {
title: 'UTM Medium',
btn: 'Medium',
},
utm_campaign: {
title: 'UTM Campaign',
btn: 'Campaign',
},
utm_term: {
title: 'UTM Term',
btn: 'Term',
},
utm_content: {
title: 'UTM Content',
btn: 'Content',
},
});
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
}),
);
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.name || NOT_SET_VALUE} />
<button
type="button"
className="truncate"
onClick={() => {
if (widget.key.startsWith('utm_')) {
setFilter(
`properties.__query.${widget.key}`,
item.name,
);
} else {
setFilter(widget.key, item.name);
}
}}
>
{(item.name || 'Direct / Not set')
.replace(/https?:\/\//, '')
.replace('www.', '')}
</button>
</div>
);
},
}}
/>
)}
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>
);
}

View File

@@ -0,0 +1,346 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { ExternalLinkIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip';
import { WidgetTable, type Props as WidgetTableProps } from '../widget-table';
type Props<T> = WidgetTableProps<T> & {
getColumnPercentage: (item: T) => number;
};
export const OverviewWidgetTable = <T,>({
data,
keyExtractor,
columns,
getColumnPercentage,
className,
}: Props<T>) => {
return (
<div className={cn(className)}>
<WidgetTable
data={data ?? []}
keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container [&_.head]:pt-3'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
eachRow={(item) => {
return (
<div className="absolute top-0 left-0 !p-0 w-full h-full">
<div
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative"
style={{
width: `${getColumnPercentage(item) * 100}%`,
}}
/>
</div>
);
}}
columns={columns.map((column, index) => {
return {
...column,
className: cn(
index === 0
? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono',
index !== 0 &&
index !== columns.length - 1 &&
'hidden @[310px]:table-cell',
column.className,
),
};
})}
/>
</div>
);
};
export function OverviewWidgetTableLoading({
className,
}: {
className?: string;
}) {
return (
<OverviewWidgetTable
className={className}
data={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
keyExtractor={(item) => item.toString()}
getColumnPercentage={() => 0}
columns={[
{
name: 'Path',
render: () => <Skeleton className="h-4 w-1/3" />,
width: 'w-full',
},
{
name: 'BR',
render: () => <Skeleton className="h-4 w-[30px]" />,
width: '60px',
},
// {
// name: 'Duration',
// render: () => <Skeleton className="h-4 w-[30px]" />,
// },
{
name: 'Sessions',
render: () => <Skeleton className="h-4 w-[30px]" />,
width: '84px',
},
]}
/>
);
}
function getPath(path: string, showDomain = false) {
try {
const url = new URL(path);
if (showDomain) {
return url.hostname + url.pathname;
}
return url.pathname;
} catch {
return path;
}
}
export function OverviewWidgetTablePages({
data,
lastColumnName,
className,
showDomain = false,
}: {
className?: string;
lastColumnName: string;
data: {
origin: string;
path: string;
avg_duration: number;
bounce_rate: number;
sessions: number;
}[];
showDomain?: boolean;
}) {
const [_filters, setFilter] = useEventQueryFilters();
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.path + item.origin}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
{
name: 'Path',
width: 'w-full',
render(item) {
return (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{item.path ? (
<>
{showDomain ? (
<>
<span className="opacity-40">{item.origin}</span>
<span>{item.path}</span>
</>
) : (
item.path
)}
</>
) : (
<span className="opacity-40">Not set</span>
)}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
</a>
</div>
</Tooltiper>
);
},
},
{
name: 'BR',
width: '60px',
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
{
name: 'Duration',
width: '75px',
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
},
},
{
name: lastColumnName,
width: '84px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.sessions)}
</span>
</div>
);
},
},
]}
/>
);
}
export function OverviewWidgetTableBots({
data,
className,
}: {
className?: string;
data: {
total_sessions: number;
origin: string;
path: string;
sessions: number;
avg_duration: number;
bounce_rate: number;
}[];
}) {
const [filters, setFilter] = useEventQueryFilters();
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.path + item.origin}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
{
name: 'Path',
width: 'w-full',
render(item) {
return (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter('path', item.path);
}}
>
{getPath(item.path)}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
</a>
</div>
</Tooltiper>
);
},
},
{
name: 'Bot',
width: '60px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">Google bot</span>
</div>
);
},
},
{
name: 'Date',
width: '60px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">Google bot</span>
</div>
);
},
},
]}
/>
);
}
export function OverviewWidgetTableGeneric({
data,
column,
className,
}: {
className?: string;
data: RouterOutputs['overview']['topGeneric'];
column: {
name: string;
render: (
item: RouterOutputs['overview']['topGeneric'][number],
) => React.ReactNode;
};
}) {
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.name}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
{
...column,
width: 'w-full',
},
{
name: 'BR',
width: '60px',
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
// {
// name: 'Duration',
// render(item) {
// return number.shortWithUnit(item.avg_session_duration, 'min');
// },
// },
{
name: 'Sessions',
width: '84px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.sessions)}
</span>
</div>
);
},
},
]}
/>
);
}

View File

@@ -0,0 +1,188 @@
import { useThrottle } from '@/hooks/use-throttle';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon, Icon, type LucideIcon } from 'lucide-react';
import { last } from 'ramda';
import { Children, useEffect, useRef, useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
import { WidgetHead as WidgetHeadBase } from '../widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn(
'relative flex flex-col rounded-t-xl p-0 [&_.title]:flex [&_.title]:items-center [&_.title]:p-4 [&_.title]:font-semibold',
className,
)}
{...props}
/>
);
}
export function WidgetTitle({
children,
className,
icon: Icon,
...props
}: WidgetTitleProps & {
icon?: LucideIcon;
}) {
return (
<div
className={cn('title text-left row justify-start', className)}
{...props}
>
{Icon && (
<div className="rounded-lg bg-def-200 p-1 mr-2">
<Icon size={16} />
</div>
)}
{children}
</div>
);
}
export function WidgetAbsoluteButtons({
className,
children,
...props
}: WidgetHeadProps) {
return (
<div
className={cn(
'row gap-1 absolute right-4 top-1/2 -translate-y-1/2',
className,
)}
{...props}
>
{children}
</div>
);
}
export function WidgetButtons({
className,
children,
...props
}: WidgetHeadProps) {
const container = useRef<HTMLDivElement>(null);
const sizes = useRef<number[]>([]);
const [slice, setSlice] = useState(3); // Show 3 buttons by default
const gap = 16;
const handleResize = useThrottle(() => {
if (container.current) {
if (sizes.current.length === 0) {
// Get buttons
const buttons: HTMLButtonElement[] = Array.from(
container.current.querySelectorAll('button'),
);
// Get sizes and cache them
sizes.current = buttons.map(
(button) => Math.ceil(button.offsetWidth) + gap,
);
}
const containerWidth = container.current.offsetWidth;
const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
const moreWidth = (last(sizes.current) ?? 0) + gap;
if (buttonsWidth > containerWidth) {
const res = sizes.current.reduce(
(acc, size, index) => {
if (acc.size + size + moreWidth > containerWidth) {
return { index: acc.index, size: acc.size + size };
}
return { index, size: acc.size + size };
},
{ index: 0, size: 0 },
);
setSlice(res.index);
} else {
setSlice(sizes.current.length - 1);
}
}
}, 30);
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize, children]);
const hidden = '!opacity-0 absolute pointer-events-none';
return (
<div
ref={container}
className={cn(
'-mb-px -mt-2 flex flex-wrap justify-start self-stretch px-4 transition-opacity [&_button.active]:border-b-2 [&_button.active]:border-black [&_button.active]:opacity-100 dark:[&_button.active]:border-white [&_button]:whitespace-nowrap [&_button]:py-1 [&_button]:text-sm [&_button]:opacity-50',
className,
)}
style={{ gap }}
{...props}
>
{Children.map(children, (child, index) => {
return (
<div
className={cn(
'flex [&_button]:leading-normal',
slice < index ? hidden : 'opacity-100',
)}
>
{child}
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'flex select-none items-center gap-1',
sizes.current.length - 1 === slice ? hidden : 'opacity-50',
)}
>
More <ChevronsUpDownIcon size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="[&_button]:w-full">
<DropdownMenuGroup>
{Children.map(children, (child, index) => {
if (index <= slice) {
return null;
}
return <DropdownMenuItem asChild>{child}</DropdownMenuItem>;
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function WidgetFooter({
className,
children,
...props
}: WidgetHeadProps) {
return (
<div
className={cn(
'flex rounded-b-md border-t bg-def-100 p-2 py-1',
className,
)}
{...props}
>
{children}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More