feat: dashboard v2, esm, upgrades (#211)
* esm * wip * wip * wip * wip * wip * wip * subscription notice * wip * wip * wip * fix envs * fix: update docker build * fix * esm/types * delete dashboard :D * add patches to dockerfiles * update packages + catalogs + ts * wip * remove native libs * ts * improvements * fix redirects and fetching session * try fix favicon * fixes * fix * order and resize reportds within a dashboard * improvements * wip * added userjot to dashboard * fix * add op * wip * different cache key * improve date picker * fix table * event details loading * redo onboarding completely * fix login * fix * fix * extend session, billing and improve bars * fix * reduce price on 10M
This commit is contained in:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
21
apps/start/src/components/animate-height.tsx
Normal file
21
apps/start/src/components/animate-height.tsx
Normal 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;
|
||||
20
apps/start/src/components/animated-number.tsx
Normal file
20
apps/start/src/components/animated-number.tsx
Normal 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} />;
|
||||
}
|
||||
11
apps/start/src/components/auth/or.tsx
Normal file
11
apps/start/src/components/auth/or.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/start/src/components/auth/reset-password-form.tsx
Normal file
68
apps/start/src/components/auth/reset-password-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/start/src/components/auth/share-enter-password.tsx
Normal file
75
apps/start/src/components/auth/share-enter-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/start/src/components/auth/sign-in-email-form.tsx
Normal file
73
apps/start/src/components/auth/sign-in-email-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/start/src/components/auth/sign-in-github.tsx
Normal file
47
apps/start/src/components/auth/sign-in-github.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/start/src/components/auth/sign-in-google.tsx
Normal file
59
apps/start/src/components/auth/sign-in-google.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/start/src/components/auth/sign-up-email-form.tsx
Normal file
87
apps/start/src/components/auth/sign-up-email-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/start/src/components/button-container.tsx
Normal file
11
apps/start/src/components/button-container.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
48
apps/start/src/components/card.tsx
Normal file
48
apps/start/src/components/card.tsx
Normal 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;
|
||||
112
apps/start/src/components/chart-ssr.tsx
Normal file
112
apps/start/src/components/chart-ssr.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/start/src/components/charts/chart-tooltip.tsx
Normal file
59
apps/start/src/components/charts/chart-tooltip.tsx
Normal 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} />} />
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
47
apps/start/src/components/charts/common-bar.tsx
Normal file
47
apps/start/src/components/charts/common-bar.tsx
Normal 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)',
|
||||
},
|
||||
});
|
||||
70
apps/start/src/components/chat/chat-form.tsx
Normal file
70
apps/start/src/components/chat/chat-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/start/src/components/chat/chat-message.tsx
Normal file
144
apps/start/src/components/chat/chat-message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/start/src/components/chat/chat-messages.tsx
Normal file
80
apps/start/src/components/chat/chat-messages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/start/src/components/chat/chat-report.tsx
Normal file
85
apps/start/src/components/chat/chat-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/start/src/components/chat/chat.tsx
Normal file
74
apps/start/src/components/chat/chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/start/src/components/click-to-copy.tsx
Normal file
28
apps/start/src/components/click-to-copy.tsx
Normal 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;
|
||||
38
apps/start/src/components/clients/create-client-success.tsx
Normal file
38
apps/start/src/components/clients/create-client-success.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
apps/start/src/components/clients/table/columns.tsx
Normal file
91
apps/start/src/components/clients/table/columns.tsx
Normal 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;
|
||||
}
|
||||
30
apps/start/src/components/clients/table/index.tsx
Normal file
30
apps/start/src/components/clients/table/index.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
17
apps/start/src/components/color-square.tsx
Normal file
17
apps/start/src/components/color-square.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/start/src/components/dot.tsx
Normal file
46
apps/start/src/components/dot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
apps/start/src/components/events/event-icon.tsx
Normal file
241
apps/start/src/components/events/event-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/start/src/components/events/event-list-item.tsx
Normal file
114
apps/start/src/components/events/event-list-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
apps/start/src/components/events/event-listener.tsx
Normal file
74
apps/start/src/components/events/event-listener.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/start/src/components/events/list-properties-icon.tsx
Normal file
49
apps/start/src/components/events/list-properties-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
apps/start/src/components/events/table/columns.tsx
Normal file
222
apps/start/src/components/events/table/columns.tsx
Normal 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;
|
||||
}
|
||||
298
apps/start/src/components/events/table/index.tsx
Normal file
298
apps/start/src/components/events/table/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
apps/start/src/components/events/table/item.tsx
Normal file
178
apps/start/src/components/events/table/item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/start/src/components/fade-in.tsx
Normal file
25
apps/start/src/components/fade-in.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/start/src/components/feedback-button.tsx
Normal file
27
apps/start/src/components/feedback-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/start/src/components/forms/checkbox-item.tsx
Normal file
54
apps/start/src/components/forms/checkbox-item.tsx
Normal 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';
|
||||
29
apps/start/src/components/forms/copy-input.tsx
Normal file
29
apps/start/src/components/forms/copy-input.tsx
Normal 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;
|
||||
68
apps/start/src/components/forms/input-with-label.tsx
Normal file
68
apps/start/src/components/forms/input-with-label.tsx
Normal 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';
|
||||
152
apps/start/src/components/forms/tag-input.tsx
Normal file
152
apps/start/src/components/forms/tag-input.tsx
Normal 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;
|
||||
39
apps/start/src/components/full-page-empty-state.tsx
Normal file
39
apps/start/src/components/full-page-empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/start/src/components/full-page-error-state.tsx
Normal file
20
apps/start/src/components/full-page-error-state.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
apps/start/src/components/full-page-loading-state.tsx
Normal file
25
apps/start/src/components/full-page-loading-state.tsx
Normal 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;
|
||||
21
apps/start/src/components/full-width-navbar.tsx
Normal file
21
apps/start/src/components/full-width-navbar.tsx
Normal 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;
|
||||
109
apps/start/src/components/fullscreen-toggle.tsx
Normal file
109
apps/start/src/components/fullscreen-toggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
87
apps/start/src/components/grid-table.tsx
Normal file
87
apps/start/src/components/grid-table.tsx
Normal 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>
|
||||
);
|
||||
121
apps/start/src/components/integrations/active-integrations.tsx
Normal file
121
apps/start/src/components/integrations/active-integrations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/start/src/components/integrations/all-integrations.tsx
Normal file
34
apps/start/src/components/integrations/all-integrations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
144
apps/start/src/components/integrations/integration-card.tsx
Normal file
144
apps/start/src/components/integrations/integration-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/start/src/components/integrations/integrations.tsx
Normal file
49
apps/start/src/components/integrations/integrations.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
79
apps/start/src/components/lazy-component.tsx
Normal file
79
apps/start/src/components/lazy-component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
apps/start/src/components/links.tsx
Normal file
36
apps/start/src/components/links.tsx
Normal 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>;
|
||||
}
|
||||
104
apps/start/src/components/login-left-panel.tsx
Normal file
104
apps/start/src/components/login-left-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/start/src/components/logo.tsx
Normal file
26
apps/start/src/components/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/start/src/components/markdown.tsx
Normal file
26
apps/start/src/components/markdown.tsx
Normal 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';
|
||||
142
apps/start/src/components/mock-event-list.tsx
Normal file
142
apps/start/src/components/mock-event-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
16
apps/start/src/components/notifications/notifications.tsx
Normal file
16
apps/start/src/components/notifications/notifications.tsx
Normal 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} />;
|
||||
}
|
||||
133
apps/start/src/components/notifications/rule-card.tsx
Normal file
133
apps/start/src/components/notifications/rule-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
apps/start/src/components/notifications/table/columns.tsx
Normal file
184
apps/start/src/components/notifications/table/columns.tsx
Normal 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;
|
||||
}
|
||||
32
apps/start/src/components/notifications/table/index.tsx
Normal file
32
apps/start/src/components/notifications/table/index.tsx
Normal 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} />;
|
||||
</>
|
||||
);
|
||||
};
|
||||
132
apps/start/src/components/onboarding-left-panel.tsx
Normal file
132
apps/start/src/components/onboarding-left-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/start/src/components/onboarding/connect-app.tsx
Normal file
57
apps/start/src/components/onboarding/connect-app.tsx
Normal 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;
|
||||
86
apps/start/src/components/onboarding/connect-backend.tsx
Normal file
86
apps/start/src/components/onboarding/connect-backend.tsx
Normal 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;
|
||||
78
apps/start/src/components/onboarding/connect-web.tsx
Normal file
78
apps/start/src/components/onboarding/connect-web.tsx
Normal 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;
|
||||
72
apps/start/src/components/onboarding/curl-preview.tsx
Normal file
72
apps/start/src/components/onboarding/curl-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
71
apps/start/src/components/onboarding/skip-onboarding.tsx
Normal file
71
apps/start/src/components/onboarding/skip-onboarding.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
apps/start/src/components/onboarding/steps.tsx
Normal file
117
apps/start/src/components/onboarding/steps.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
70
apps/start/src/components/organization/billing-faq.tsx
Normal file
70
apps/start/src/components/organization/billing-faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
432
apps/start/src/components/organization/billing.tsx
Normal file
432
apps/start/src/components/organization/billing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
apps/start/src/components/organization/current-subscription.tsx
Normal file
285
apps/start/src/components/organization/current-subscription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/start/src/components/organization/edit-organization.tsx
Normal file
101
apps/start/src/components/organization/edit-organization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/start/src/components/organization/organization.tsx
Normal file
15
apps/start/src/components/organization/organization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
374
apps/start/src/components/organization/usage.tsx
Normal file
374
apps/start/src/components/organization/usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/start/src/components/overview/filters/origin-filter.tsx
Normal file
49
apps/start/src/components/overview/filters/origin-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
81
apps/start/src/components/overview/live-counter.tsx
Normal file
81
apps/start/src/components/overview/live-counter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/start/src/components/overview/overview-chart-toggle.tsx
Normal file
28
apps/start/src/components/overview/overview-chart-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/start/src/components/overview/overview-constants.tsx
Normal file
49
apps/start/src/components/overview/overview-constants.tsx
Normal 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',
|
||||
};
|
||||
@@ -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;
|
||||
49
apps/start/src/components/overview/overview-interval.tsx
Normal file
49
apps/start/src/components/overview/overview-interval.tsx
Normal 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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
251
apps/start/src/components/overview/overview-live-histogram.tsx
Normal file
251
apps/start/src/components/overview/overview-live-histogram.tsx
Normal 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);
|
||||
};
|
||||
200
apps/start/src/components/overview/overview-metric-card.tsx
Normal file
200
apps/start/src/components/overview/overview-metric-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
275
apps/start/src/components/overview/overview-metrics.tsx
Normal file
275
apps/start/src/components/overview/overview-metrics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/start/src/components/overview/overview-range.tsx
Normal file
18
apps/start/src/components/overview/overview-range.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
86
apps/start/src/components/overview/overview-share.tsx
Normal file
86
apps/start/src/components/overview/overview-share.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
385
apps/start/src/components/overview/overview-top-devices.tsx
Normal file
385
apps/start/src/components/overview/overview-top-devices.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
184
apps/start/src/components/overview/overview-top-events.tsx
Normal file
184
apps/start/src/components/overview/overview-top-events.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
176
apps/start/src/components/overview/overview-top-geo.tsx
Normal file
176
apps/start/src/components/overview/overview-top-geo.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
127
apps/start/src/components/overview/overview-top-pages.tsx
Normal file
127
apps/start/src/components/overview/overview-top-pages.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
apps/start/src/components/overview/overview-top-sources.tsx
Normal file
145
apps/start/src/components/overview/overview-top-sources.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
346
apps/start/src/components/overview/overview-widget-table.tsx
Normal file
346
apps/start/src/components/overview/overview-widget-table.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
188
apps/start/src/components/overview/overview-widget.tsx
Normal file
188
apps/start/src/components/overview/overview-widget.tsx
Normal 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
Reference in New Issue
Block a user