chore:little fixes and formating and linting and patches
This commit is contained in:
@@ -9,9 +9,9 @@ type Props = {
|
||||
const AnimateHeight = ({ children, className, open }: Props) => {
|
||||
return (
|
||||
<ReactAnimateHeight
|
||||
className={className}
|
||||
duration={300}
|
||||
height={open ? 'auto' : 0}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</ReactAnimateHeight>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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>
|
||||
<span className="px-2 font-medium text-muted-foreground text-sm">OR</span>
|
||||
<div className="h-px w-full bg-def-300" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { zResetPassword } from '@openpanel/validation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -8,6 +7,7 @@ import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
import { InputWithLabel } from '../forms/input-with-label';
|
||||
import { Button } from '../ui/button';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
const validator = zResetPassword;
|
||||
type IForm = z.infer<typeof validator>;
|
||||
@@ -26,7 +26,7 @@ export function ResetPasswordForm({ token }: { token: string }) {
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm<IForm>({
|
||||
@@ -44,17 +44,17 @@ export function ResetPasswordForm({ token }: { token: string }) {
|
||||
return (
|
||||
<div className="col gap-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||
<h1 className="mb-2 font-bold text-3xl text-foreground">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="underline">
|
||||
<a className="underline" href="/login">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="col gap-6">
|
||||
<form className="col gap-6" onSubmit={onSubmit}>
|
||||
<InputWithLabel
|
||||
label="New password"
|
||||
placeholder="New password"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -7,6 +6,7 @@ import { toast } from 'sonner';
|
||||
import { PublicPageCard } from '../public-page-card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
export function ShareEnterPassword({
|
||||
shareId,
|
||||
@@ -24,7 +24,7 @@ export function ShareEnterPassword({
|
||||
onError() {
|
||||
toast.error('Incorrect password');
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
const form = useForm<ISignInShare>({
|
||||
resolver: zodResolver(zSignInShare),
|
||||
@@ -52,15 +52,15 @@ export function ShareEnterPassword({
|
||||
|
||||
return (
|
||||
<PublicPageCard
|
||||
title={`${typeLabel} is locked`}
|
||||
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
|
||||
title={`${typeLabel} is locked`}
|
||||
>
|
||||
<form onSubmit={onSubmit} className="col gap-4">
|
||||
<form className="col gap-4" onSubmit={onSubmit}>
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
type="password"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { useMutation } from '@tanstack/react-query';
|
||||
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';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
|
||||
const validator = zSignInEmail;
|
||||
type IForm = z.infer<typeof validator>;
|
||||
@@ -24,7 +23,7 @@ export function SignInEmailForm({ isLastUsed }: { isLastUsed?: boolean }) {
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
const form = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
@@ -40,38 +39,38 @@ export function SignInEmailForm({ isLastUsed }: { isLastUsed?: boolean }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
|
||||
<form className="col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<InputWithLabel
|
||||
{...form.register('email')}
|
||||
className="border-def-300 bg-def-100/50 focus:border-highlight focus:ring-highlight/20"
|
||||
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')}
|
||||
className="border-def-300 bg-def-100/50 focus:border-highlight focus:ring-highlight/20"
|
||||
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"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Button type="submit" size="lg" className="w-full">
|
||||
<Button className="w-full" size="lg" type="submit">
|
||||
Sign in
|
||||
</Button>
|
||||
{isLastUsed && (
|
||||
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
|
||||
<span className="absolute -top-2 right-3 rounded-full bg-highlight px-1.5 py-0.5 font-medium text-[10px] text-white leading-none">
|
||||
Used last time
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-center text-muted-foreground text-sm transition-colors duration-200 hover:text-highlight hover:underline"
|
||||
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"
|
||||
type="button"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Button } from '../ui/button';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
export function SignInGithub({
|
||||
type,
|
||||
inviteId,
|
||||
isLastUsed,
|
||||
}: { type: 'sign-in' | 'sign-up'; inviteId?: string; isLastUsed?: boolean }) {
|
||||
}: {
|
||||
type: 'sign-in' | 'sign-up';
|
||||
inviteId?: string;
|
||||
isLastUsed?: boolean;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const mutation = useMutation(
|
||||
trpc.auth.signInOAuth.mutationOptions({
|
||||
@@ -15,38 +19,42 @@ export function SignInGithub({
|
||||
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';
|
||||
if (type === 'sign-in') {
|
||||
return 'Sign in with Github';
|
||||
}
|
||||
if (type === 'sign-up') {
|
||||
return 'Sign up with Github';
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="relative">
|
||||
<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"
|
||||
className="w-full bg-primary text-primary-foreground shadow-sm transition-all duration-200 hover:bg-primary/90 hover:shadow-md [&_svg]:shrink-0"
|
||||
onClick={() =>
|
||||
mutation.mutate({
|
||||
provider: 'github',
|
||||
inviteId: type === 'sign-up' ? inviteId : undefined,
|
||||
})
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
<svg
|
||||
className="size-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
className="mr-2 size-4"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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>
|
||||
{isLastUsed && (
|
||||
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
|
||||
<span className="absolute -top-2 right-3 rounded-full bg-highlight px-1.5 py-0.5 font-medium text-[10px] text-white leading-none">
|
||||
Used last time
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { zSignUpEmail } from '@openpanel/validation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
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';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
const validator = zSignUpEmail;
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export function SignUpEmailForm({
|
||||
inviteId,
|
||||
}: { inviteId: string | undefined }) {
|
||||
}: {
|
||||
inviteId: string | undefined;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const mutation = useMutation(
|
||||
trpc.auth.signUpEmail.mutationOptions({
|
||||
@@ -26,7 +26,7 @@ export function SignUpEmailForm({
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
const form = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
@@ -39,46 +39,46 @@ export function SignUpEmailForm({
|
||||
};
|
||||
return (
|
||||
<form className="col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="row gap-4 w-full flex-1">
|
||||
<div className="row w-full flex-1 gap-4">
|
||||
<InputWithLabel
|
||||
label="First name"
|
||||
className="flex-1"
|
||||
label="First name"
|
||||
type="text"
|
||||
{...form.register('firstName')}
|
||||
error={form.formState.errors.firstName?.message}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Last name"
|
||||
className="flex-1"
|
||||
label="Last name"
|
||||
type="text"
|
||||
{...form.register('lastName')}
|
||||
error={form.formState.errors.lastName?.message}
|
||||
/>
|
||||
</div>
|
||||
<InputWithLabel
|
||||
label="Email"
|
||||
className="w-full"
|
||||
label="Email"
|
||||
type="email"
|
||||
{...form.register('email')}
|
||||
error={form.formState.errors.email?.message}
|
||||
/>
|
||||
<div className="row gap-4 w-full">
|
||||
<div className="row w-full gap-4">
|
||||
<InputWithLabel
|
||||
label="Password"
|
||||
className="flex-1"
|
||||
label="Password"
|
||||
type="password"
|
||||
{...form.register('password')}
|
||||
error={form.formState.errors.password?.message}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Confirm password"
|
||||
className="flex-1"
|
||||
label="Confirm password"
|
||||
type="password"
|
||||
{...form.register('confirmPassword')}
|
||||
error={form.formState.errors.confirmPassword?.message}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" size="lg">
|
||||
<Button className="w-full" size="lg" type="submit">
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
} 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;
|
||||
@@ -19,7 +19,7 @@ export function Card({ children, hover, className }: CardProps) {
|
||||
className={cn(
|
||||
'card relative',
|
||||
hover && 'transition-all hover:-translate-y-0.5',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -32,7 +32,7 @@ interface CardActionsProps {
|
||||
}
|
||||
export function CardActions({ children }: CardActionsProps) {
|
||||
return (
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
|
||||
<MoreHorizontal size={16} />
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { createContext, useContext as useBaseContext } from 'react';
|
||||
|
||||
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export const ChartTooltipContainer = ({
|
||||
children,
|
||||
className,
|
||||
}: { children: React.ReactNode; className?: string }) => {
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm',
|
||||
className,
|
||||
'col min-w-[180px] gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -21,7 +23,9 @@ export const ChartTooltipContainer = ({
|
||||
|
||||
export const ChartTooltipHeader = ({
|
||||
children,
|
||||
}: { children: React.ReactNode }) => {
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return <div className="flex justify-between gap-8">{children}</div>;
|
||||
};
|
||||
|
||||
@@ -53,7 +57,7 @@ export function createChartTooltip<
|
||||
context: PropsFromContext;
|
||||
data: PropsFromTooltip[];
|
||||
} & TooltipProps<number, string>
|
||||
>,
|
||||
>
|
||||
) {
|
||||
const context = createContext<PropsFromContext | null>(null);
|
||||
const useContext = () => {
|
||||
@@ -68,13 +72,13 @@ export function createChartTooltip<
|
||||
const context = useContext();
|
||||
const data = tooltip.payload?.map((p) => p.payload) ?? [];
|
||||
|
||||
if (!data || !tooltip.active) {
|
||||
if (!(data && tooltip.active)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartTooltipContainer>
|
||||
<Tooltip data={data} context={context} {...tooltip} />
|
||||
<Tooltip context={context} data={data} {...tooltip} />
|
||||
</ChartTooltipContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
||||
import { Bar } from 'recharts';
|
||||
|
||||
type Options = {
|
||||
borderHeight: number;
|
||||
@@ -35,23 +34,23 @@ export const BarWithBorder = (options: Options) => {
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
fill={withActive(fill)}
|
||||
height={height}
|
||||
rx={3}
|
||||
stroke="none"
|
||||
width={width}
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
stroke="none"
|
||||
fill={withActive(fill)}
|
||||
rx={3}
|
||||
/>
|
||||
{value > 0 && (
|
||||
<rect
|
||||
fill={withActive(border)}
|
||||
height={options.borderHeight}
|
||||
rx={2}
|
||||
stroke="none"
|
||||
width={width}
|
||||
x={x}
|
||||
y={y - options.borderHeight - 1}
|
||||
width={width}
|
||||
height={options.borderHeight}
|
||||
stroke="none"
|
||||
fill={withActive(border)}
|
||||
rx={2}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
@@ -105,5 +104,5 @@ const BarShapes = [...new Array(13)].map((_, index) =>
|
||||
border: getChartColor(index),
|
||||
fill: getChartTranslucentColor(index),
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
type Props = Pick<
|
||||
ReturnType<typeof useChat>,
|
||||
@@ -22,7 +22,7 @@ export function ChatForm({
|
||||
}: Props) {
|
||||
const [quickActions, setQuickActions] = useLocalStorage<string[]>(
|
||||
`chat-quick-actions:${projectId}`,
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -31,38 +31,38 @@ export function ChatForm({
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="absolute right-0 bottom-0 left-0 z-20 bg-gradient-to-t from-def-100 to-def-100/50 backdrop-blur-sm">
|
||||
<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,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{q}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<form onSubmit={handleSubmit} className="p-4 pt-2">
|
||||
<form className="p-4 pt-2" onSubmit={handleSubmit}>
|
||||
<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',
|
||||
'h-12 w-full rounded-md border border-border bg-background/50 px-4 font-mono text-foreground outline-none placeholder:text-foreground/50',
|
||||
isLimited && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
value={input}
|
||||
placeholder="Ask me anything..."
|
||||
disabled={isLimited}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Ask me anything..."
|
||||
value={input}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Markdown } from '@/components/markdown';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { zReport } from '@openpanel/validation';
|
||||
import { z } from 'zod';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { Loader2Icon, UserIcon } from 'lucide-react';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Card } from '../card';
|
||||
import { LogoSquare } from '../logo';
|
||||
import { Skeleton } from '../skeleton';
|
||||
@@ -16,6 +14,8 @@ import {
|
||||
AccordionTrigger,
|
||||
} from '../ui/accordion';
|
||||
import { ChatReport } from './chat-report';
|
||||
import { Markdown } from '@/components/markdown';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export const ChatMessage = memo(
|
||||
({
|
||||
@@ -31,29 +31,29 @@ export const ChatMessage = memo(
|
||||
}) => {
|
||||
const showIsStreaming = isLast && isStreaming;
|
||||
return (
|
||||
<div className="max-w-xl w-full">
|
||||
<div className="w-full max-w-xl">
|
||||
<div className="row">
|
||||
<div className="w-8 shrink-0">
|
||||
<div className="size-6 relative">
|
||||
<div className="relative size-6">
|
||||
{message.role === 'assistant' ? (
|
||||
<LogoSquare className="size-full rounded-full" />
|
||||
) : (
|
||||
<div className="size-full bg-black text-white rounded-full center-center">
|
||||
<div className="center-center size-full rounded-full bg-black text-white">
|
||||
<UserIcon className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bg-background rounded-full center-center opacity-0',
|
||||
showIsStreaming && 'opacity-100',
|
||||
'center-center absolute inset-0 rounded-full bg-background opacity-0',
|
||||
showIsStreaming && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-4 flex-1">
|
||||
<div className="col flex-1 gap-4">
|
||||
{message.parts.map((p, index) => {
|
||||
const key = index.toString() + p.type;
|
||||
const isToolInvocation = p.type === 'tool-invocation';
|
||||
@@ -78,15 +78,17 @@ export const ChatMessage = memo(
|
||||
const { result } = p.toolInvocation;
|
||||
|
||||
if (result.type === 'report') {
|
||||
const report = zReport.extend({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
}).safeParse(result.report);
|
||||
const report = zReport
|
||||
.extend({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
})
|
||||
.safeParse(result.report);
|
||||
if (report.success) {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<Debug json={result} enabled={debug} />
|
||||
<ChatReport report={report.data} lazy={!isLast} />
|
||||
<Debug enabled={debug} json={result} />
|
||||
<ChatReport lazy={!isLast} report={report.data} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -94,9 +96,9 @@ export const ChatMessage = memo(
|
||||
|
||||
return (
|
||||
<Debug
|
||||
key={key}
|
||||
json={p.toolInvocation.result}
|
||||
enabled={debug}
|
||||
json={p.toolInvocation.result}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -104,22 +106,22 @@ export const ChatMessage = memo(
|
||||
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 className="col w-full gap-2">
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-4 w-2/5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-full shrink-0 pl-8 mt-4">
|
||||
<div className="mt-4 w-full shrink-0 pl-8">
|
||||
<div className="h-px bg-border" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Debug({ enabled, json }: { enabled?: boolean; json?: any }) {
|
||||
@@ -128,17 +130,17 @@ function Debug({ enabled, json }: { enabled?: boolean; json?: any }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="single" collapsible>
|
||||
<Accordion collapsible type="single">
|
||||
<Card>
|
||||
<AccordionItem value={'json'}>
|
||||
<AccordionTrigger className="text-left p-4 py-2 w-full font-medium font-mono row items-center">
|
||||
<AccordionTrigger className="row w-full items-center p-4 py-2 text-left font-medium font-mono">
|
||||
<span className="flex-1">Show JSON result</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<Syntax
|
||||
wrapLines
|
||||
language="json"
|
||||
code={JSON.stringify(json, null, 2)}
|
||||
language="json"
|
||||
wrapLines
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useScrollAnchor } from '@/hooks/use-scroll-anchor';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
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';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useScrollAnchor } from '@/hooks/use-scroll-anchor';
|
||||
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
@@ -37,25 +36,25 @@ export function ChatMessages({
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full" ref={scrollRef}>
|
||||
<div ref={messagesRef} className="p-8 col gap-2">
|
||||
<div className="col gap-2 p-8" ref={messagesRef}>
|
||||
{messages.map((m, index) => {
|
||||
return (
|
||||
<ChatMessage
|
||||
debug={debug}
|
||||
isLast={index === messages.length - 1}
|
||||
isStreaming={status === 'streaming'}
|
||||
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 className="card center-center max-w-xl p-4 pl-8">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{isLimited && (
|
||||
<div className="max-w-xl pl-8 mt-8">
|
||||
<div className="mt-8 max-w-xl pl-8">
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>Upgrade your account</AlertTitle>
|
||||
<AlertDescription>
|
||||
@@ -64,11 +63,11 @@ export function ChatMessages({
|
||||
</p>
|
||||
<p>
|
||||
<Link
|
||||
to="/$organizationId/billing"
|
||||
className="font-medium underline"
|
||||
params={{
|
||||
organizationId,
|
||||
}}
|
||||
className="font-medium underline"
|
||||
to="/$organizationId/billing"
|
||||
>
|
||||
Visit Billing
|
||||
</Link>{' '}
|
||||
@@ -78,8 +77,8 @@ export function ChatMessages({
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-20 p-4 w-full" />
|
||||
<div className="w-full h-px" ref={visibilityRef} />
|
||||
<div className="h-20 w-full p-4" />
|
||||
<div className="h-px w-full" ref={visibilityRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
import { ChatForm } from '@/components/chat/chat-form';
|
||||
import { ChatMessages } from '@/components/chat/chat-messages';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
|
||||
const getErrorMessage = (error: Error) => {
|
||||
try {
|
||||
@@ -50,24 +50,24 @@ export default function Chat({
|
||||
organization.isTrial ||
|
||||
organization.isWillBeCanceled ||
|
||||
organization.isExceeded ||
|
||||
organization.isExpired),
|
||||
organization.isExpired)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full col relative">
|
||||
<div className="col relative h-screen w-full">
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
debug={debug}
|
||||
status={status}
|
||||
isLimited={isLimited}
|
||||
messages={messages}
|
||||
status={status}
|
||||
/>
|
||||
<ChatForm
|
||||
append={append}
|
||||
handleInputChange={handleInputChange}
|
||||
handleSubmit={handleSubmit}
|
||||
input={input}
|
||||
handleInputChange={handleInputChange}
|
||||
append={append}
|
||||
projectId={projectId}
|
||||
isLimited={isLimited}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { clipboard } from '@/utils/clipboard';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Tooltiper } from './ui/tooltip';
|
||||
import { clipboard } from '@/utils/clipboard';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -12,9 +11,9 @@ type Props = {
|
||||
const ClickToCopy = ({ children, value }: Props) => {
|
||||
return (
|
||||
<Tooltiper
|
||||
content="Click to copy"
|
||||
asChild
|
||||
className="cursor-pointer"
|
||||
content="Click to copy"
|
||||
onClick={() => {
|
||||
clipboard(value);
|
||||
toast('Copied to clipboard');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { RocketIcon } from 'lucide-react';
|
||||
|
||||
import CopyInput from '../forms/copy-input';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
type Props = { id: string; secret: string };
|
||||
|
||||
@@ -12,7 +11,7 @@ export function CreateClientSuccess({ id, secret }: Props) {
|
||||
{secret && (
|
||||
<div className="w-full">
|
||||
<CopyInput label="Secret" value={secret} />
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
You will only need the secret if you want to send server events.
|
||||
</p>
|
||||
</div>
|
||||
@@ -23,10 +22,10 @@ export function CreateClientSuccess({ id, secret }: Props) {
|
||||
<AlertDescription>
|
||||
Read our{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://openpanel.dev/docs"
|
||||
className="underline"
|
||||
href="https://openpanel.dev/docs"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
documentation
|
||||
</a>{' '}
|
||||
|
||||
@@ -7,8 +7,8 @@ export function ColorSquare({ children, className, color }: 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,
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 font-medium font-mono text-sm text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { formatDateTime, timeAgo } from '@/utils/date';
|
||||
export function ColumnCreatedAt({ children }: { children: Date | string }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 opacity-0 group-hover/row:opacity-100 transition-opacity duration-100">
|
||||
<div className="absolute inset-0 opacity-0 transition-opacity duration-100 group-hover/row:opacity-100">
|
||||
{formatDateTime(
|
||||
typeof children === 'string' ? new Date(children) : children,
|
||||
typeof children === 'string' ? new Date(children) : children
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground group-hover/row:opacity-0 transition-opacity duration-100">
|
||||
<div className="text-muted-foreground transition-opacity duration-100 group-hover/row:opacity-0">
|
||||
{timeAgo(typeof children === 'string' ? new Date(children) : children)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
const deltaChipVariants = cva(
|
||||
'flex items-center justify-center gap-1 rounded-full font-semibold',
|
||||
@@ -12,7 +12,7 @@ const deltaChipVariants = cva(
|
||||
default: 'bg-muted text-muted-foreground',
|
||||
},
|
||||
size: {
|
||||
xs: 'px-1.5 py-0 leading-none text-[10px]',
|
||||
xs: 'px-1.5 py-0 text-[10px] leading-none',
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-2 py-1 text-sm',
|
||||
lg: 'px-2 py-1 text-base',
|
||||
@@ -22,7 +22,7 @@ const deltaChipVariants = cva(
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
|
||||
@@ -53,13 +53,13 @@ export function DeltaChip({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
deltaChipVariants({ variant: getVariant(variant, inverted), size }),
|
||||
deltaChipVariants({ variant: getVariant(variant, inverted), size })
|
||||
)}
|
||||
>
|
||||
{variant === 'inc' ? (
|
||||
<ArrowUpIcon size={iconVariants[size || 'md']} className="shrink-0" />
|
||||
<ArrowUpIcon className="shrink-0" size={iconVariants[size || 'md']} />
|
||||
) : variant === 'dec' ? (
|
||||
<ArrowDownIcon size={iconVariants[size || 'md']} className="shrink-0" />
|
||||
<ArrowDownIcon className="shrink-0" size={iconVariants[size || 'md']} />
|
||||
) : null}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
|
||||
@@ -22,22 +22,22 @@ export function Dot({ className, size = 8, animated }: DotProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'relative',
|
||||
filterCn(['bg-', 'animate-', 'group-hover/row'], className),
|
||||
filterCn(['bg-', 'animate-', 'group-hover/row'], className)
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute !m-0 rounded-full',
|
||||
'!m-0 absolute rounded-full',
|
||||
animated !== false && 'animate-ping',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute !m-0 rounded-full',
|
||||
filterCn(['animate-', 'group-hover/row'], className),
|
||||
'!m-0 absolute rounded-full',
|
||||
filterCn(['animate-', 'group-hover/row'], className)
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { EventMeta } from '@openpanel/db';
|
||||
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';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
const variants = cva('flex shrink-0 items-center justify-center rounded-full', {
|
||||
variants: {
|
||||
@@ -237,8 +236,8 @@ export function EventIcon({ className, name, size, meta }: EventIconProps) {
|
||||
return (
|
||||
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
|
||||
<Icon
|
||||
size={size === 'xs' ? 12 : size === 'sm' ? 14 : 20}
|
||||
className={`text-${color}-700`}
|
||||
size={size === 'xs' ? 12 : size === 'sm' ? 14 : 20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { memo } from 'react';
|
||||
import { Skeleton } from '../../skeleton';
|
||||
import { EventIcon } from '../event-icon';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
@@ -5,10 +9,6 @@ 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>;
|
||||
@@ -37,6 +37,16 @@ export const EventItem = memo<EventItemProps>(
|
||||
return (
|
||||
<div className={cn('group card @container overflow-hidden', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'col flex-1 gap-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'
|
||||
)}
|
||||
data-slot="inner"
|
||||
onClick={() => {
|
||||
pushModal('EventDetails', {
|
||||
id: event.id,
|
||||
@@ -53,20 +63,9 @@ export const EventItem = memo<EventItemProps>(
|
||||
});
|
||||
}
|
||||
}}
|
||||
data-slot="inner"
|
||||
className={cn(
|
||||
'col gap-1 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-2">
|
||||
<div className="row min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -75,14 +74,15 @@ export const EventItem = memo<EventItemProps>(
|
||||
id: event.id,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<EventIcon name={event.name} size="sm" meta={event.meta} />
|
||||
<EventIcon meta={event.meta} name={event.name} size="sm" />
|
||||
</button>
|
||||
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all text-sm leading-normal">
|
||||
<span className="wrap-break-word min-w-0 whitespace-break-spaces break-all text-sm leading-normal">
|
||||
{event.name === 'screen_view' ? (
|
||||
<>
|
||||
<span className="text-muted-foreground mr-2">Visit:</span>
|
||||
<span className="font-medium min-w-0">
|
||||
<span className="mr-2 text-muted-foreground">Visit:</span>
|
||||
<span className="min-w-0 font-medium">
|
||||
{url ? url : event.path}
|
||||
</span>
|
||||
</>
|
||||
@@ -93,7 +93,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row gap-2 items-center @max-lg:pl-8">
|
||||
<div className="row items-center gap-2 @max-lg:pl-8">
|
||||
{event.referrerName && viewOptions.referrerName !== false && (
|
||||
<Pill
|
||||
icon={<SerieIcon className="mr-2" name={event.referrerName} />}
|
||||
@@ -123,7 +123,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
</Pill>
|
||||
)}
|
||||
{viewOptions.createdAt !== false && (
|
||||
<span className="text-sm text-neutral-500">
|
||||
<span className="text-neutral-500 text-sm">
|
||||
{formatTimeAgoOrDateTime(event.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
@@ -131,8 +131,8 @@ export const EventItem = memo<EventItemProps>(
|
||||
</div>
|
||||
{viewOptions.properties !== false && (
|
||||
<div
|
||||
className="border-neutral-200 border-t bg-def-100 p-4 py-2"
|
||||
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)}
|
||||
@@ -141,15 +141,15 @@ export const EventItem = memo<EventItemProps>(
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const EventItemSkeleton = () => {
|
||||
return (
|
||||
<div className="card h-10 p-2 gap-4 row items-center">
|
||||
<div className="card row h-10 items-center gap-4 p-2">
|
||||
<Skeleton className="size-6 rounded-full" />
|
||||
<Skeleton className="w-1/2 h-3" />
|
||||
<div className="row gap-2 ml-auto">
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<div className="row ml-auto gap-2">
|
||||
<Skeleton className="size-4 rounded-full" />
|
||||
<Skeleton className="size-4 rounded-full" />
|
||||
<Skeleton className="size-4 rounded-full" />
|
||||
@@ -163,17 +163,21 @@ function Pill({
|
||||
children,
|
||||
icon,
|
||||
className,
|
||||
}: { children: React.ReactNode; icon?: React.ReactNode; className?: string }) {
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltiper
|
||||
content={children}
|
||||
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,
|
||||
'inline-flex h-6 shrink-0 items-center gap-2 whitespace-nowrap rounded-full font-mono @3xl:text-muted-foreground text-xs',
|
||||
className
|
||||
)}
|
||||
content={children}
|
||||
>
|
||||
{icon && <div className="size-4 center-center">{icon}</div>}
|
||||
<div className="hidden @3xl:inline">{children}</div>
|
||||
{icon && <div className="center-center size-4">{icon}</div>}
|
||||
<div className="@3xl:inline hidden">{children}</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export const AvatarFallback = React.forwardRef<
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const { imageLoadingStatus } = useAvatarContext();
|
||||
const [canRender, setCanRender] = React.useState(delayMs === 0);
|
||||
@@ -108,10 +108,10 @@ export const AvatarFallback = React.forwardRef<
|
||||
if (children) {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={style}
|
||||
data-avatar-fallback=""
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -123,10 +123,10 @@ export const AvatarFallback = React.forwardRef<
|
||||
if (facehash) {
|
||||
return (
|
||||
<Facehash
|
||||
ref={ref as React.Ref<HTMLDivElement>}
|
||||
name={name || '?'}
|
||||
size="100%"
|
||||
groupHover={groupHover}
|
||||
name={name || '?'}
|
||||
ref={ref as React.Ref<HTMLDivElement>}
|
||||
size="100%"
|
||||
{...facehashProps}
|
||||
style={{
|
||||
...style,
|
||||
@@ -139,8 +139,9 @@ export const AvatarFallback = React.forwardRef<
|
||||
// Initials mode
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
data-avatar-fallback=""
|
||||
ref={ref}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -149,13 +150,12 @@ export const AvatarFallback = React.forwardRef<
|
||||
height: '100%',
|
||||
...style,
|
||||
}}
|
||||
data-avatar-fallback=""
|
||||
{...props}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
AvatarFallback.displayName = 'AvatarFallback';
|
||||
|
||||
@@ -25,7 +25,7 @@ export type AvatarImageProps = Omit<
|
||||
export const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
|
||||
(
|
||||
{ src, alt = '', className, style, onLoadingStatusChange, ...props },
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const { imageLoadingStatus, onImageLoadingStatusChange } =
|
||||
useAvatarContext();
|
||||
@@ -38,7 +38,7 @@ export const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
|
||||
onImageLoadingStatusChange(status);
|
||||
onLoadingStatusChange?.(status);
|
||||
},
|
||||
[onImageLoadingStatusChange, onLoadingStatusChange],
|
||||
[onImageLoadingStatusChange, onLoadingStatusChange]
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -74,21 +74,21 @@ export const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src || undefined}
|
||||
alt={alt}
|
||||
className={className}
|
||||
data-avatar-image=""
|
||||
ref={imageRef}
|
||||
src={src || undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
...style,
|
||||
}}
|
||||
data-avatar-image=""
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
AvatarImage.displayName = 'AvatarImage';
|
||||
|
||||
@@ -17,7 +17,7 @@ export const useAvatarContext = () => {
|
||||
const context = React.useContext(AvatarContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Avatar compound components must be rendered within an Avatar component',
|
||||
'Avatar compound components must be rendered within an Avatar component'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
@@ -44,7 +44,7 @@ export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
|
||||
imageLoadingStatus,
|
||||
onImageLoadingStatusChange: setImageLoadingStatus,
|
||||
}),
|
||||
[imageLoadingStatus],
|
||||
[imageLoadingStatus]
|
||||
);
|
||||
|
||||
const Element = asChild ? React.Fragment : 'span';
|
||||
@@ -72,7 +72,7 @@ export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
|
||||
<Element {...elementProps}>{children}</Element>
|
||||
</AvatarContext.Provider>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Avatar.displayName = 'Avatar';
|
||||
|
||||
@@ -184,15 +184,19 @@ const DEFAULT_GRADIENT_STYLE: React.CSSProperties = {
|
||||
function useColorScheme(colorScheme: ColorScheme): 'light' | 'dark' {
|
||||
const [systemScheme, setSystemScheme] = React.useState<'light' | 'dark'>(
|
||||
() => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
if (typeof window === 'undefined') {
|
||||
return 'light';
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (colorScheme !== 'auto') return;
|
||||
if (colorScheme !== 'auto') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
@@ -231,7 +235,7 @@ export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
|
||||
onMouseLeave,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const resolvedScheme = useColorScheme(colorScheme);
|
||||
@@ -242,9 +246,13 @@ export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
|
||||
// Determine which colors to use based on scheme
|
||||
const effectiveColors = React.useMemo(() => {
|
||||
// If explicit colors prop is provided, use it
|
||||
if (colors) return colors;
|
||||
if (colors) {
|
||||
return colors;
|
||||
}
|
||||
// If colorClasses is provided, don't use inline colors
|
||||
if (colorClasses) return undefined;
|
||||
if (colorClasses) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use scheme-specific colors or defaults
|
||||
const lightColors = colorsLight ?? DEFAULT_COLORS_LIGHT;
|
||||
@@ -313,7 +321,7 @@ export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
|
||||
}
|
||||
onMouseEnter?.(e);
|
||||
},
|
||||
[interactive, usesCssHover, onMouseEnter],
|
||||
[interactive, usesCssHover, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = React.useCallback(
|
||||
@@ -323,16 +331,18 @@ export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
|
||||
}
|
||||
onMouseLeave?.(e);
|
||||
},
|
||||
[interactive, usesCssHover, onMouseLeave],
|
||||
[interactive, usesCssHover, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`Avatar for ${name}`}
|
||||
className={`${bgColorClass ?? ''} ${className ?? ''}`}
|
||||
data-facehash-avatar=""
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={ref}
|
||||
role="img"
|
||||
aria-label={`Avatar for ${name}`}
|
||||
data-facehash-avatar=""
|
||||
className={`${bgColorClass ?? ''} ${className ?? ''}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
@@ -345,13 +355,12 @@ export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
|
||||
...(bgColorHex && { backgroundColor: bgColorHex }),
|
||||
...style,
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
{/* Gradient overlay */}
|
||||
{variant === 'gradient' && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={gradientOverlayClass}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -359,29 +368,30 @@ export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
|
||||
pointerEvents: 'none',
|
||||
...(gradientOverlayClass ? {} : DEFAULT_GRADIENT_STYLE),
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Face container with 3D transform */}
|
||||
<div
|
||||
data-facehash-avatar-face=""
|
||||
className={
|
||||
usesCssHover && interactive
|
||||
? 'group-hover:[transform:var(--facehash-hover-transform)]'
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform,
|
||||
transition: interactive ? 'transform 0.2s ease-out' : undefined,
|
||||
transformStyle: 'preserve-3d',
|
||||
'--facehash-hover-transform': hoverTransform,
|
||||
} as React.CSSProperties}
|
||||
data-facehash-avatar-face=""
|
||||
style={
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform,
|
||||
transition: interactive ? 'transform 0.2s ease-out' : undefined,
|
||||
transformStyle: 'preserve-3d',
|
||||
'--facehash-hover-transform': hoverTransform,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Face SVG */}
|
||||
<FaceComponent
|
||||
@@ -411,7 +421,7 @@ export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Facehash.displayName = 'Facehash';
|
||||
|
||||
@@ -9,10 +9,15 @@ export type FaceProps = {
|
||||
* Round eyes face - simple circular eyes
|
||||
*/
|
||||
export const RoundFace: React.FC<FaceProps> = ({ className, style }) => (
|
||||
<svg viewBox="0 0 100 100" className={className} style={style} aria-hidden="true">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={style}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<title>Round Eyes</title>
|
||||
<circle cx="35" cy="45" r="8" fill="currentColor" />
|
||||
<circle cx="65" cy="45" r="8" fill="currentColor" />
|
||||
<circle cx="35" cy="45" fill="currentColor" r="8" />
|
||||
<circle cx="65" cy="45" fill="currentColor" r="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -20,10 +25,25 @@ export const RoundFace: React.FC<FaceProps> = ({ className, style }) => (
|
||||
* Cross eyes face - X-shaped eyes
|
||||
*/
|
||||
export const CrossFace: React.FC<FaceProps> = ({ className, style }) => (
|
||||
<svg viewBox="0 0 100 100" className={className} style={style} aria-hidden="true">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={style}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<title>Cross Eyes</title>
|
||||
<path d="M27 37 L43 53 M43 37 L27 53" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
|
||||
<path d="M57 37 L73 53 M73 37 L57 53" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
|
||||
<path
|
||||
d="M27 37 L43 53 M43 37 L27 53"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
d="M57 37 L73 53 M73 37 L57 53"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -31,10 +51,31 @@ export const CrossFace: React.FC<FaceProps> = ({ className, style }) => (
|
||||
* Line eyes face - horizontal line eyes
|
||||
*/
|
||||
export const LineFace: React.FC<FaceProps> = ({ className, style }) => (
|
||||
<svg viewBox="0 0 100 100" className={className} style={style} aria-hidden="true">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={style}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<title>Line Eyes</title>
|
||||
<line x1="27" y1="45" x2="43" y2="45" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
|
||||
<line x1="57" y1="45" x2="73" y2="45" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
|
||||
<line
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
x1="27"
|
||||
x2="43"
|
||||
y1="45"
|
||||
y2="45"
|
||||
/>
|
||||
<line
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
x1="57"
|
||||
x2="73"
|
||||
y1="45"
|
||||
y2="45"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -42,10 +83,27 @@ export const LineFace: React.FC<FaceProps> = ({ className, style }) => (
|
||||
* Curved eyes face - sleepy/happy curved eyes
|
||||
*/
|
||||
export const CurvedFace: React.FC<FaceProps> = ({ className, style }) => (
|
||||
<svg viewBox="0 0 100 100" className={className} style={style} aria-hidden="true">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={style}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<title>Curved Eyes</title>
|
||||
<path d="M27 50 Q35 38 43 50" stroke="currentColor" strokeWidth="4" strokeLinecap="round" fill="none" />
|
||||
<path d="M57 50 Q65 38 73 50" stroke="currentColor" strokeWidth="4" strokeLinecap="round" fill="none" />
|
||||
<path
|
||||
d="M27 50 Q35 38 43 50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
d="M57 50 Q65 38 73 50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
// Primary Export - This is what you want
|
||||
// ============================================================================
|
||||
|
||||
export type { FacehashProps, Intensity3D, Variant, ColorScheme } from './facehash';
|
||||
export type {
|
||||
ColorScheme,
|
||||
FacehashProps,
|
||||
Intensity3D,
|
||||
Variant,
|
||||
} from './facehash';
|
||||
export {
|
||||
Facehash,
|
||||
DEFAULT_COLORS,
|
||||
DEFAULT_COLORS_LIGHT,
|
||||
DEFAULT_COLORS_DARK,
|
||||
DEFAULT_COLORS_LIGHT,
|
||||
Facehash,
|
||||
} from './facehash';
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { slug } from '@/utils/slug';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
@@ -17,7 +16,7 @@ type Props = {
|
||||
export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
|
||||
(
|
||||
{ label, description, Icon, children, onChange, value, disabled, error },
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const id = slug(label);
|
||||
return (
|
||||
@@ -25,30 +24,30 @@ export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-center gap-4 px-4 py-6 transition-colors hover:bg-def-200',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
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 className="text-muted-foreground">{description}</div>
|
||||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onCheckedChange={onChange}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
CheckboxItem.displayName = 'CheckboxItem';
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CopyIcon } from 'lucide-react';
|
||||
import { Label } from '../ui/label';
|
||||
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;
|
||||
@@ -13,12 +12,12 @@ type Props = {
|
||||
const CopyInput = ({ label, value, className }: Props) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn('w-full text-left', className)}
|
||||
onClick={() => clipboard(value)}
|
||||
type="button"
|
||||
>
|
||||
{!!label && <Label>{label}</Label>}
|
||||
<div className="font-mono flex items-center justify-between rounded bg-muted p-2 px-3 ">
|
||||
<div className="flex items-center justify-between rounded bg-muted p-2 px-3 font-mono">
|
||||
{value}
|
||||
<CopyIcon size={16} />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { BanIcon, InfoIcon } from 'lucide-react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { Input } from '../ui/input';
|
||||
import type { InputProps } from '../ui/input';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
|
||||
@@ -38,12 +37,12 @@ export const WithLabel = ({
|
||||
</Label>
|
||||
{error && (
|
||||
<Tooltiper
|
||||
align="end"
|
||||
asChild
|
||||
content={error}
|
||||
tooltipClassName="max-w-80 leading-normal"
|
||||
align="end"
|
||||
>
|
||||
<div className="flex items-center gap-1 leading-none text-destructive">
|
||||
<div className="flex items-center gap-1 text-destructive leading-none">
|
||||
Issues
|
||||
<BanIcon size={14} />
|
||||
</div>
|
||||
@@ -59,10 +58,10 @@ export const InputWithLabel = forwardRef<HTMLInputElement, InputWithLabelProps>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<WithLabel {...props}>
|
||||
<Input ref={ref} id={props.label} {...props} />
|
||||
<Input id={props.label} ref={ref} {...props} />
|
||||
</WithLabel>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
InputWithLabel.displayName = 'InputWithLabel';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { BoxSelectIcon } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { BoxSelectIcon } from 'lucide-react';
|
||||
import { PageHeader } from './page-header';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface FullPageEmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
@@ -22,7 +22,7 @@ export function FullPageEmptyState({
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center p-4 text-center',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-xl flex-col items-center justify-center p-8">
|
||||
@@ -30,7 +30,7 @@ export function FullPageEmptyState({
|
||||
<Icon size={60} strokeWidth={1} />
|
||||
</div>
|
||||
|
||||
<PageHeader title={title} description={description} className="mb-4" />
|
||||
<PageHeader className="mb-4" description={description} title={title} />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +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 }) => {
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<FullPageEmptyState
|
||||
className="min-h-[calc(100vh-theme(spacing.16))]"
|
||||
title={title}
|
||||
icon={ServerCrashIcon}
|
||||
title={title}
|
||||
>
|
||||
{description}
|
||||
{children && <div className="mt-4">{children}</div>}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
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 }) => {
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
return (
|
||||
<FullPageEmptyState
|
||||
className="min-h-[calc(100vh-theme(spacing.16))]"
|
||||
title={title}
|
||||
description={description}
|
||||
icon={
|
||||
((props) => (
|
||||
<Loader2Icon {...props} className="animate-spin" />
|
||||
)) as LucideIcon
|
||||
}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { LogoSquare } from './logo';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -9,8 +8,8 @@ type Props = {
|
||||
|
||||
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">
|
||||
<div className={cn('border-border border-b bg-card', className)}>
|
||||
<div className="mx-auto flex h-14 w-full max-w-screen-2xl items-center justify-between px-4 md:w-[95vw] lg:w-[80vw]">
|
||||
<LogoSquare className="size-8" />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -18,7 +17,7 @@ export const useFullscreen = () =>
|
||||
'fullscreen',
|
||||
parseAsBoolean.withDefault(false).withOptions({
|
||||
history: 'push',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
export const Fullscreen = (props: Props) => {
|
||||
@@ -28,7 +27,7 @@ export const Fullscreen = (props: Props) => {
|
||||
className={cn(
|
||||
isFullscreen
|
||||
? 'fixed inset-0 z-50 overflow-auto bg-def-200'
|
||||
: 'w-full min-h-full col',
|
||||
: 'col min-h-full w-full'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
@@ -42,13 +41,13 @@ export const FullscreenOpen = () => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltiper content="Toggle fullscreen" asChild>
|
||||
<Tooltiper asChild content="Toggle fullscreen">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsFullscreen((p) => !p);
|
||||
}}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<FullscreenIcon className="size-4" />
|
||||
</Button>
|
||||
@@ -88,18 +87,18 @@ export const FullscreenClose = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 top-0 z-50 flex items-center">
|
||||
<Tooltiper content="Exit full screen" asChild>
|
||||
<div className="fixed top-0 bottom-0 z-50 flex items-center">
|
||||
<Tooltiper asChild content="Exit full screen">
|
||||
<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',
|
||||
visible && isFullscreenDebounced && '-translate-x-10'
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsFullscreen(false);
|
||||
}}
|
||||
ref={ref}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeftIcon className="ml-6 text-background" />
|
||||
</button>
|
||||
|
||||
@@ -7,7 +7,7 @@ const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
export type Layout = ReactGridLayout.Layout;
|
||||
|
||||
export const useReportLayouts = (
|
||||
reports: NonNullable<IServiceReport>[],
|
||||
reports: NonNullable<IServiceReport>[]
|
||||
): ReactGridLayout.Layouts => {
|
||||
return useMemo(() => {
|
||||
const baseLayout = reports.map((report, index) => ({
|
||||
@@ -70,22 +70,22 @@ export function GrafanaGrid({
|
||||
`}</style>
|
||||
<div className="-m-4">
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={layouts}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
className="layout"
|
||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={100}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
margin={[16, 16]}
|
||||
transformScale={1}
|
||||
useCSSTransforms={true}
|
||||
onLayoutChange={onLayoutChange}
|
||||
onDragStop={onDragStop}
|
||||
onResizeStop={onResizeStop}
|
||||
draggableHandle=".drag-handle"
|
||||
isDraggable={isDraggable}
|
||||
isResizable={isResizable}
|
||||
layouts={layouts}
|
||||
margin={[16, 16]}
|
||||
onDragStop={onDragStop}
|
||||
onLayoutChange={onLayoutChange}
|
||||
onResizeStop={onResizeStop}
|
||||
preventCollision={false}
|
||||
rowHeight={100}
|
||||
transformScale={1}
|
||||
useCSSTransforms={true}
|
||||
>
|
||||
{children}
|
||||
</ResponsiveGridLayout>
|
||||
|
||||
@@ -62,11 +62,11 @@ export const GridCell: React.FC<
|
||||
'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,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="truncate w-full">{children}</div>
|
||||
<div className="w-full truncate">{children}</div>
|
||||
</Component>
|
||||
);
|
||||
|
||||
@@ -78,7 +78,7 @@ export const GridRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'contents transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -81,7 +81,9 @@ export function GroupMemberGrowth({ data }: Props) {
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={TrendingUpIcon}>New members last 30 days</WidgetTitle>
|
||||
<WidgetTitle icon={TrendingUpIcon}>
|
||||
New members last 30 days
|
||||
</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{data.length === 0 ? (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { IServiceGroup } from '@openpanel/db';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { IServiceGroup } from '@openpanel/db';
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
|
||||
export type IServiceGroupWithStats = IServiceGroup & {
|
||||
memberCount: number;
|
||||
@@ -42,9 +42,7 @@ export function useGroupColumns(): ColumnDef<IServiceGroupWithStats>[] {
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">{row.original.type}</Badge>
|
||||
),
|
||||
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'memberCount',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { IServiceGroup } from '@openpanel/db';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
@@ -26,7 +25,17 @@ interface Props {
|
||||
toolbarLeft?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceGroupWithStats[];
|
||||
const LOADING_DATA = [
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
] as IServiceGroupWithStats[];
|
||||
|
||||
export const GroupsTable = memo(
|
||||
({ query, pageSize = PAGE_SIZE, toolbarLeft }: Props) => {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { countries } from '@/translations/countries';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { InsightPayload } from '@openpanel/validation';
|
||||
import { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { FilterIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { DeltaChip } from '../delta-chip';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Badge } from '../ui/badge';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
function formatWindowKind(windowKind: string): string {
|
||||
switch (windowKind) {
|
||||
@@ -38,7 +35,7 @@ export function InsightCard({
|
||||
|
||||
// Pick what to display: prefer share if available (geo/devices), else primaryMetric
|
||||
const [metricIndex, setMetricIndex] = useState(
|
||||
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
|
||||
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric)
|
||||
);
|
||||
const currentMetricKey = availableMetrics[metricIndex][0];
|
||||
const currentMetricEntry = availableMetrics[metricIndex][1];
|
||||
@@ -58,8 +55,12 @@ export function InsightCard({
|
||||
|
||||
// Format metric values
|
||||
const formatValue = (value: number | null): string => {
|
||||
if (value == null) return '-';
|
||||
if (metricUnit === 'ratio') return `${(value * 100).toFixed(1)}%`;
|
||||
if (value == null) {
|
||||
return '-';
|
||||
}
|
||||
if (metricUnit === 'ratio') {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
return Math.round(value).toLocaleString();
|
||||
};
|
||||
|
||||
@@ -76,7 +77,7 @@ export function InsightCard({
|
||||
dimensions[0]?.key === 'device'
|
||||
) {
|
||||
return (
|
||||
<span className="capitalize flex items-center gap-2">
|
||||
<span className="flex items-center gap-2 capitalize">
|
||||
<SerieIcon name={dimensions[0]?.value} /> {insight.displayName}
|
||||
</span>
|
||||
);
|
||||
@@ -99,22 +100,22 @@ export function InsightCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'card p-4 h-full flex flex-col hover:bg-def-50 transition-colors group/card',
|
||||
className,
|
||||
'card group/card flex h-full flex-col p-4 transition-colors hover:bg-def-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'row justify-between h-4 items-center',
|
||||
onFilter && 'group-hover/card:hidden',
|
||||
'row h-4 items-center justify-between',
|
||||
onFilter && 'group-hover/card:hidden'
|
||||
)}
|
||||
>
|
||||
<Badge variant="outline" className="-ml-2">
|
||||
<Badge className="-ml-2" variant="outline">
|
||||
{formatWindowKind(insight.windowKind)}
|
||||
</Badge>
|
||||
{/* Severity: subtle dot instead of big pill */}
|
||||
{insight.severityBand && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
@@ -122,7 +123,7 @@ export function InsightCard({
|
||||
? 'bg-red-500'
|
||||
: insight.severityBand === 'moderate'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-500',
|
||||
: 'bg-blue-500'
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground capitalize">
|
||||
@@ -132,36 +133,34 @@ export function InsightCard({
|
||||
)}
|
||||
</div>
|
||||
{onFilter && (
|
||||
<div className="row group-hover/card:flex hidden h-4 justify-between gap-2">
|
||||
<div className="row hidden h-4 justify-between gap-2 group-hover/card:flex">
|
||||
{availableMetrics.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground capitalize"
|
||||
onClick={() =>
|
||||
setMetricIndex((metricIndex + 1) % availableMetrics.length)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcwIcon className="size-2" />
|
||||
Show{' '}
|
||||
{metricKeyToLabel(
|
||||
availableMetrics[
|
||||
(metricIndex + 1) % availableMetrics.length
|
||||
][0],
|
||||
availableMetrics[(metricIndex + 1) % availableMetrics.length][0]
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground capitalize"
|
||||
onClick={onFilter}
|
||||
type="button"
|
||||
>
|
||||
Filter <FilterIcon className="size-2" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="font-semibold text-sm leading-snug line-clamp-2 mt-2">
|
||||
<div className="mt-2 line-clamp-2 font-semibold text-sm leading-snug">
|
||||
{renderTitle()}
|
||||
</div>
|
||||
|
||||
@@ -169,18 +168,18 @@ export function InsightCard({
|
||||
<div className="mt-auto pt-2">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-muted-foreground mb-1">
|
||||
<div className="mb-1 text-[11px] text-muted-foreground">
|
||||
{metricLabel}
|
||||
</div>
|
||||
|
||||
<div className="col gap-1">
|
||||
<div className="text-2xl font-semibold tracking-tight">
|
||||
<div className="font-semibold text-2xl tracking-tight">
|
||||
{formatValue(currentValue)}
|
||||
</div>
|
||||
|
||||
{/* Inline compare, smaller */}
|
||||
{compareValue != null && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
vs {formatValue(compareValue)}
|
||||
</div>
|
||||
)}
|
||||
@@ -189,8 +188,8 @@ export function InsightCard({
|
||||
|
||||
{/* Delta chip */}
|
||||
<DeltaChip
|
||||
variant={isIncrease ? 'inc' : isDecrease ? 'dec' : 'default'}
|
||||
size="sm"
|
||||
variant={isIncrease ? 'inc' : isDecrease ? 'dec' : 'default'}
|
||||
>
|
||||
{deltaText}
|
||||
</DeltaChip>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
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';
|
||||
@@ -15,6 +11,9 @@ import {
|
||||
IntegrationCardSkeleton,
|
||||
} from './integration-card';
|
||||
import { INTEGRATIONS } from './integrations';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal, showConfirm } from '@/modals';
|
||||
|
||||
export function ActiveIntegrations() {
|
||||
const { organizationId } = useAppParams();
|
||||
@@ -22,7 +21,7 @@ export function ActiveIntegrations() {
|
||||
const query = useQuery(
|
||||
trpc.integration.list.queryOptions({
|
||||
organizationId: organizationId!,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const client = useQueryClient();
|
||||
const deletion = useMutation(
|
||||
@@ -31,17 +30,17 @@ export function ActiveIntegrations() {
|
||||
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,
|
||||
(integration) => integration.type === item.config.type
|
||||
)!;
|
||||
return {
|
||||
...item,
|
||||
@@ -54,7 +53,7 @@ export function ActiveIntegrations() {
|
||||
const isLoading = query.isLoading;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 auto-rows-auto">
|
||||
<div className="grid auto-rows-auto grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{isLoading && (
|
||||
<>
|
||||
<IntegrationCardSkeleton />
|
||||
@@ -64,13 +63,13 @@ export function ActiveIntegrations() {
|
||||
)}
|
||||
{!isLoading && data.length === 0 && (
|
||||
<IntegrationCard
|
||||
description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section."
|
||||
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">
|
||||
@@ -78,11 +77,10 @@ export function ActiveIntegrations() {
|
||||
return (
|
||||
<motion.div key={item.id} layout="position">
|
||||
<IntegrationCard {...item.integration} name={item.name}>
|
||||
<IntegrationCardFooter className="row justify-between items-center">
|
||||
<IntegrationCardFooter className="row items-center justify-between">
|
||||
<PingBadge>Connected</PingBadge>
|
||||
<div className="row gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
showConfirm({
|
||||
@@ -95,17 +93,18 @@ export function ActiveIntegrations() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
pushModal('AddIntegration', {
|
||||
id: item.id,
|
||||
type: item.config.type,
|
||||
});
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
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';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
|
||||
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}
|
||||
icon={integration.icon}
|
||||
key={integration.name}
|
||||
name={integration.name}
|
||||
>
|
||||
<IntegrationCardFooter className="row justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
pushModal('AddIntegration', {
|
||||
type: integration.type,
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<PlugIcon className="size-4 mr-2" />
|
||||
<PlugIcon className="mr-2 size-4" />
|
||||
Connect
|
||||
</Button>
|
||||
</IntegrationCardFooter>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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 { mergeDeepRight, path } from 'ramda';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
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>;
|
||||
|
||||
@@ -33,7 +33,7 @@ export function DiscordIntegrationForm({
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
defaultValues ?? {},
|
||||
defaultValues ?? {}
|
||||
),
|
||||
resolver: zodResolver(zCreateDiscordIntegration),
|
||||
});
|
||||
@@ -44,7 +44,7 @@ export function DiscordIntegrationForm({
|
||||
onError() {
|
||||
toast.error('Failed to create integration');
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const handleSubmit = (values: IForm) => {
|
||||
@@ -70,8 +70,8 @@ export function DiscordIntegrationForm({
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit, handleError)}
|
||||
className="col gap-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit, handleError)}
|
||||
>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
@@ -85,10 +85,10 @@ export function DiscordIntegrationForm({
|
||||
error={path(['config', 'url', 'message'], form.formState.errors)}
|
||||
/>
|
||||
<div className="row gap-4">
|
||||
<Button type="button" variant="outline" onClick={handleTest}>
|
||||
<Button onClick={handleTest} type="button" variant="outline">
|
||||
Test connection
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1">
|
||||
<Button className="flex-1" type="submit">
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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';
|
||||
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';
|
||||
|
||||
type IForm = z.infer<typeof zCreateSlackIntegration>;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function SlackIntegrationForm({
|
||||
onError() {
|
||||
toast.error('Failed to create integration');
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const handleSubmit = (values: IForm) => {
|
||||
@@ -52,8 +52,8 @@ export function SlackIntegrationForm({
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit, handleError)}
|
||||
className="col gap-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit, handleError)}
|
||||
>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { zCreateWebhookIntegration } from '@openpanel/validation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { PlusIcon, TrashIcon } from 'lucide-react';
|
||||
import { mergeDeepRight, path } from 'ramda';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
||||
import { JsonEditor } from '@/components/json-editor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -6,16 +15,6 @@ import { Input } from '@/components/ui/input';
|
||||
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 { PlusIcon, TrashIcon } from 'lucide-react';
|
||||
import { path, mergeDeepRight } from 'ramda';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
|
||||
type IForm = z.infer<typeof zCreateWebhookIntegration>;
|
||||
|
||||
@@ -25,7 +24,7 @@ const DEFAULT_TRANSFORMER = `(payload) => {
|
||||
|
||||
// Convert Record<string, string> to array format for form
|
||||
function headersToArray(
|
||||
headers: Record<string, string> | undefined,
|
||||
headers: Record<string, string> | undefined
|
||||
): { key: string; value: string }[] {
|
||||
if (!headers || Object.keys(headers).length === 0) {
|
||||
return [];
|
||||
@@ -35,7 +34,7 @@ function headersToArray(
|
||||
|
||||
// Convert array format back to Record<string, string> for API
|
||||
function headersToRecord(
|
||||
headers: { key: string; value: string }[],
|
||||
headers: { key: string; value: string }[]
|
||||
): Record<string, string> {
|
||||
return headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
@@ -44,7 +43,7 @@ function headersToRecord(
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +75,7 @@ export function WebhookIntegrationForm({
|
||||
javascriptTemplate: undefined,
|
||||
},
|
||||
},
|
||||
defaultValues ?? {},
|
||||
defaultValues ?? {}
|
||||
),
|
||||
resolver: zodResolver(zCreateWebhookIntegration),
|
||||
});
|
||||
@@ -106,7 +105,7 @@ export function WebhookIntegrationForm({
|
||||
(h): h is { key: string; value: string } =>
|
||||
h !== undefined &&
|
||||
typeof h.key === 'string' &&
|
||||
typeof h.value === 'string',
|
||||
typeof h.value === 'string'
|
||||
);
|
||||
form.setValue('config.headers', headersToRecord(validHeaders), {
|
||||
shouldValidate: false,
|
||||
@@ -135,7 +134,7 @@ export function WebhookIntegrationForm({
|
||||
toast.error('Failed to create integration');
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const handleSubmit = (values: IForm) => {
|
||||
@@ -148,8 +147,8 @@ export function WebhookIntegrationForm({
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit, handleError)}
|
||||
className="col gap-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit, handleError)}
|
||||
>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
@@ -164,12 +163,12 @@ export function WebhookIntegrationForm({
|
||||
/>
|
||||
|
||||
<WithLabel
|
||||
label="Headers"
|
||||
info="Add custom HTTP headers to include with webhook requests"
|
||||
label="Headers"
|
||||
>
|
||||
<div className="col gap-2">
|
||||
{headersArray.fields.map((field, index) => (
|
||||
<div key={field.id} className="row gap-2">
|
||||
<div className="row gap-2" key={field.id}>
|
||||
<Input
|
||||
placeholder="Header Name"
|
||||
{...headersForm.register(`headers.${index}.key`)}
|
||||
@@ -181,22 +180,22 @@ export function WebhookIntegrationForm({
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
className="text-destructive"
|
||||
onClick={() => headersArray.remove(index)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => headersArray.remove(index)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => headersArray.append({ key: '', value: '' })}
|
||||
className="self-start"
|
||||
icon={PlusIcon}
|
||||
onClick={() => headersArray.append({ key: '', value: '' })}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Add Header
|
||||
</Button>
|
||||
@@ -208,13 +207,12 @@ export function WebhookIntegrationForm({
|
||||
name="config.mode"
|
||||
render={({ field }) => (
|
||||
<WithLabel
|
||||
label="Payload Format"
|
||||
info="Choose how to format the webhook payload"
|
||||
label="Payload Format"
|
||||
>
|
||||
<Combobox
|
||||
{...field}
|
||||
className="w-full"
|
||||
placeholder="Select format"
|
||||
items={[
|
||||
{
|
||||
label: 'Message',
|
||||
@@ -225,8 +223,9 @@ export function WebhookIntegrationForm({
|
||||
value: 'javascript' as const,
|
||||
},
|
||||
]}
|
||||
value={field.value ?? 'message'}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select format"
|
||||
value={field.value ?? 'message'}
|
||||
/>
|
||||
</WithLabel>
|
||||
)}
|
||||
@@ -238,7 +237,6 @@ export function WebhookIntegrationForm({
|
||||
name="config.javascriptTemplate"
|
||||
render={({ field }) => (
|
||||
<WithLabel
|
||||
label="JavaScript Transform"
|
||||
info={
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p>
|
||||
@@ -246,7 +244,7 @@ export function WebhookIntegrationForm({
|
||||
payload. The function receives <code>payload</code> as a
|
||||
parameter and should return an object.
|
||||
</p>
|
||||
<p className="text-sm font-semibold mt-2">
|
||||
<p className="mt-2 font-semibold text-sm">
|
||||
Available in payload:
|
||||
</p>
|
||||
<ul className="text-sm">
|
||||
@@ -267,7 +265,7 @@ export function WebhookIntegrationForm({
|
||||
<code>payload.profile.firstName</code> - Profile property
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex gap-x-2 flex-wrap mt-1">
|
||||
<div className="mt-1 flex flex-wrap gap-x-2">
|
||||
<code>country</code>
|
||||
<code>city</code>
|
||||
<code>device</code>
|
||||
@@ -279,7 +277,7 @@ export function WebhookIntegrationForm({
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-sm font-semibold mt-2">
|
||||
<p className="mt-2 font-semibold text-sm">
|
||||
Available helpers:
|
||||
</p>
|
||||
<ul className="text-sm">
|
||||
@@ -289,10 +287,10 @@ export function WebhookIntegrationForm({
|
||||
<code>Object</code>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-sm mt-2">
|
||||
<p className="mt-2 text-sm">
|
||||
<strong>Example:</strong>
|
||||
</p>
|
||||
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">
|
||||
<pre className="mt-1 overflow-x-auto rounded bg-muted p-2 text-xs">
|
||||
{`(payload) => ({
|
||||
event: payload.name,
|
||||
user: payload.profileId,
|
||||
@@ -301,15 +299,17 @@ export function WebhookIntegrationForm({
|
||||
location: \`\${payload.city}, \${payload.country}\`
|
||||
})`}
|
||||
</pre>
|
||||
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-400">
|
||||
<p className="mt-2 text-sm text-yellow-600 dark:text-yellow-400">
|
||||
<strong>Security:</strong> Network calls, file system
|
||||
access, and other dangerous operations are blocked.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
label="JavaScript Transform"
|
||||
>
|
||||
<JsonEditor
|
||||
value={field.value ?? DEFAULT_TRANSFORMER}
|
||||
language="javascript"
|
||||
minHeight="300px"
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
// Clear error when user starts typing
|
||||
@@ -318,11 +318,10 @@ export function WebhookIntegrationForm({
|
||||
}
|
||||
}}
|
||||
placeholder={DEFAULT_TRANSFORMER}
|
||||
minHeight="300px"
|
||||
language="javascript"
|
||||
value={field.value ?? DEFAULT_TRANSFORMER}
|
||||
/>
|
||||
{form.formState.errors.config?.javascriptTemplate && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
<p className="mt-1 text-destructive text-sm">
|
||||
{form.formState.errors.config.javascriptTemplate.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function IntegrationCardFooter({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('row p-4 border-t rounded-b', className)}>
|
||||
<div className={cn('row rounded-b border-t p-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -22,7 +22,7 @@ export function IntegrationCardHeader({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('relative row p-4 border-b rounded-t', className)}>
|
||||
<div className={cn('row relative rounded-t border-b p-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -38,8 +38,8 @@ export function IntegrationCardHeaderButtons({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-4 top-0 bottom-0 row items-center gap-2',
|
||||
className,
|
||||
'row absolute top-0 right-4 bottom-0 items-center gap-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -63,7 +63,7 @@ export function IntegrationCardLogoImage({
|
||||
backgroundColor,
|
||||
}}
|
||||
>
|
||||
<img src={src} alt="Integration Logo" />
|
||||
<img alt="Integration Logo" src={src} />
|
||||
</IntegrationCardLogo>
|
||||
);
|
||||
}
|
||||
@@ -78,8 +78,8 @@ export function IntegrationCardLogo({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'size-14 rounded overflow-hidden shrink-0 center-center',
|
||||
className,
|
||||
'center-center size-14 shrink-0 overflow-hidden rounded',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -102,9 +102,9 @@ export function IntegrationCard({
|
||||
return (
|
||||
<div className="card self-start">
|
||||
<IntegrationCardContent
|
||||
description={description}
|
||||
icon={icon}
|
||||
name={name}
|
||||
description={description}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
@@ -135,9 +135,9 @@ 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="size-14 shrink-0 rounded" />
|
||||
<div className="col flex-grow gap-1">
|
||||
<Skeleton className="mb-2 h-5 w-1/2" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,8 @@ export const INTEGRATIONS: {
|
||||
'Connect your Slack workspace to get notified when new issues are created.',
|
||||
icon: (
|
||||
<IntegrationCardLogoImage
|
||||
src="https://play-lh.googleusercontent.com/mzJpTCsTW_FuR6YqOPaLHrSEVCSJuXzCljdxnCKhVZMcu6EESZBQTCHxMh8slVtnKqo"
|
||||
backgroundColor="#481449"
|
||||
src="https://play-lh.googleusercontent.com/mzJpTCsTW_FuR6YqOPaLHrSEVCSJuXzCljdxnCKhVZMcu6EESZBQTCHxMh8slVtnKqo"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -30,8 +30,8 @@ export const INTEGRATIONS: {
|
||||
'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"
|
||||
src="https://static.vecteezy.com/system/resources/previews/006/892/625/non_2x/discord-logo-icon-editorial-free-vector.jpg"
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { basicSetup } from 'codemirror';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import {
|
||||
Compartment,
|
||||
EditorState,
|
||||
type Extension,
|
||||
} from '@codemirror/state';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { basicSetup } from 'codemirror';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from './theme-provider';
|
||||
|
||||
@@ -57,8 +53,7 @@ export function JsonEditor({
|
||||
onValidate?.(true);
|
||||
} catch (e) {
|
||||
setIsValid(false);
|
||||
const errorMsg =
|
||||
e instanceof Error ? e.message : 'Invalid JSON syntax';
|
||||
const errorMsg = e instanceof Error ? e.message : 'Invalid JSON syntax';
|
||||
setError(errorMsg);
|
||||
onValidate?.(false, errorMsg);
|
||||
}
|
||||
@@ -72,7 +67,9 @@ export function JsonEditor({
|
||||
|
||||
// Create editor once on mount
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || viewRef.current) return;
|
||||
if (!editorRef.current || viewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const themeCompartment = new Compartment();
|
||||
themeCompartmentRef.current = themeCompartment;
|
||||
@@ -82,7 +79,9 @@ export function JsonEditor({
|
||||
|
||||
const extensions: Extension[] = [
|
||||
basicSetup,
|
||||
languageCompartment.of(language === 'javascript' ? [javascript()] : [json()]),
|
||||
languageCompartment.of(
|
||||
language === 'javascript' ? [javascript()] : [json()]
|
||||
),
|
||||
EditorState.tabSize.of(2),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
@@ -162,22 +161,26 @@ export function JsonEditor({
|
||||
|
||||
// Update theme using compartment
|
||||
useEffect(() => {
|
||||
if (!viewRef.current || !themeCompartmentRef.current) return;
|
||||
if (!(viewRef.current && themeCompartmentRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewRef.current.dispatch({
|
||||
effects: themeCompartmentRef.current.reconfigure(
|
||||
appTheme === 'dark' ? [oneDark] : [],
|
||||
appTheme === 'dark' ? [oneDark] : []
|
||||
),
|
||||
});
|
||||
}, [appTheme]);
|
||||
|
||||
// Update language using compartment
|
||||
useEffect(() => {
|
||||
if (!viewRef.current || !languageCompartmentRef.current) return;
|
||||
if (!(viewRef.current && languageCompartmentRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewRef.current.dispatch({
|
||||
effects: languageCompartmentRef.current.reconfigure(
|
||||
language === 'javascript' ? [javascript()] : [json()],
|
||||
language === 'javascript' ? [javascript()] : [json()]
|
||||
),
|
||||
});
|
||||
validateContent(value);
|
||||
@@ -185,7 +188,9 @@ export function JsonEditor({
|
||||
|
||||
// Update editor content when value changes externally
|
||||
useEffect(() => {
|
||||
if (!viewRef.current || isUpdatingRef.current) return;
|
||||
if (!viewRef.current || isUpdatingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = viewRef.current.state.doc.toString();
|
||||
if (currentContent !== value) {
|
||||
@@ -205,12 +210,13 @@ export function JsonEditor({
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
className={`rounded-md ${isValid ? '' : 'ring-1 ring-destructive'}`}
|
||||
ref={editorRef}
|
||||
className={`rounded-md ${!isValid ? 'ring-1 ring-destructive' : ''}`}
|
||||
/>
|
||||
{!isValid && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
{error || `Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`}
|
||||
<p className="mt-1 text-destructive text-sm">
|
||||
{error ||
|
||||
`Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ export const LazyComponent = ({
|
||||
viewportOptions,
|
||||
{
|
||||
disconnectOnLeave,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,7 +72,7 @@ export const LazyComponent = ({
|
||||
const shouldRender = lazy ? once.current || inViewport : true;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
<div className={className} ref={ref}>
|
||||
{shouldRender ? children : (fallback ?? <div />)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { Link, type LinkComponentProps } from '@tanstack/react-router';
|
||||
import { omit } from 'ramda';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
|
||||
export function ProjectLink({
|
||||
children,
|
||||
@@ -15,9 +15,6 @@ export function ProjectLink({
|
||||
if (typeof props.href === 'string') {
|
||||
return (
|
||||
<Link
|
||||
to={
|
||||
`/$organizationId/$projectId/${props.href.replace(/^\//, '')}` as any
|
||||
}
|
||||
activeOptions={{ exact: props.exact ?? true }}
|
||||
params={
|
||||
{
|
||||
@@ -25,6 +22,9 @@ export function ProjectLink({
|
||||
projectId,
|
||||
} as any
|
||||
}
|
||||
to={
|
||||
`/$organizationId/$projectId/${props.href.replace(/^\//, '')}` as any
|
||||
}
|
||||
{...omit(['href'], props)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SellingPoint } from './selling-points';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
@@ -5,7 +6,6 @@ import {
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/components/ui/carousel';
|
||||
import { SellingPoint } from './selling-points';
|
||||
|
||||
const sellingPoints = [
|
||||
{
|
||||
@@ -13,8 +13,8 @@ const sellingPoints = [
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-1.webp"
|
||||
title="Best open-source alternative"
|
||||
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring"
|
||||
title="Best open-source alternative"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -23,8 +23,8 @@ const sellingPoints = [
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-2.webp"
|
||||
title="Fast and reliable"
|
||||
description="Never miss a beat with our real-time analytics"
|
||||
title="Fast and reliable"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -33,8 +33,8 @@ const sellingPoints = [
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-3.webp"
|
||||
title="Easy to use"
|
||||
description="Compared to other tools we have kept it simple"
|
||||
title="Easy to use"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -43,8 +43,8 @@ const sellingPoints = [
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-4.webp"
|
||||
title="Privacy by default"
|
||||
description="We have built our platform with privacy at its heart"
|
||||
title="Privacy by default"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -53,8 +53,8 @@ const sellingPoints = [
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-5.webp"
|
||||
title="Open source"
|
||||
description="You can inspect the code and self-host if you choose"
|
||||
title="Open source"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -64,9 +64,9 @@ export function LoginLeftPanel() {
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden">
|
||||
{/* Carousel */}
|
||||
<div className="flex items-center justify-center h-full mt-24">
|
||||
<div className="mt-24 flex h-full items-center justify-center">
|
||||
<Carousel
|
||||
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
|
||||
className="h-full w-full [&>div]:h-full [&>div]:min-h-full"
|
||||
opts={{
|
||||
loop: true,
|
||||
align: 'center',
|
||||
@@ -75,17 +75,17 @@ export function LoginLeftPanel() {
|
||||
<CarouselContent className="h-full">
|
||||
{sellingPoints.map((point, index) => (
|
||||
<CarouselItem
|
||||
className="p-8 pt-0 pb-32"
|
||||
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">
|
||||
<div className="h-full min-h-full overflow-hidden rounded-xl border border-border bg-card shadow-lg">
|
||||
{point.render()}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="left-12 bottom-30 top-auto" />
|
||||
<CarouselNext className="right-12 bottom-30 top-auto" />
|
||||
<CarouselPrevious className="top-auto bottom-30 left-12" />
|
||||
<CarouselNext className="top-auto right-12 bottom-30" />
|
||||
</Carousel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,9 @@ interface LogoProps {
|
||||
export function LogoSquare({ className }: LogoProps) {
|
||||
return (
|
||||
<img
|
||||
src="/logo.svg"
|
||||
className={cn('rounded-md', className)}
|
||||
alt="Openpanel logo"
|
||||
className={cn('rounded-md', className)}
|
||||
src="/logo.svg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function LogoSquare({ className }: LogoProps) {
|
||||
export function Logo({ className }: LogoProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center gap-2 text-xl font-medium', className)}
|
||||
className={cn('flex items-center gap-2 font-medium text-xl', className)}
|
||||
>
|
||||
<LogoSquare className="max-h-8" />
|
||||
<span>openpanel.dev</span>
|
||||
|
||||
@@ -12,15 +12,15 @@ export const Markdown = memo<Options>(
|
||||
(props) => (
|
||||
<ReactMarkdown
|
||||
{...props}
|
||||
remarkPlugins={[remarkParse, remarkHighlight, remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex, remarkRehype]}
|
||||
remarkPlugins={[remarkParse, remarkHighlight, remarkMath, remarkGfm]}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
'className' in prevProps &&
|
||||
'className' in nextProps &&
|
||||
prevProps.className === nextProps.className,
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
|
||||
Markdown.displayName = 'Markdown';
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { EventListItem } from '@/components/events/event-list-item';
|
||||
import type { IServiceEventMinimal } from '@openpanel/db';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { IServiceEventMinimal } from '@openpanel/db';
|
||||
import { EventListItem } from '@/components/events/event-list-item';
|
||||
|
||||
const useWebEventGenerator = () => {
|
||||
const [events, setEvents] = useState<IServiceEventMinimal[]>([]);
|
||||
@@ -116,20 +115,20 @@ export const MockEventList = () => {
|
||||
|
||||
return (
|
||||
<div className="hide-scrollbar h-screen overflow-y-auto">
|
||||
<div className="text-background-foreground py-16 text-center text-2xl font-bold">
|
||||
<div className="py-16 text-center font-bold text-2xl text-background-foreground">
|
||||
Real time data
|
||||
<br />
|
||||
at your fingertips
|
||||
</div>
|
||||
<AnimatePresence mode="popLayout" initial>
|
||||
<AnimatePresence initial mode="popLayout">
|
||||
<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 }}
|
||||
initial={{ opacity: 0, x: -400, scale: 0.5 }}
|
||||
key={event.id}
|
||||
layout
|
||||
transition={{ duration: 0.6, type: 'spring' }}
|
||||
>
|
||||
<EventListItem {...event} minimal />
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
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';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
|
||||
export function NotificationProvider() {
|
||||
const { projectId } = useAppParams();
|
||||
|
||||
if (!projectId) return null;
|
||||
if (!projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <InnerNotificationProvider projectId={projectId} />;
|
||||
}
|
||||
|
||||
export function InnerNotificationProvider({
|
||||
projectId,
|
||||
}: { projectId: string }) {
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
useWS<Notification>(`/live/notifications/${projectId}`, (notification) => {
|
||||
toast(notification.title, {
|
||||
description: notification.message,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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';
|
||||
@@ -9,6 +6,9 @@ import { FullPageEmptyState } from '../full-page-empty-state';
|
||||
import { IntegrationCardSkeleton } from '../integrations/integration-card';
|
||||
import { Button } from '../ui/button';
|
||||
import { RuleCard } from './rule-card';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
|
||||
export function NotificationRules() {
|
||||
const { projectId } = useAppParams();
|
||||
@@ -16,7 +16,7 @@ export function NotificationRules() {
|
||||
const query = useQuery(
|
||||
trpc.notification.rules.queryOptions({
|
||||
projectId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const data = useMemo(() => {
|
||||
return query.data || [];
|
||||
@@ -26,19 +26,19 @@ export function NotificationRules() {
|
||||
|
||||
if (!isLoading && data.length === 0) {
|
||||
return (
|
||||
<FullPageEmptyState title="No rules yet" icon={PencilRulerIcon}>
|
||||
<FullPageEmptyState icon={PencilRulerIcon} title="No rules yet">
|
||||
<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,
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Add Rule
|
||||
</Button>
|
||||
@@ -51,17 +51,17 @@ export function NotificationRules() {
|
||||
<div className="mb-2">
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
pushModal('AddNotificationRule', {
|
||||
rule: undefined,
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col gap-4 w-full grid md:grid-cols-2">
|
||||
<div className="col grid w-full gap-4 md:grid-cols-2">
|
||||
{isLoading && (
|
||||
<>
|
||||
<IntegrationCardSkeleton />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NotificationsTable } from './table';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
export function Notifications() {
|
||||
const { projectId } = useAppParams();
|
||||
@@ -9,7 +9,7 @@ export function Notifications() {
|
||||
const query = useQuery(
|
||||
trpc.notification.list.queryOptions({
|
||||
projectId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
return <NotificationsTable query={query} />;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
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';
|
||||
@@ -15,13 +11,17 @@ import { PingBadge } from '../ping';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal, showConfirm } from '@/modals';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
|
||||
function EventBadge({
|
||||
event,
|
||||
}: { event: NotificationRule['config']['events'][number] }) {
|
||||
}: {
|
||||
event: NotificationRule['config']['events'][number];
|
||||
}) {
|
||||
return (
|
||||
<Tooltiper
|
||||
disabled={!event.filters.length}
|
||||
content={
|
||||
<div className="col gap-2 font-mono">
|
||||
{event.filters.map((filter) => (
|
||||
@@ -31,11 +31,12 @@ function EventBadge({
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
disabled={!event.filters.length}
|
||||
>
|
||||
<Badge variant="outline" className="inline-flex">
|
||||
<Badge className="inline-flex" variant="outline">
|
||||
{event.name === '*' ? 'Any event' : event.name}
|
||||
{Boolean(event.filters.length) && (
|
||||
<FilterIcon className="size-2 ml-1" />
|
||||
<FilterIcon className="ml-1 size-2" />
|
||||
)}
|
||||
</Badge>
|
||||
</Tooltiper>
|
||||
@@ -44,7 +45,9 @@ function EventBadge({
|
||||
|
||||
export function RuleCard({
|
||||
rule,
|
||||
}: { rule: RouterOutputs['notification']['rules'][number] }) {
|
||||
}: {
|
||||
rule: RouterOutputs['notification']['rules'][number];
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const client = useQueryClient();
|
||||
const deletion = useMutation(
|
||||
@@ -54,19 +57,19 @@ export function RuleCard({
|
||||
client.refetchQueries(
|
||||
trpc.notification.rules.queryOptions({
|
||||
projectId: rule.projectId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
const renderConfig = () => {
|
||||
switch (rule.config.type) {
|
||||
case 'events':
|
||||
return (
|
||||
<div className="row gap-2 items-baseline flex-wrap">
|
||||
<div className="row flex-wrap items-baseline gap-2">
|
||||
<div>Get notified when</div>
|
||||
{rule.config.events.map((event) => (
|
||||
<EventBadge key={event.id} event={event} />
|
||||
<EventBadge event={event} key={event.id} />
|
||||
))}
|
||||
<div>occurs</div>
|
||||
</div>
|
||||
@@ -78,11 +81,11 @@ export function RuleCard({
|
||||
<div className="col gap-2">
|
||||
{rule.config.events.map((event, index) => (
|
||||
<div
|
||||
className="row items-center gap-2 font-mono"
|
||||
key={event.id}
|
||||
className="row gap-2 items-center font-mono"
|
||||
>
|
||||
<ColorSquare>{index + 1}</ColorSquare>
|
||||
<EventBadge key={event.id} event={event} />
|
||||
<EventBadge event={event} key={event.id} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -95,16 +98,15 @@ export function RuleCard({
|
||||
<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">
|
||||
<div className="col gap-2 p-4">{renderConfig()}</div>
|
||||
<IntegrationCardFooter className="row items-center justify-between gap-2">
|
||||
<div className="row flex-wrap gap-2">
|
||||
{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({
|
||||
@@ -117,16 +119,17 @@ export function RuleCard({
|
||||
},
|
||||
});
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
pushModal('AddNotificationRule', {
|
||||
rule,
|
||||
});
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { formatDateTime, formatTime } from '@/utils/date';
|
||||
import type { INotificationPayload } from '@openpanel/db';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { isToday } from 'date-fns';
|
||||
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
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') {
|
||||
@@ -27,7 +24,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { title } = row.original;
|
||||
return (
|
||||
<div className="row gap-2 items-center">
|
||||
<div className="row items-center gap-2">
|
||||
{/* {isReadAt === null && <PingBadge>Unread</PingBadge>} */}
|
||||
<span className="max-w-md truncate font-medium">{title}</span>
|
||||
</div>
|
||||
@@ -149,8 +146,8 @@ export function useColumns() {
|
||||
}
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${encodeURIComponent(event.profileId)}`}
|
||||
className="inline-flex min-w-full flex-none items-center gap-2"
|
||||
href={`/profiles/${encodeURIComponent(event.profileId)}`}
|
||||
>
|
||||
{event.profileId}
|
||||
</ProjectLink>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
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<
|
||||
@@ -27,7 +26,7 @@ export const NotificationsTable = ({ query }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} loading={isLoading} />
|
||||
<DataTable loading={isLoading} table={table} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
import { QuoteIcon } from 'lucide-react';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel';
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
import { QuoteIcon } from 'lucide-react';
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
@@ -53,19 +53,19 @@ function TestimonialSlide({
|
||||
site?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex flex-col justify-end h-full p-10 select-none">
|
||||
<div className="relative flex h-full select-none flex-col justify-end p-10">
|
||||
<img
|
||||
src={bgImage}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
src={bgImage}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-black/10" />
|
||||
<div className="relative z-10 flex flex-col gap-4">
|
||||
<QuoteIcon className="size-10 text-white/40 stroke-1" />
|
||||
<blockquote className="text-3xl font-medium text-white leading-relaxed">
|
||||
<QuoteIcon className="size-10 stroke-1 text-white/40" />
|
||||
<blockquote className="font-medium text-3xl text-white leading-relaxed">
|
||||
{quote}
|
||||
</blockquote>
|
||||
<figcaption className="text-white/60 text-sm">
|
||||
<figcaption className="text-sm text-white/60">
|
||||
— {author}
|
||||
{site && <span className="ml-1 text-white/40">· {site}</span>}
|
||||
</figcaption>
|
||||
@@ -77,20 +77,20 @@ function TestimonialSlide({
|
||||
export function OnboardingLeftPanel() {
|
||||
return (
|
||||
<div className="sticky top-0 h-screen overflow-hidden">
|
||||
<div className="flex items-center justify-center h-full mt-24">
|
||||
<div className="mt-24 flex h-full items-center justify-center">
|
||||
<Carousel
|
||||
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
|
||||
className="h-full w-full [&>div]:h-full [&>div]:min-h-full"
|
||||
opts={{ loop: true, align: 'center' }}
|
||||
plugins={[Autoplay({ delay: 6000, stopOnInteraction: false })]}
|
||||
>
|
||||
<CarouselContent className="h-full">
|
||||
{testimonials.map((t) => (
|
||||
<CarouselItem key={t.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">
|
||||
<CarouselItem className="p-8 pt-0 pb-32" key={t.key}>
|
||||
<div className="h-full min-h-full overflow-hidden rounded-xl border border-border bg-card shadow-lg">
|
||||
<TestimonialSlide
|
||||
author={t.author}
|
||||
bgImage={t.bgImage}
|
||||
quote={t.quote}
|
||||
author={t.author}
|
||||
site={t.site}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate } from '@tanstack/react-router';
|
||||
import { ChevronLastIcon, LogInIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
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']];
|
||||
|
||||
@@ -16,12 +15,12 @@ export const SkipOnboarding = () => {
|
||||
const pathname = location.pathname;
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
const isPublic = PUBLIC_SEGMENTS.some((segment) =>
|
||||
segments.every((s, index) => s === segment[index]),
|
||||
segments.every((s, index) => s === segment[index])
|
||||
);
|
||||
const res = useQuery(
|
||||
trpc.onboarding.skipOnboardingCheck.queryOptions(undefined, {
|
||||
enabled: !isPublic,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const logout = useLogout();
|
||||
@@ -33,8 +32,8 @@ export const SkipOnboarding = () => {
|
||||
if (isPublic) {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
to="/login"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
Login
|
||||
<LogInIcon size={16} />
|
||||
@@ -48,7 +47,7 @@ export const SkipOnboarding = () => {
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
onClick={() => {
|
||||
if (res.data?.canSkip) {
|
||||
navigate({ to: '/' });
|
||||
@@ -62,7 +61,7 @@ export const SkipOnboarding = () => {
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
type="button"
|
||||
>
|
||||
Skip onboarding
|
||||
<ChevronLastIcon size={16} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import { CheckCheckIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Step = {
|
||||
name: string;
|
||||
@@ -36,9 +36,9 @@ function useSteps(path: string) {
|
||||
},
|
||||
];
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
const matchIndex = steps.findLastIndex((step) =>
|
||||
path.match(new RegExp(step.match)),
|
||||
path.match(new RegExp(step.match))
|
||||
);
|
||||
|
||||
return steps.map((step, index) => {
|
||||
@@ -59,9 +59,9 @@ export const OnboardingSteps = ({ className }: Props) => {
|
||||
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 top-4 bottom-4 left-4 w-px bg-def-200" />
|
||||
<div
|
||||
className="absolute left-4 top-4 w-px bg-highlight"
|
||||
className="absolute top-4 left-4 w-px bg-highlight"
|
||||
style={{
|
||||
height: `calc(${((currentIndex + 1) / steps.length) * 100}% - 3.5rem)`,
|
||||
}}
|
||||
@@ -69,7 +69,7 @@ export const OnboardingSteps = ({ className }: Props) => {
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex gap-4 overflow-hidden md:-ml-3 md:flex-col md:gap-8',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
@@ -80,19 +80,19 @@ export const OnboardingSteps = ({ className }: Props) => {
|
||||
'rounded-xl border border-border bg-card',
|
||||
step.status === 'completed' &&
|
||||
index !== currentIndex - 1 &&
|
||||
'max-md:hidden',
|
||||
'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',
|
||||
'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 === 'pending' && 'bg-def-400'
|
||||
)}
|
||||
/>
|
||||
{step.status === 'current' && (
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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 type { IServiceOrganization } from '@openpanel/db';
|
||||
import { zEditOrganization } from '@openpanel/validation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import { zEditOrganization } from '@openpanel/validation';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { handleError } from '@/trpc/client';
|
||||
|
||||
const validator = zEditOrganization;
|
||||
|
||||
@@ -44,7 +43,7 @@ export default function EditOrganization({
|
||||
queryClient.invalidateQueries(trpc.organization.get.pathFilter());
|
||||
},
|
||||
onError: handleError,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -57,7 +56,7 @@ export default function EditOrganization({
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Details</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="gap-4 col">
|
||||
<WidgetBody className="col gap-4">
|
||||
<InputWithLabel
|
||||
className="flex-1"
|
||||
label="Name"
|
||||
@@ -65,28 +64,28 @@ export default function EditOrganization({
|
||||
defaultValue={organization?.name}
|
||||
/>
|
||||
<Controller
|
||||
name="timezone"
|
||||
control={control}
|
||||
name="timezone"
|
||||
render={({ field }) => (
|
||||
<WithLabel label="Timezone">
|
||||
<Combobox
|
||||
placeholder="Select timezone"
|
||||
className="w-full"
|
||||
items={Intl.supportedValuesOf('timeZone').map((item) => ({
|
||||
value: item,
|
||||
label: item,
|
||||
}))}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
className="w-full"
|
||||
placeholder="Select timezone"
|
||||
value={field.value}
|
||||
/>
|
||||
</WithLabel>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className="self-end"
|
||||
disabled={!formState.isDirty}
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={!formState.isDirty}
|
||||
className="self-end"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ interface OrganizationProps {
|
||||
}
|
||||
export default function Organization({ organization }: OrganizationProps) {
|
||||
return (
|
||||
<section className="max-w-screen-sm col gap-8">
|
||||
<section className="col max-w-screen-sm gap-8">
|
||||
<EditOrganization organization={organization} />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface PromptCardProps {
|
||||
title: string;
|
||||
@@ -23,38 +23,38 @@ export function PromptCard({
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
className="fixed right-0 bottom-0 z-50 max-w-sm p-4"
|
||||
exit={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
|
||||
>
|
||||
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_var(--color-background)] col gap-6 py-6 overflow-hidden">
|
||||
<div className="relative px-6 col gap-1">
|
||||
<div className="col gap-6 overflow-hidden rounded-lg border bg-card py-6 shadow-[0_0_100px_50px_var(--color-background)]">
|
||||
<div className="col relative gap-1 px-6">
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"
|
||||
className="pointer-events-none absolute -right-10 -bottom-10 h-64 w-64 rounded-full opacity-30 blur-3xl"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${gradientColor} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="row items-center justify-between">
|
||||
<h2 className="text-xl font-semibold max-w-[200px] leading-snug">
|
||||
<h2 className="max-w-[200px] font-semibold text-xl leading-snug">
|
||||
{title}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||
<p className="text-muted-foreground text-sm">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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';
|
||||
|
||||
export function OriginFilter() {
|
||||
const { projectId } = useAppParams();
|
||||
@@ -11,10 +11,7 @@ export function OriginFilter() {
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { data } = useQuery(
|
||||
trpc.event.origin.queryOptions(
|
||||
{ projectId },
|
||||
{ staleTime: 1000 * 60 * 60 },
|
||||
),
|
||||
trpc.event.origin.queryOptions({ projectId }, { staleTime: 1000 * 60 * 60 })
|
||||
);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
@@ -27,15 +24,15 @@ export function OriginFilter() {
|
||||
const active = originFilter?.value.includes(item.origin);
|
||||
return (
|
||||
<button
|
||||
key={item.origin}
|
||||
type="button"
|
||||
onClick={() => setFilter('origin', [item.origin], 'is')}
|
||||
className={cn(
|
||||
'rounded-md border px-2.5 py-1 text-sm transition-colors cursor-pointer truncate max-w-56',
|
||||
'max-w-56 cursor-pointer truncate rounded-md border px-2.5 py-1 text-sm transition-colors',
|
||||
active
|
||||
? 'bg-foreground text-background border-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:border-foreground/30',
|
||||
? 'border-foreground bg-foreground font-medium text-background'
|
||||
: 'text-muted-foreground hover:border-foreground/30 hover:text-foreground'
|
||||
)}
|
||||
key={item.origin}
|
||||
onClick={() => setFilter('origin', [item.origin], 'is')}
|
||||
type="button"
|
||||
>
|
||||
{item.origin}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { operators } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEventFilter,
|
||||
IChartEventFilterOperator,
|
||||
} from '@openpanel/validation';
|
||||
import { FilterIcon, X } from 'lucide-react';
|
||||
import type { Options as NuqsOptions } from 'nuqs';
|
||||
import { FilterOperatorSelect } from '@/components/report/sidebar/filters/FilterOperatorSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { FilterOperatorSelect } from '@/components/report/sidebar/filters/FilterOperatorSelect';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
@@ -11,13 +18,6 @@ import { pushModal } from '@/modals';
|
||||
import type { OverviewFiltersProps } from '@/modals/overview-filters';
|
||||
import { getPropertyLabel } from '@/translations/properties';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { operators } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEventFilter,
|
||||
IChartEventFilterOperator,
|
||||
} from '@openpanel/validation';
|
||||
import { FilterIcon, X } from 'lucide-react';
|
||||
import type { Options as NuqsOptions } from 'nuqs';
|
||||
|
||||
interface OverviewFiltersButtonsProps {
|
||||
className?: string;
|
||||
@@ -27,14 +27,14 @@ interface OverviewFiltersButtonsProps {
|
||||
export function OverviewFilterButton(props: OverviewFiltersProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
responsive
|
||||
icon={FilterIcon}
|
||||
onClick={() =>
|
||||
pushModal('OverviewFilters', {
|
||||
...props,
|
||||
})
|
||||
}
|
||||
responsive
|
||||
variant="outline"
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
@@ -67,21 +67,21 @@ function FilterPill({
|
||||
filter.operator === 'isNull' || filter.operator === 'isNotNull';
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch text-sm border rounded-md overflow-hidden h-8">
|
||||
<div className="flex h-8 items-stretch overflow-hidden rounded-md border text-sm">
|
||||
{/* Key — opens modal to change the property */}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer px-2 transition-colors hover:bg-accent"
|
||||
onClick={() => pushModal('OverviewFilters', { nuqsOptions })}
|
||||
className="px-2 hover:bg-accent transition-colors cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
{getPropertyLabel(filter.name)}
|
||||
</button>
|
||||
|
||||
{/* Operator dropdown */}
|
||||
<FilterOperatorSelect value={filter.operator} onChange={onChangeOperator}>
|
||||
<FilterOperatorSelect onChange={onChangeOperator} value={filter.operator}>
|
||||
<button
|
||||
className="cursor-pointer border-l px-2 lowercase opacity-50 transition-colors hover:bg-accent hover:opacity-100"
|
||||
type="button"
|
||||
className="px-2 opacity-50 lowercase hover:opacity-100 hover:bg-accent transition-colors border-l cursor-pointer"
|
||||
>
|
||||
{operators[filter.operator]}
|
||||
</button>
|
||||
@@ -91,17 +91,17 @@ function FilterPill({
|
||||
{!noValueNeeded && (
|
||||
<ComboboxAdvanced
|
||||
items={potentialValues.map((v) => ({ value: v, label: v }))}
|
||||
value={filter.value}
|
||||
onChange={onChangeValue}
|
||||
value={filter.value}
|
||||
>
|
||||
<button
|
||||
className="max-w-40 cursor-pointer truncate border-l px-2 font-semibold transition-colors hover:bg-accent"
|
||||
type="button"
|
||||
className="px-2 font-semibold hover:bg-accent transition-colors border-l cursor-pointer max-w-40 truncate"
|
||||
>
|
||||
{filter.value.length > 0 ? (
|
||||
filter.value.join(', ')
|
||||
) : (
|
||||
<span className="opacity-40 font-normal italic">pick value</span>
|
||||
<span className="font-normal italic opacity-40">pick value</span>
|
||||
)}
|
||||
</button>
|
||||
</ComboboxAdvanced>
|
||||
@@ -109,10 +109,10 @@ function FilterPill({
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="px-2 hover:bg-destructive hover:text-destructive-foreground transition-colors border-l cursor-pointer"
|
||||
aria-label="Remove filter"
|
||||
className="cursor-pointer border-l px-2 transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
@@ -128,33 +128,35 @@ export function OverviewFiltersButtons({
|
||||
const [filters, setFilter, _setFilters, removeFilter] =
|
||||
useEventQueryFilters(nuqsOptions);
|
||||
|
||||
if (filters.length === 0 && events.length === 0) return null;
|
||||
if (filters.length === 0 && events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
{events.map((event) => (
|
||||
<Button
|
||||
icon={X}
|
||||
key={event}
|
||||
onClick={() => setEvents((p) => p.filter((e) => e !== 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) => (
|
||||
<FilterPill
|
||||
key={filter.name}
|
||||
filter={filter}
|
||||
key={filter.name}
|
||||
nuqsOptions={nuqsOptions}
|
||||
onRemove={() => removeFilter(filter.name)}
|
||||
onChangeOperator={(operator) =>
|
||||
setFilter(filter.name, filter.value, operator)
|
||||
}
|
||||
onChangeValue={(value) =>
|
||||
setFilter(filter.name, value, filter.operator)
|
||||
}
|
||||
onRemove={() => removeFilter(filter.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
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 {
|
||||
@@ -12,11 +10,11 @@ interface Props {
|
||||
export function OverviewChartToggle({ chartType, setChartType }: Props) {
|
||||
return (
|
||||
<Button
|
||||
size={'icon'}
|
||||
variant={'ghost'}
|
||||
onClick={() => {
|
||||
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
|
||||
}}
|
||||
size={'icon'}
|
||||
variant={'ghost'}
|
||||
>
|
||||
{chartType === 'bar' ? (
|
||||
<LineChartIcon size={16} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ScanEyeIcon } from 'lucide-react';
|
||||
|
||||
import { Button, type ButtonProps } from '../ui/button';
|
||||
|
||||
type Props = Omit<ButtonProps, 'children'>;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { InsightCard } from '../insights/insight-card';
|
||||
import { Skeleton } from '../skeleton';
|
||||
@@ -10,6 +8,8 @@ import {
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '../ui/carousel';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface OverviewInsightsProps {
|
||||
projectId: string;
|
||||
@@ -22,19 +22,19 @@ export default function OverviewInsights({ projectId }: OverviewInsightsProps) {
|
||||
trpc.insight.list.queryOptions({
|
||||
projectId,
|
||||
limit: 20,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
const keys = Array.from({ length: 4 }, (_, i) => `insight-skeleton-${i}`);
|
||||
return (
|
||||
<div className="col-span-6">
|
||||
<Carousel opts={{ align: 'start' }} className="w-full">
|
||||
<Carousel className="w-full" opts={{ align: 'start' }}>
|
||||
<CarouselContent className="-ml-4">
|
||||
{keys.map((key) => (
|
||||
<CarouselItem
|
||||
className="basis-full pl-4 sm:basis-1/2 lg:basis-1/4"
|
||||
key={key}
|
||||
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
|
||||
>
|
||||
<Skeleton className="h-36 w-full" />
|
||||
</CarouselItem>
|
||||
@@ -45,16 +45,18 @@ export default function OverviewInsights({ projectId }: OverviewInsightsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!insights || insights.length === 0) return null;
|
||||
if (!insights || insights.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-6 -mx-4">
|
||||
<Carousel opts={{ align: 'start' }} className="w-full group">
|
||||
<Carousel className="group w-full" opts={{ align: 'start' }}>
|
||||
<CarouselContent className="mr-4">
|
||||
{insights.map((insight) => (
|
||||
<CarouselItem
|
||||
className="basis-full pl-4 sm:basis-1/2 lg:basis-1/4"
|
||||
key={insight.id}
|
||||
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
|
||||
>
|
||||
<InsightCard
|
||||
insight={insight}
|
||||
@@ -67,8 +69,8 @@ export default function OverviewInsights({ projectId }: OverviewInsightsProps) {
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
|
||||
<CarouselNext className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
|
||||
<CarouselPrevious className="!opacity-0 group-hover:!opacity-100 pointer-events-none transition-opacity group-hover:pointer-events-auto group-focus:pointer-events-auto group-focus:opacity-100" />
|
||||
<CarouselNext className="!opacity-0 group-hover:!opacity-100 pointer-events-none transition-opacity group-hover:pointer-events-auto group-focus:pointer-events-auto group-focus:opacity-100" />
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import {
|
||||
isHourIntervalEnabledByRange,
|
||||
isMinuteIntervalEnabledByRange,
|
||||
} from '@openpanel/constants';
|
||||
import { ClockIcon } from 'lucide-react';
|
||||
import { ReportInterval } from '../report/ReportInterval';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
|
||||
export function OverviewInterval() {
|
||||
const { interval, setInterval, range, startDate, endDate } =
|
||||
@@ -13,12 +7,12 @@ export function OverviewInterval() {
|
||||
|
||||
return (
|
||||
<ReportInterval
|
||||
chartType="linear"
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
onChange={setInterval}
|
||||
range={range}
|
||||
chartType="linear"
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import React from 'react';
|
||||
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
|
||||
type Data = {
|
||||
date: string;
|
||||
@@ -63,7 +61,7 @@ export const OverviewLineChartTooltip = createChartTooltip<Data, Context>(
|
||||
(item) =>
|
||||
item.payload &&
|
||||
typeof item.payload === 'object' &&
|
||||
'name' in item.payload,
|
||||
'name' in item.payload
|
||||
);
|
||||
|
||||
// Sort by sessions (descending)
|
||||
@@ -122,18 +120,18 @@ export const OverviewLineChartTooltip = createChartTooltip<Data, Context>(
|
||||
</div>
|
||||
<div className="col gap-1 text-sm">
|
||||
{revenue !== undefined && revenue > 0 && (
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span className="text-muted-foreground">Revenue</span>
|
||||
<span style={{ color: '#3ba974' }}>
|
||||
{number.currency(revenue / 100, { short: true })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span className="text-muted-foreground">Pageviews</span>
|
||||
<span>{number.short(pageviews)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span className="text-muted-foreground">Sessions</span>
|
||||
<span>{number.short(sessions)}</span>
|
||||
</div>
|
||||
@@ -149,5 +147,5 @@ export const OverviewLineChartTooltip = createChartTooltip<Data, Context>(
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
@@ -11,12 +9,13 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { OverviewLineChartTooltip } from './overview-line-chart-tooltip';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
type SeriesData =
|
||||
RouterOutputs['overview']['topGenericSeries']['items'][number];
|
||||
@@ -30,7 +29,7 @@ interface OverviewLineChartProps {
|
||||
|
||||
function transformDataForRecharts(
|
||||
items: SeriesData[],
|
||||
searchQuery?: string,
|
||||
searchQuery?: string
|
||||
): Array<{
|
||||
date: string;
|
||||
timestamp: number;
|
||||
@@ -108,7 +107,7 @@ export function OverviewLineChart({
|
||||
|
||||
const chartData = useMemo(
|
||||
() => transformDataForRecharts(data.items, searchQuery),
|
||||
[data.items, searchQuery],
|
||||
[data.items, searchQuery]
|
||||
);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
@@ -130,7 +129,7 @@ export function OverviewLineChart({
|
||||
if (visibleItems.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center h-[358px]', className)}
|
||||
className={cn('flex h-[358px] items-center justify-center', className)}
|
||||
>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{searchQuery ? 'No results found' : 'No data available'}
|
||||
@@ -146,10 +145,10 @@ export function OverviewLineChart({
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
horizontal={true}
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
<YAxis {...yAxisProps} />
|
||||
@@ -162,13 +161,13 @@ export function OverviewLineChart({
|
||||
: item.name;
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={`${key}:sessions`}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
key={key}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -183,31 +182,31 @@ export function OverviewLineChart({
|
||||
);
|
||||
}
|
||||
|
||||
function LegendScrollable({
|
||||
items,
|
||||
}: {
|
||||
items: SeriesData[];
|
||||
}) {
|
||||
function LegendScrollable({ items }: { items: SeriesData[] }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
|
||||
const updateGradients = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = el;
|
||||
const hasOverflow = scrollWidth > clientWidth;
|
||||
|
||||
setShowLeftGradient(hasOverflow && scrollLeft > 0);
|
||||
setShowRightGradient(
|
||||
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
|
||||
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateGradients();
|
||||
|
||||
@@ -230,15 +229,15 @@ function LegendScrollable({
|
||||
{/* Left gradient */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
|
||||
showLeftGradient ? 'opacity-100' : 'opacity-0',
|
||||
'pointer-events-none absolute top-0 left-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
|
||||
showLeftGradient ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Scrollable legend */}
|
||||
<div
|
||||
className="hide-scrollbar flex gap-x-4 gap-y-1 overflow-x-auto px-2 py-1 text-xs"
|
||||
ref={scrollRef}
|
||||
className="flex gap-x-4 gap-y-1 overflow-x-auto px-2 py-1 hide-scrollbar text-xs"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const color = getChartColor(index);
|
||||
@@ -249,7 +248,7 @@ function LegendScrollable({
|
||||
style={{ color }}
|
||||
>
|
||||
<SerieIcon name={item.prefix || item.name} />
|
||||
<span className="font-semibold whitespace-nowrap">
|
||||
<span className="whitespace-nowrap font-semibold">
|
||||
{item.prefix && (
|
||||
<>
|
||||
<span className="text-muted-foreground">{item.prefix}</span>
|
||||
@@ -266,8 +265,8 @@ function LegendScrollable({
|
||||
{/* Right gradient */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
|
||||
showRightGradient ? 'opacity-100' : 'opacity-0',
|
||||
'pointer-events-none absolute top-0 right-0 z-10 h-full w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
|
||||
showRightGradient ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -281,21 +280,17 @@ export function OverviewLineChartLoading({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center h-[358px]', className)}
|
||||
className={cn('flex h-[358px] items-center justify-center', className)}
|
||||
>
|
||||
<div className="text-muted-foreground text-sm">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewLineChartEmpty({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
export function OverviewLineChartEmpty({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center h-[358px]', className)}
|
||||
className={cn('flex h-[358px] items-center justify-center', className)}
|
||||
>
|
||||
<div className="text-muted-foreground text-sm">No data available</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { ModalContent } from '@/modals/Modal/Container';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import type React from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { ModalContent } from '@/modals/Modal/Container';
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
|
||||
@@ -19,28 +18,28 @@ function RevenuePieChart({ percentage }: { percentage: number }) {
|
||||
const offset = circumference - percentage * circumference;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="flex-shrink-0">
|
||||
<svg className="flex-shrink-0" height={size} width={size}>
|
||||
<circle
|
||||
className="text-def-200"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-def-200"
|
||||
/>
|
||||
<circle
|
||||
className="transition-all"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
r={radius}
|
||||
stroke="#3ba974"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidth}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -108,11 +107,11 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
|
||||
useMemo(() => {
|
||||
const maxSessions = Math.max(
|
||||
...filteredData.map((item) => item.sessions),
|
||||
...filteredData.map((item) => item.sessions)
|
||||
);
|
||||
const totalRevenue = filteredData.reduce(
|
||||
(sum, item) => sum + (item.revenue ?? 0),
|
||||
0,
|
||||
0
|
||||
);
|
||||
const hasRevenue = filteredData.some((item) => (item.revenue ?? 0) > 0);
|
||||
const hasPageviews =
|
||||
@@ -131,21 +130,21 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0 sm:max-w-2xl">
|
||||
<ModalContent className="!max-h-[90vh] flex flex-col gap-0 p-0 sm:max-w-2xl">
|
||||
{/* Sticky Header */}
|
||||
<div className="flex-shrink-0 border-b border-border">
|
||||
<div className="flex-shrink-0 border-border border-b">
|
||||
<div className="p-6 pb-4">
|
||||
<DialogTitle className="text-lg font-semibold mb-4">
|
||||
<DialogTitle className="mb-4 font-semibold text-lg">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
{headerContent}
|
||||
@@ -153,13 +152,13 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
|
||||
{/* Column Headers */}
|
||||
<div
|
||||
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
|
||||
className="grid bg-def-100 px-4 py-2 font-medium text-muted-foreground text-sm"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||
}}
|
||||
>
|
||||
<div className="text-left truncate">{columnName}</div>
|
||||
<div className="truncate text-left">{columnName}</div>
|
||||
{hasRevenue && <div className="text-right">Revenue</div>}
|
||||
{hasPageviews && <div className="text-right">Views</div>}
|
||||
{showSessions && <div className="text-right">Sessions</div>}
|
||||
@@ -168,8 +167,8 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
|
||||
{/* Virtualized Scrollable Body */}
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto"
|
||||
style={{ maxHeight: '60vh' }}
|
||||
>
|
||||
<div
|
||||
@@ -181,7 +180,9 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = filteredData[virtualRow.index];
|
||||
if (!item) return null;
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = item.sessions / maxSessions;
|
||||
const revenuePercentage =
|
||||
@@ -189,8 +190,8 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/row absolute top-0 left-0 w-full"
|
||||
key={keyExtractor(item)}
|
||||
className="absolute top-0 left-0 w-full group/row"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
@@ -199,14 +200,14 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
{/* Background bar */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors"
|
||||
className="h-full bg-def-200 transition-colors group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900"
|
||||
style={{ width: `${percentage * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row content */}
|
||||
<div
|
||||
className="relative grid h-full items-center px-4 border-b border-border"
|
||||
className="relative grid h-full items-center border-border border-b px-4"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||
@@ -221,7 +222,7 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
{hasRevenue && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span
|
||||
className="font-semibold font-mono text-sm"
|
||||
className="font-mono font-semibold text-sm"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{(item.revenue ?? 0) > 0
|
||||
@@ -236,14 +237,14 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
|
||||
{/* Pageviews cell */}
|
||||
{hasPageviews && (
|
||||
<div className="text-right font-semibold font-mono text-sm">
|
||||
<div className="text-right font-mono font-semibold text-sm">
|
||||
{number.short(item.pageviews)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions cell */}
|
||||
{showSessions && (
|
||||
<div className="text-right font-semibold font-mono text-sm">
|
||||
<div className="text-right font-mono font-semibold text-sm">
|
||||
{number.short(item.sessions)}
|
||||
</div>
|
||||
)}
|
||||
@@ -255,7 +256,7 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredData.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||
{searchQuery ? 'No results found' : 'No data available'}
|
||||
</div>
|
||||
)}
|
||||
@@ -263,7 +264,7 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
|
||||
{/* Fixed Footer */}
|
||||
{footer && (
|
||||
<div className="flex-shrink-0 border-t border-border p-4">{footer}</div>
|
||||
<div className="flex-shrink-0 border-border border-t p-4">{footer}</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -16,6 +12,10 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface OverviewLiveHistogramProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
@@ -29,7 +29,7 @@ export function OverviewLiveHistogram({
|
||||
|
||||
// Use the new liveData endpoint instead of chart props
|
||||
const { data: liveData, isLoading } = useQuery(
|
||||
trpc.overview.liveData.queryOptions({ projectId, shareId }),
|
||||
trpc.overview.liveData.queryOptions({ projectId, shareId })
|
||||
);
|
||||
|
||||
const totalSessions = liveData?.totalSessions ?? 0;
|
||||
@@ -38,7 +38,7 @@ export function OverviewLiveHistogram({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper count={0}>
|
||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
||||
<div className="h-full w-full animate-pulse rounded bg-def-200" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -54,11 +54,11 @@ export function OverviewLiveHistogram({
|
||||
<Wrapper
|
||||
count={totalSessions}
|
||||
icons={
|
||||
<div className="row gap-2 shrink-0">
|
||||
<div className="row shrink-0 gap-2">
|
||||
{liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
<div
|
||||
className="row items-center gap-1 font-bold text-xs"
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="font-bold text-xs row gap-1 items-center"
|
||||
>
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span>{ref.count}</span>
|
||||
@@ -68,7 +68,7 @@ export function OverviewLiveHistogram({
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
@@ -79,11 +79,11 @@ export function OverviewLiveHistogram({
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<XAxis axisLine={false} dataKey="time" hide tickLine={false} />
|
||||
<YAxis domain={[0, maxDomain]} hide />
|
||||
<Bar
|
||||
dataKey="sessionCount"
|
||||
className="fill-chart-0"
|
||||
dataKey="sessionCount"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
@@ -102,8 +102,8 @@ interface WrapperProps {
|
||||
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="row gap-2 justify-between items-center">
|
||||
<div className="relative text-xs font-medium text-muted-foreground">
|
||||
<div className="row items-center justify-between gap-2">
|
||||
<div className="relative font-medium text-muted-foreground text-xs">
|
||||
{count} sessions last 30 min
|
||||
</div>
|
||||
{icons}
|
||||
@@ -119,10 +119,10 @@ function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const number = useNumber();
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
const inactive = !active || !payload?.length;
|
||||
const inactive = !(active && payload?.length);
|
||||
useEffect(() => {
|
||||
const setPositionThrottled = throttle(setPosition, 50);
|
||||
const unsubMouseMove = bind(window, {
|
||||
@@ -150,7 +150,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!active || !payload || !payload.length) {
|
||||
if (!(active && payload && payload.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
|
||||
return (
|
||||
<Portal.Portal
|
||||
className="rounded-md border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position?.y,
|
||||
@@ -180,7 +181,6 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
zIndex: 1000,
|
||||
width: tooltipWidth,
|
||||
}}
|
||||
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.time}</div>
|
||||
@@ -193,7 +193,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Sessions</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.sessionCount)}
|
||||
</div>
|
||||
@@ -201,18 +201,18 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
</div>
|
||||
</div>
|
||||
{data.referrers && data.referrers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
|
||||
<div className="mt-2 border-border border-t pt-2">
|
||||
<div className="mb-2 text-muted-foreground text-xs">Referrers:</div>
|
||||
<div className="space-y-1">
|
||||
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="row items-center justify-between text-xs"
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
>
|
||||
<div className="row items-center gap-1">
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span
|
||||
className="truncate max-w-[120px]"
|
||||
className="max-w-[120px] truncate"
|
||||
title={ref.referrer}
|
||||
>
|
||||
{ref.referrer}
|
||||
@@ -222,7 +222,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
</div>
|
||||
))}
|
||||
{data.referrers.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
+{data.referrers.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import WorldMap from 'react-svg-worldmap';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface OverviewMapProps {
|
||||
projectId: string;
|
||||
@@ -24,11 +24,13 @@ export function OverviewMap({ projectId, shareId }: OverviewMapProps) {
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const mapData = useMemo(() => {
|
||||
if (!query.data) return [];
|
||||
if (!query.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Aggregate by country (sum counts for same country)
|
||||
const countryMap = new Map<string, number>();
|
||||
@@ -46,7 +48,7 @@ export function OverviewMap({ projectId, shareId }: OverviewMapProps) {
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading map...</div>
|
||||
</div>
|
||||
);
|
||||
@@ -54,7 +56,7 @@ export function OverviewMap({ projectId, shareId }: OverviewMapProps) {
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-muted-foreground">Error loading map</div>
|
||||
</div>
|
||||
);
|
||||
@@ -62,7 +64,7 @@ export function OverviewMap({ projectId, shareId }: OverviewMapProps) {
|
||||
|
||||
if (!query.data || mapData.length === 0) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-muted-foreground">No data available</div>
|
||||
</div>
|
||||
);
|
||||
@@ -73,15 +75,15 @@ export function OverviewMap({ projectId, shareId }: OverviewMapProps) {
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<WorldMap
|
||||
borderColor={'var(--foreground)'}
|
||||
color={'var(--chart-0)'}
|
||||
data={mapData}
|
||||
onClickFunction={(event) => {
|
||||
if (event.countryCode) {
|
||||
setFilter('country', event.countryCode);
|
||||
}
|
||||
}}
|
||||
size={width}
|
||||
data={mapData}
|
||||
color={'var(--chart-0)'}
|
||||
borderColor={'var(--foreground)'}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
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';
|
||||
@@ -33,6 +23,15 @@ import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { OverviewLiveHistogram } from './overview-live-histogram';
|
||||
import { OverviewMetricCard } from './overview-metric-card';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
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 { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface OverviewMetricsProps {
|
||||
projectId: string;
|
||||
@@ -103,7 +102,7 @@ export default function OverviewMetrics({
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const data =
|
||||
@@ -114,33 +113,33 @@ export default function OverviewMetrics({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative -top-0.5 col-span-6 mb-0 mt-0 md:m-0">
|
||||
<div className="relative -top-0.5 col-span-6 mt-0 mb-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}
|
||||
active={metric === index}
|
||||
data={data.map((item) => ({
|
||||
date: item.date,
|
||||
current: item[title.key],
|
||||
previous: item[`prev_${title.key}`],
|
||||
}))}
|
||||
active={metric === index}
|
||||
isLoading={overviewQuery.isLoading}
|
||||
id={title.key}
|
||||
inverted={title.inverted}
|
||||
isLoading={overviewQuery.isLoading}
|
||||
key={title.key}
|
||||
label={title.title}
|
||||
metric={{
|
||||
current: overviewQuery.data?.metrics[title.key] ?? 0,
|
||||
previous: overviewQuery.data?.metrics[`prev_${title.key}`],
|
||||
}}
|
||||
onClick={() => setMetric(index)}
|
||||
unit={title.unit}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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-1',
|
||||
'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-1'
|
||||
)}
|
||||
>
|
||||
<OverviewLiveHistogram projectId={projectId} shareId={shareId} />
|
||||
@@ -148,17 +147,17 @@ export default function OverviewMetrics({
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-3 -mt-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
<div className="-mt-1 mb-3 flex items-center justify-between">
|
||||
<div className="font-medium text-muted-foreground text-sm">
|
||||
{activeMetric.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-[150px]">
|
||||
<div className="h-[150px] w-full">
|
||||
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
||||
<Chart
|
||||
activeMetric={activeMetric}
|
||||
interval={interval}
|
||||
data={data}
|
||||
interval={interval}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
@@ -203,7 +202,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
/>
|
||||
<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="flex justify-between gap-8 font-medium font-mono">
|
||||
<div className="row gap-1">
|
||||
{metric.unit === 'currency'
|
||||
? number.currency((data[metric.key] ?? 0) / 100)
|
||||
@@ -215,7 +214,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
? number.currency((data[`prev_${metric.key}`] ?? 0) / 100)
|
||||
: number.formatWithUnit(
|
||||
data[`prev_${metric.key}`],
|
||||
metric.unit,
|
||||
metric.unit
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
@@ -225,21 +224,21 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
data[metric.key],
|
||||
data[`prev_${metric.key}`],
|
||||
data[`prev_${metric.key}`]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{anyMetric && revenue > 0 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: '#3ba974' }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Revenue</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<div className="row gap-1">
|
||||
{number.currency(revenue / 100)}
|
||||
{prevRevenue > 0 && (
|
||||
@@ -293,12 +292,12 @@ function Chart({
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 10,
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Line chart specific logic
|
||||
let dotIndex = undefined;
|
||||
let dotIndex;
|
||||
if (interval === 'hour') {
|
||||
// Find closest index based on times
|
||||
dotIndex = data.findIndex((item) => {
|
||||
@@ -338,37 +337,37 @@ function Chart({
|
||||
|
||||
if (activeMetric.key === 'total_revenue') {
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<TooltipProvider interval={interval} metric={activeMetric}>
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
animationDuration={0}
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis {...yAxisProps} width={25} />
|
||||
<XAxis {...xAxisProps} />
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
horizontal={true}
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="rainbow-line-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
@@ -379,12 +378,13 @@ function Chart({
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
key={'prev_total_revenue'}
|
||||
type="monotone"
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
dataKey={'prev_total_revenue'}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
@@ -395,28 +395,21 @@ function Chart({
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
key={'prev_total_revenue'}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
|
||||
<Area
|
||||
key={'total_revenue'}
|
||||
type="monotone"
|
||||
activeDot={{
|
||||
stroke: '#3ba974',
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
dataKey={'total_revenue'}
|
||||
stroke={'#3ba974'}
|
||||
fill={'#3ba974'}
|
||||
fillOpacity={0.05}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
@@ -427,28 +420,34 @@ function Chart({
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: '#3ba974',
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
fill={'#3ba974'}
|
||||
fillOpacity={0.05}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
isAnimationActive={false}
|
||||
key={'total_revenue'}
|
||||
stroke={'#3ba974'}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
fontSize={10}
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
x={ref.date.getTime()}
|
||||
/>
|
||||
))}
|
||||
</ComposedChart>
|
||||
@@ -458,8 +457,8 @@ function Chart({
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval} anyMetric={true}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<TooltipProvider anyMetric={true} interval={interval} metric={activeMetric}>
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<ComposedChart
|
||||
data={data}
|
||||
onMouseMove={(e) => {
|
||||
@@ -468,9 +467,9 @@ function Chart({
|
||||
>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
animationDuration={0}
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
@@ -486,41 +485,41 @@ function Chart({
|
||||
/>
|
||||
<YAxis
|
||||
{...revenueYAxisProps}
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
allowDataOverflow={false}
|
||||
domain={[
|
||||
0,
|
||||
Math.max(
|
||||
data.reduce(
|
||||
(max, item) => Math.max(max, item.total_revenue ?? 0),
|
||||
0,
|
||||
0
|
||||
) * 1.2,
|
||||
1,
|
||||
1
|
||||
),
|
||||
]}
|
||||
orientation="right"
|
||||
width={30}
|
||||
allowDataOverflow={false}
|
||||
yAxisId="right"
|
||||
/>
|
||||
<XAxis {...xAxisProps} padding={{ left: 10, right: 10 }} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
horizontal={true}
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="rainbow-line-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
@@ -531,13 +530,6 @@ function Chart({
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="monotone"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'var(--def-100)',
|
||||
@@ -545,43 +537,44 @@ function Chart({
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
key={`prev_${activeMetric.key}`}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Bar
|
||||
key="total_revenue"
|
||||
dataKey="total_revenue"
|
||||
yAxisId="right"
|
||||
stackId="revenue"
|
||||
isAnimationActive={false}
|
||||
radius={5}
|
||||
key="total_revenue"
|
||||
maxBarSize={20}
|
||||
radius={5}
|
||||
stackId="revenue"
|
||||
yAxisId="right"
|
||||
>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<Cell
|
||||
key={item.date}
|
||||
className={cn(
|
||||
index === activeBar
|
||||
? 'fill-emerald-700/100'
|
||||
: 'fill-emerald-700/80',
|
||||
: 'fill-emerald-700/80'
|
||||
)}
|
||||
key={item.date}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Area
|
||||
key={activeMetric.key}
|
||||
type="monotone"
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
fill={getChartColor(0)}
|
||||
fillOpacity={0.05}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
@@ -593,28 +586,34 @@ function Chart({
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
fill={getChartColor(0)}
|
||||
fillOpacity={0.05}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
isAnimationActive={false}
|
||||
key={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
fontSize={10}
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
x={ref.date.getTime()}
|
||||
/>
|
||||
))}
|
||||
</ComposedChart>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -8,9 +11,6 @@ import {
|
||||
} 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;
|
||||
@@ -25,8 +25,8 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
|
||||
},
|
||||
{
|
||||
retry: 0,
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
const data = query.data;
|
||||
const mutation = useMutation(
|
||||
@@ -34,7 +34,7 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
|
||||
onSuccess() {
|
||||
query.refetch();
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -42,8 +42,8 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
icon={data?.public ? Globe2Icon : LockIcon}
|
||||
responsive
|
||||
loading={query.isLoading}
|
||||
responsive
|
||||
>
|
||||
{data?.public ? 'Public' : 'Private'}
|
||||
</Button>
|
||||
@@ -52,17 +52,17 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
|
||||
<DropdownMenuGroup>
|
||||
{(!data || data.public === false) && (
|
||||
<DropdownMenuItem onClick={() => pushModal('ShareOverviewModal')}>
|
||||
<Globe2Icon size={16} className="mr-2" />
|
||||
<Globe2Icon className="mr-2" size={16} />
|
||||
Make public
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{data?.public && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to={'/share/overview/$shareId'}
|
||||
params={{ shareId: data.id }}
|
||||
to={'/share/overview/$shareId'}
|
||||
>
|
||||
<EyeIcon size={16} className="mr-2" />
|
||||
<EyeIcon className="mr-2" size={16} />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -77,7 +77,7 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LockIcon size={16} className="mr-2" />
|
||||
<LockIcon className="mr-2" size={16} />
|
||||
Make private
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
@@ -23,6 +18,9 @@ import {
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
|
||||
interface OverviewTopDevicesProps {
|
||||
projectId: string;
|
||||
@@ -68,10 +66,10 @@ export default function OverviewTopDevices({
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
interval,
|
||||
name: 'Top devices',
|
||||
range: range,
|
||||
previous: previous,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
@@ -105,10 +103,10 @@ export default function OverviewTopDevices({
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
interval,
|
||||
name: 'Top browser',
|
||||
range: range,
|
||||
previous: previous,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
@@ -149,10 +147,10 @@ export default function OverviewTopDevices({
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
interval,
|
||||
name: 'Top Browser Version',
|
||||
range: range,
|
||||
previous: previous,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
@@ -186,10 +184,10 @@ export default function OverviewTopDevices({
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
interval,
|
||||
name: 'Top OS',
|
||||
range: range,
|
||||
previous: previous,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
@@ -230,10 +228,10 @@ export default function OverviewTopDevices({
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
interval,
|
||||
name: 'Top OS version',
|
||||
range: range,
|
||||
previous: previous,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
@@ -267,10 +265,10 @@ export default function OverviewTopDevices({
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
interval,
|
||||
name: 'Top Brands',
|
||||
range: range,
|
||||
previous: previous,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
@@ -311,10 +309,10 @@ export default function OverviewTopDevices({
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
interval,
|
||||
name: 'Top Models',
|
||||
range: range,
|
||||
previous: previous,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
@@ -333,7 +331,7 @@ export default function OverviewTopDevices({
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const seriesQuery = useQuery(
|
||||
@@ -350,8 +348,8 @@ export default function OverviewTopDevices({
|
||||
},
|
||||
{
|
||||
enabled: view === 'chart',
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -372,13 +370,13 @@ export default function OverviewTopDevices({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
onSearchChange={setSearchQuery}
|
||||
onTabChange={setWidget}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
searchValue={searchQuery}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<WidgetBody className="p-0">
|
||||
{view === 'chart' ? (
|
||||
@@ -397,19 +395,18 @@ export default function OverviewTopDevices({
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
data={filteredData}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<div className="row relative min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.name || NOT_SET_VALUE} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter(widget.key, item.name);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{item.name || 'Not set'}
|
||||
</button>
|
||||
@@ -417,6 +414,7 @@ export default function OverviewTopDevices({
|
||||
);
|
||||
},
|
||||
}}
|
||||
data={filteredData}
|
||||
/>
|
||||
)}
|
||||
</WidgetBody>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { eventQueryFiltersParser } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
|
||||
import {
|
||||
@@ -15,6 +10,12 @@ import {
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import {
|
||||
eventQueryFiltersParser,
|
||||
useEventQueryFilters,
|
||||
} from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
export interface OverviewTopEventsProps {
|
||||
projectId: string;
|
||||
@@ -31,7 +32,7 @@ export default function OverviewTopEvents({
|
||||
const navigate = useNavigate();
|
||||
const trpc = useTRPC();
|
||||
const { data: conversions } = useQuery(
|
||||
trpc.overview.topConversions.queryOptions({ projectId, shareId }),
|
||||
trpc.overview.topConversions.queryOptions({ projectId, shareId })
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
@@ -73,7 +74,7 @@ export default function OverviewTopEvents({
|
||||
widget.meta?.type === 'events'
|
||||
? ['session_start', 'session_end', 'screen_view']
|
||||
: undefined,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const linkOutQuery = useQuery(
|
||||
@@ -84,13 +85,15 @@ export default function OverviewTopEvents({
|
||||
startDate,
|
||||
endDate,
|
||||
filters,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const tableData: EventTableItem[] = useMemo(() => {
|
||||
// For link out, use href as name
|
||||
if (widget.meta?.type === 'linkOut') {
|
||||
if (!linkOutQuery.data) return [];
|
||||
if (!linkOutQuery.data) {
|
||||
return [];
|
||||
}
|
||||
return linkOutQuery.data.map((item) => ({
|
||||
id: item.href,
|
||||
name: item.href,
|
||||
@@ -99,7 +102,9 @@ export default function OverviewTopEvents({
|
||||
}
|
||||
|
||||
// For events and conversions
|
||||
if (!eventsQuery.data) return [];
|
||||
if (!eventsQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For conversions, filter events by conversion names (client-side filtering)
|
||||
if (widget.meta?.type === 'conversions' && conversions) {
|
||||
@@ -133,7 +138,7 @@ export default function OverviewTopEvents({
|
||||
}
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return tableData.filter((item) =>
|
||||
item.name?.toLowerCase().includes(queryLower),
|
||||
item.name?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
}, [tableData, searchQuery]);
|
||||
|
||||
@@ -145,20 +150,20 @@ export default function OverviewTopEvents({
|
||||
key: w.key,
|
||||
label: w.btn,
|
||||
})),
|
||||
[widgets],
|
||||
[widgets]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
onSearchChange={setSearchQuery}
|
||||
onTabChange={setWidget}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
searchValue={searchQuery}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<WidgetBody className="p-0">
|
||||
{isLoading ? (
|
||||
@@ -168,9 +173,7 @@ export default function OverviewTopEvents({
|
||||
data={filteredData}
|
||||
onItemClick={(name) => {
|
||||
const filterName =
|
||||
widget.meta?.type === 'linkOut'
|
||||
? 'properties.href'
|
||||
: 'name';
|
||||
widget.meta?.type === 'linkOut' ? 'properties.href' : 'name';
|
||||
const f = eventQueryFiltersParser.serialize([
|
||||
{
|
||||
id: filterName,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { IGetTopGenericInput } from '@openpanel/db';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
@@ -11,6 +8,8 @@ import {
|
||||
} from './overview-constants';
|
||||
import { OverviewListModal } from './overview-list-modal';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface OverviewTopGenericModalProps {
|
||||
projectId: string;
|
||||
@@ -32,7 +31,7 @@ export default function OverviewTopGenericModal({
|
||||
endDate,
|
||||
range,
|
||||
column,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
|
||||
@@ -40,25 +39,18 @@ export default function OverviewTopGenericModal({
|
||||
|
||||
return (
|
||||
<OverviewListModal
|
||||
title={`Top ${columnNamePlural}`}
|
||||
searchPlaceholder={`Search ${columnNamePlural.toLowerCase()}...`}
|
||||
columnName={columnName}
|
||||
data={query.data ?? []}
|
||||
keyExtractor={(item) => (item.prefix ?? '') + item.name}
|
||||
searchFilter={(item, query) =>
|
||||
item.name?.toLowerCase().includes(query) ||
|
||||
item.prefix?.toLowerCase().includes(query) ||
|
||||
false
|
||||
}
|
||||
columnName={columnName}
|
||||
renderItem={(item) => (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.prefix || item.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate hover:underline"
|
||||
onClick={() => {
|
||||
setFilter(column, item.name);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{item.prefix && (
|
||||
<span className="mr-1 inline-flex items-center gap-1">
|
||||
@@ -70,6 +62,12 @@ export default function OverviewTopGenericModal({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
searchFilter={(item, query) =>
|
||||
item.name?.toLowerCase().includes(query) ||
|
||||
item.prefix?.toLowerCase().includes(query)
|
||||
}
|
||||
searchPlaceholder={`Search ${columnNamePlural.toLowerCase()}...`}
|
||||
title={`Top ${columnNamePlural}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { countries } from '@/translations/countries';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
@@ -30,6 +24,10 @@ import {
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { countries } from '@/translations/countries';
|
||||
|
||||
interface OverviewTopGeoProps {
|
||||
projectId: string;
|
||||
@@ -72,7 +70,7 @@ export default function OverviewTopGeo({
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const seriesQuery = useQuery(
|
||||
@@ -89,8 +87,8 @@ export default function OverviewTopGeo({
|
||||
},
|
||||
{
|
||||
enabled: view === 'chart',
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -105,7 +103,7 @@ export default function OverviewTopGeo({
|
||||
item.prefix?.toLowerCase().includes(queryLower) ||
|
||||
countries[item.name as keyof typeof countries]
|
||||
?.toLowerCase()
|
||||
.includes(queryLower),
|
||||
.includes(queryLower)
|
||||
);
|
||||
}, [query.data, searchQuery]);
|
||||
|
||||
@@ -118,13 +116,13 @@ export default function OverviewTopGeo({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
onSearchChange={setSearchQuery}
|
||||
onTabChange={setWidget}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
searchValue={searchQuery}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<WidgetBody className="p-0">
|
||||
{view === 'chart' ? (
|
||||
@@ -143,17 +141,15 @@ export default function OverviewTopGeo({
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
data={filteredData}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<div className="row relative min-w-0 items-center gap-2">
|
||||
<SerieIcon
|
||||
name={item.prefix || item.name || NOT_SET_VALUE}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
if (widget.key === 'country') {
|
||||
@@ -163,9 +159,10 @@ export default function OverviewTopGeo({
|
||||
}
|
||||
setFilter(widget.key, item.name);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{item.prefix && (
|
||||
<span className="mr-1 row inline-flex items-center gap-1">
|
||||
<span className="row mr-1 inline-flex items-center gap-1">
|
||||
<span>
|
||||
{countries[
|
||||
item.prefix as keyof typeof countries
|
||||
@@ -184,6 +181,7 @@ export default function OverviewTopGeo({
|
||||
);
|
||||
},
|
||||
}}
|
||||
data={filteredData}
|
||||
/>
|
||||
)}
|
||||
</WidgetBody>
|
||||
@@ -198,13 +196,13 @@ export default function OverviewTopGeo({
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<OverviewViewToggle />
|
||||
<span className="text-sm text-muted-foreground pr-2 ml-2">
|
||||
<span className="ml-2 pr-2 text-muted-foreground text-sm">
|
||||
Geo data provided by{' '}
|
||||
<a
|
||||
href="https://ipdata.co"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="hover:underline"
|
||||
href="https://ipdata.co"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
MaxMind
|
||||
</a>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { OverviewListModal } from './overview-list-modal';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface OverviewTopPagesProps {
|
||||
projectId: string;
|
||||
@@ -26,46 +25,46 @@ export default function OverviewTopPagesModal({
|
||||
endDate,
|
||||
mode: 'page',
|
||||
range,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<OverviewListModal
|
||||
title="Top Pages"
|
||||
searchPlaceholder="Search pages..."
|
||||
columnName="Path"
|
||||
data={query.data ?? []}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
searchFilter={(item, query) =>
|
||||
item.path.toLowerCase().includes(query) ||
|
||||
item.origin.toLowerCase().includes(query)
|
||||
}
|
||||
columnName="Path"
|
||||
renderItem={(item) => (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.origin} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate hover:underline"
|
||||
onClick={() => {
|
||||
setFilter('path', item.path);
|
||||
setFilter('origin', item.origin);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{item.path || <span className="opacity-40">Not set</span>}
|
||||
</button>
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-shrink-0"
|
||||
href={item.origin + item.path}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 opacity-0 group-hover/row:opacity-100 transition-opacity" />
|
||||
<ExternalLinkIcon className="size-3 opacity-0 transition-opacity group-hover/row:opacity-100" />
|
||||
</a>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
)}
|
||||
searchFilter={(item, query) =>
|
||||
item.path.toLowerCase().includes(query) ||
|
||||
item.origin.toLowerCase().includes(query)
|
||||
}
|
||||
searchPlaceholder="Search pages..."
|
||||
title="Top Pages"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Globe2Icon } from 'lucide-react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
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';
|
||||
@@ -17,6 +13,9 @@ import {
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
|
||||
interface OverviewTopPagesProps {
|
||||
projectId: string;
|
||||
@@ -66,7 +65,7 @@ export default function OverviewTopPages({
|
||||
endDate,
|
||||
mode: widget.key,
|
||||
range,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -78,7 +77,7 @@ export default function OverviewTopPages({
|
||||
return data.filter(
|
||||
(item) =>
|
||||
item.path.toLowerCase().includes(queryLower) ||
|
||||
item.origin.toLowerCase().includes(queryLower),
|
||||
item.origin.toLowerCase().includes(queryLower)
|
||||
);
|
||||
}, [query.data, searchQuery]);
|
||||
|
||||
@@ -91,13 +90,13 @@ export default function OverviewTopPages({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
onSearchChange={setSearchQuery}
|
||||
onTabChange={setWidget}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
searchValue={searchQuery}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<WidgetBody className="p-0">
|
||||
{query.isLoading ? (
|
||||
@@ -125,11 +124,11 @@ export default function OverviewTopPages({
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
icon={Globe2Icon}
|
||||
onClick={() => {
|
||||
setDomain((p) => !p);
|
||||
}}
|
||||
icon={Globe2Icon}
|
||||
variant={'ghost'}
|
||||
>
|
||||
{domain ? 'Hide domain' : 'Show domain'}
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
@@ -21,6 +17,9 @@ import {
|
||||
} from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
|
||||
interface OverviewTopSourcesProps {
|
||||
projectId: string;
|
||||
@@ -79,7 +78,7 @@ export default function OverviewTopSources({
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const seriesQuery = useQuery(
|
||||
@@ -96,8 +95,8 @@ export default function OverviewTopSources({
|
||||
},
|
||||
{
|
||||
enabled: view === 'chart',
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -118,13 +117,13 @@ export default function OverviewTopSources({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
onSearchChange={setSearchQuery}
|
||||
onTabChange={setWidget}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
searchValue={searchQuery}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<WidgetBody className="p-0">
|
||||
{view === 'chart' ? (
|
||||
@@ -143,26 +142,25 @@ export default function OverviewTopSources({
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
data={filteredData}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<div className="row relative min-w-0 items-center gap-2">
|
||||
<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,
|
||||
item.name
|
||||
);
|
||||
} else {
|
||||
setFilter(widget.key, item.name);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{(item.name || 'Direct / Not set')
|
||||
.replace(/https?:\/\//, '')
|
||||
@@ -172,6 +170,7 @@ export default function OverviewTopSources({
|
||||
);
|
||||
},
|
||||
}}
|
||||
data={filteredData}
|
||||
/>
|
||||
)}
|
||||
</WidgetBody>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { ResponsiveSankey } from '@nivo/sankey';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import {
|
||||
type ReactNode,
|
||||
@@ -17,15 +10,21 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import { useTheme } from '../theme-provider';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
|
||||
interface OverviewUserJourneyProps {
|
||||
projectId: string;
|
||||
@@ -77,12 +76,16 @@ function SankeyPortalTooltip({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Nivo renders the tooltip content inside an absolutely-positioned wrapper <div>.
|
||||
// The wrapper is the immediate parent of our rendered content.
|
||||
const wrapper = el.parentElement;
|
||||
if (!wrapper) return;
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
setAnchorRect(wrapper.getBoundingClientRect());
|
||||
@@ -104,10 +107,16 @@ function SankeyPortalTooltip({
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!anchorRect) return;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (!anchorRect) {
|
||||
return;
|
||||
}
|
||||
const tooltipEl = tooltipRef.current;
|
||||
if (!tooltipEl) return;
|
||||
if (!tooltipEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = tooltipEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
@@ -120,11 +129,11 @@ function SankeyPortalTooltip({
|
||||
// Clamp inside viewport with a little padding.
|
||||
left = Math.min(
|
||||
Math.max(padding, left),
|
||||
Math.max(padding, vw - rect.width - padding),
|
||||
Math.max(padding, vw - rect.width - padding)
|
||||
);
|
||||
top = Math.min(
|
||||
Math.max(padding, top),
|
||||
Math.max(padding, vh - rect.height - padding),
|
||||
Math.max(padding, vh - rect.height - padding)
|
||||
);
|
||||
|
||||
setPos({ left, top, ready: true });
|
||||
@@ -138,12 +147,12 @@ function SankeyPortalTooltip({
|
||||
return (
|
||||
<>
|
||||
{/* Render a tiny (screen-reader-only) anchor inside Nivo's tooltip wrapper. */}
|
||||
<span ref={anchorRef} className="sr-only" />
|
||||
<span className="sr-only" ref={anchorRef} />
|
||||
{mounted &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="pointer-events-none fixed z-[9999]"
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
@@ -152,7 +161,7 @@ function SankeyPortalTooltip({
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -166,7 +175,7 @@ export default function OverviewUserJourney({
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [steps, setSteps] = useQueryState(
|
||||
'journeySteps',
|
||||
parseAsInteger.withDefault(5).withOptions({ history: 'push' }),
|
||||
parseAsInteger.withDefault(5).withOptions({ history: 'push' })
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const trpc = useTRPC();
|
||||
@@ -180,7 +189,7 @@ export default function OverviewUserJourney({
|
||||
range,
|
||||
steps: steps ?? 5,
|
||||
shareId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const data = query.data;
|
||||
@@ -188,7 +197,9 @@ export default function OverviewUserJourney({
|
||||
|
||||
// Process data for Sankey - nodes are already sorted by step then value from backend
|
||||
const sankeyData = useMemo(() => {
|
||||
if (!data) return { nodes: [], links: [] };
|
||||
if (!data) {
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: data.nodes.map((node: any) => ({
|
||||
@@ -207,7 +218,9 @@ export default function OverviewUserJourney({
|
||||
}, [data]);
|
||||
|
||||
const totalSessions = useMemo(() => {
|
||||
if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0;
|
||||
if (!sankeyData.nodes || sankeyData.nodes.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
// Total sessions used by backend for percentages is the sum of entry nodes (step 1).
|
||||
// Fall back to summing all nodes if step is missing for some reason.
|
||||
const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1);
|
||||
@@ -226,10 +239,10 @@ export default function OverviewUserJourney({
|
||||
<WidgetButtons>
|
||||
{stepOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
className={cn((steps ?? 5) === option && 'active')}
|
||||
key={option}
|
||||
onClick={() => setSteps(option)}
|
||||
className={cn((steps ?? 5) === option && 'active')}
|
||||
type="button"
|
||||
>
|
||||
{option} Steps
|
||||
</button>
|
||||
@@ -238,78 +251,34 @@ export default function OverviewUserJourney({
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">Loading...</div>
|
||||
</div>
|
||||
) : sankeyData.nodes.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No journey data available
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative aspect-square w-full md:aspect-[2]"
|
||||
ref={containerRef}
|
||||
className="w-full relative aspect-square md:aspect-[2]"
|
||||
>
|
||||
<ResponsiveSankey
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
data={sankeyData}
|
||||
colors={(node: any) => node.nodeColor}
|
||||
nodeBorderRadius={2}
|
||||
animate={false}
|
||||
nodeBorderWidth={0}
|
||||
nodeOpacity={0.8}
|
||||
colors={(node: any) => node.nodeColor}
|
||||
data={sankeyData}
|
||||
label={(node: any) => {
|
||||
const label = showPath(
|
||||
node.data?.label || node.label || node.id
|
||||
);
|
||||
return truncate(label, 30, 'middle');
|
||||
}}
|
||||
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
|
||||
linkBlendMode={'normal'}
|
||||
linkContract={1}
|
||||
linkOpacity={0.3}
|
||||
linkBlendMode={'normal'}
|
||||
nodeTooltip={({ node }: any) => {
|
||||
const label = node?.data?.label ?? node?.label ?? node?.id;
|
||||
const value = node?.data?.value ?? node?.value ?? 0;
|
||||
const step = node?.data?.step;
|
||||
const pct =
|
||||
typeof node?.data?.percentage === 'number'
|
||||
? node.data.percentage
|
||||
: totalSessions > 0
|
||||
? (value / totalSessions) * 100
|
||||
: 0;
|
||||
const color =
|
||||
node?.color ??
|
||||
node?.data?.nodeColor ??
|
||||
node?.data?.color ??
|
||||
node?.nodeColor ??
|
||||
'#64748b';
|
||||
|
||||
return (
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer className="min-w-[250px]">
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 font-medium break-words">
|
||||
<span className="opacity-40 mr-1">
|
||||
{showDomain(label)}
|
||||
</span>
|
||||
{showPath(label)}
|
||||
</div>
|
||||
{typeof step === 'number' && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
Step {step}
|
||||
</div>
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Share</div>
|
||||
<div>{number.format(round(pct, 1))} %</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
linkTooltip={({ link }: any) => {
|
||||
const sourceLabel =
|
||||
link?.source?.data?.label ??
|
||||
@@ -346,14 +315,14 @@ export default function OverviewUserJourney({
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer>
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 font-medium break-words">
|
||||
<span className="opacity-40 mr-1">
|
||||
<div className="min-w-0 flex-1 break-words font-medium">
|
||||
<span className="mr-1 opacity-40">
|
||||
{showDomain(sourceLabel)}
|
||||
</span>
|
||||
{showPath(sourceLabel)}
|
||||
<ArrowRightIcon className="size-2 inline-block mx-3" />
|
||||
<ArrowRightIcon className="mx-3 inline-block size-2" />
|
||||
{!isSameDomain && (
|
||||
<span className="opacity-40 mr-1">
|
||||
<span className="mr-1 opacity-40">
|
||||
{showDomain(targetLabel)}
|
||||
</span>
|
||||
)}
|
||||
@@ -368,7 +337,7 @@ export default function OverviewUserJourney({
|
||||
</ChartTooltipHeader>
|
||||
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="flex items-center justify-between gap-8 font-medium font-mono">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
@@ -389,20 +358,64 @@ export default function OverviewUserJourney({
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
label={(node: any) => {
|
||||
const label = showPath(
|
||||
node.data?.label || node.label || node.id,
|
||||
);
|
||||
return truncate(label, 30, 'middle');
|
||||
}}
|
||||
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
nodeBorderRadius={2}
|
||||
nodeBorderWidth={0}
|
||||
nodeOpacity={0.8}
|
||||
nodeSpacing={10}
|
||||
nodeTooltip={({ node }: any) => {
|
||||
const label = node?.data?.label ?? node?.label ?? node?.id;
|
||||
const value = node?.data?.value ?? node?.value ?? 0;
|
||||
const step = node?.data?.step;
|
||||
const pct =
|
||||
typeof node?.data?.percentage === 'number'
|
||||
? node.data.percentage
|
||||
: totalSessions > 0
|
||||
? (value / totalSessions) * 100
|
||||
: 0;
|
||||
const color =
|
||||
node?.color ??
|
||||
node?.data?.nodeColor ??
|
||||
node?.data?.color ??
|
||||
node?.nodeColor ??
|
||||
'#64748b';
|
||||
|
||||
return (
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer className="min-w-[250px]">
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 break-words font-medium">
|
||||
<span className="mr-1 opacity-40">
|
||||
{showDomain(label)}
|
||||
</span>
|
||||
{showPath(label)}
|
||||
</div>
|
||||
{typeof step === 'number' && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
Step {step}
|
||||
</div>
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-medium font-mono">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-medium font-mono">
|
||||
<div className="text-muted-foreground">Share</div>
|
||||
<div>{number.format(round(pct, 1))} %</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Shows the most common paths users take through your application
|
||||
</div>
|
||||
</WidgetFooter>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LineChartIcon, TableIcon } from 'lucide-react';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type ViewType = 'table' | 'chart';
|
||||
@@ -18,18 +17,20 @@ export function OverviewViewToggle({
|
||||
'view',
|
||||
parseAsStringEnum(['table', 'chart'])
|
||||
.withDefault(defaultView)
|
||||
.withOptions({ history: 'push' }),
|
||||
.withOptions({ history: 'push' })
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setView(view === 'table' ? 'chart' : 'table');
|
||||
}}
|
||||
title={view === 'table' ? 'Switch to chart view' : 'Switch to table view'}
|
||||
size="icon"
|
||||
title={
|
||||
view === 'table' ? 'Switch to chart view' : 'Switch to table view'
|
||||
}
|
||||
variant="ghost"
|
||||
>
|
||||
{view === 'table' ? (
|
||||
<LineChartIcon size={16} />
|
||||
@@ -46,9 +47,8 @@ export function useOverviewView() {
|
||||
'view',
|
||||
parseAsStringEnum(['table', 'chart'])
|
||||
.withDefault('table')
|
||||
.withOptions({ history: 'push' }),
|
||||
.withOptions({ history: 'push' })
|
||||
);
|
||||
|
||||
return [view, setView] as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
@@ -18,6 +9,15 @@ import {
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { WidgetHeadSearchable } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface OverviewWeeklyTrendsProps {
|
||||
projectId: string;
|
||||
@@ -58,16 +58,36 @@ function formatHourRange(hour: number) {
|
||||
}
|
||||
|
||||
function getColorClass(ratio: number) {
|
||||
if(ratio === 0) return 'bg-transparent';
|
||||
if (ratio < 0.1) return 'bg-chart-0/5';
|
||||
if (ratio < 0.2) return 'bg-chart-0/10';
|
||||
if (ratio < 0.3) return 'bg-chart-0/20';
|
||||
if (ratio < 0.4) return 'bg-chart-0/30';
|
||||
if (ratio < 0.5) return 'bg-chart-0/40';
|
||||
if (ratio < 0.6) return 'bg-chart-0/50';
|
||||
if (ratio < 0.7) return 'bg-chart-0/60';
|
||||
if (ratio < 0.8) return 'bg-chart-0/70';
|
||||
if (ratio < 0.9) return 'bg-chart-0/90';
|
||||
if (ratio === 0) {
|
||||
return 'bg-transparent';
|
||||
}
|
||||
if (ratio < 0.1) {
|
||||
return 'bg-chart-0/5';
|
||||
}
|
||||
if (ratio < 0.2) {
|
||||
return 'bg-chart-0/10';
|
||||
}
|
||||
if (ratio < 0.3) {
|
||||
return 'bg-chart-0/20';
|
||||
}
|
||||
if (ratio < 0.4) {
|
||||
return 'bg-chart-0/30';
|
||||
}
|
||||
if (ratio < 0.5) {
|
||||
return 'bg-chart-0/40';
|
||||
}
|
||||
if (ratio < 0.6) {
|
||||
return 'bg-chart-0/50';
|
||||
}
|
||||
if (ratio < 0.7) {
|
||||
return 'bg-chart-0/60';
|
||||
}
|
||||
if (ratio < 0.8) {
|
||||
return 'bg-chart-0/70';
|
||||
}
|
||||
if (ratio < 0.9) {
|
||||
return 'bg-chart-0/90';
|
||||
}
|
||||
return 'bg-chart-0';
|
||||
}
|
||||
|
||||
@@ -90,25 +110,27 @@ export default function OverviewWeeklyTrends({
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Build a 7×24 heatmap: aggregated[dayOfWeek][hour] averaged over all weeks
|
||||
const heatmap = useMemo(() => {
|
||||
const series = query.data?.series;
|
||||
if (!series?.length) return null;
|
||||
if (!series?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// aggregated[day 0=Mon..6=Sun][hour]
|
||||
const sums: number[][] = Array.from({ length: 7 }, () =>
|
||||
Array(24).fill(0),
|
||||
);
|
||||
const sums: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
|
||||
const counts: number[][] = Array.from({ length: 7 }, () =>
|
||||
Array(24).fill(0),
|
||||
Array(24).fill(0)
|
||||
);
|
||||
|
||||
for (const item of series) {
|
||||
const value = item[metric];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const d = new Date(item.date);
|
||||
// JS getDay(): 0=Sun,1=Mon,...,6=Sat → remap to 0=Mon..6=Sun
|
||||
@@ -124,13 +146,15 @@ export default function OverviewWeeklyTrends({
|
||||
row.map((sum, hour) => {
|
||||
const count = counts[day]![hour]!;
|
||||
return count > 0 ? sum / count : 0;
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
let max = 0;
|
||||
for (const row of averages) {
|
||||
for (const v of row) {
|
||||
if (v > max) max = v;
|
||||
if (v > max) {
|
||||
max = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,20 +166,16 @@ export default function OverviewWeeklyTrends({
|
||||
return (
|
||||
<Widget className="col-span-6">
|
||||
<WidgetHeadSearchable
|
||||
tabs={METRICS.map((m) => ({ key: m.key, label: m.label }))}
|
||||
activeTab={metric}
|
||||
onTabChange={setMetric}
|
||||
tabs={METRICS.map((m) => ({ key: m.key, label: m.label }))}
|
||||
/>
|
||||
<WidgetBody>
|
||||
{query.isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
<div className="flex h-48 items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
) : !heatmap ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
) : (
|
||||
) : heatmap ? (
|
||||
<div className="flex">
|
||||
{/* Hour labels */}
|
||||
<div className="w-14 shrink-0 pr-2">
|
||||
@@ -163,92 +183,89 @@ export default function OverviewWeeklyTrends({
|
||||
<div className="h-6" />
|
||||
{Array.from({ length: 24 }, (_, hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="flex h-4 items-center justify-end text-[10px] text-muted-foreground"
|
||||
key={hour}
|
||||
>
|
||||
{hour % 3 === 0
|
||||
? `${String(hour).padStart(2, '0')}:00`
|
||||
: ''}
|
||||
{hour % 3 === 0 ? `${String(hour).padStart(2, '0')}:00` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Day labels */}
|
||||
<div className="flex h-6">
|
||||
{SHORT_DAY_NAMES.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="flex-1 text-center text-[11px] text-muted-foreground"
|
||||
key={day}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<TooltipProvider disableHoverableContent delayDuration={0}>
|
||||
{/* Rows = hours, columns = days */}
|
||||
{Array.from({ length: 24 }, (_, hour) => (
|
||||
<div key={hour} className="flex h-4">
|
||||
{Array.from({ length: 7 }, (_, day) => {
|
||||
const value = heatmap.averages[day]![hour]!;
|
||||
const ratio =
|
||||
heatmap.max > 0 && value > 0
|
||||
? value / heatmap.max
|
||||
: 0;
|
||||
const colorClass = getColorClass(ratio)
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
{/* Rows = hours, columns = days */}
|
||||
{Array.from({ length: 24 }, (_, hour) => (
|
||||
<div className="flex h-4" key={hour}>
|
||||
{Array.from({ length: 7 }, (_, day) => {
|
||||
const value = heatmap.averages[day]![hour]!;
|
||||
const ratio =
|
||||
heatmap.max > 0 && value > 0 ? value / heatmap.max : 0;
|
||||
const colorClass = getColorClass(ratio);
|
||||
|
||||
return (
|
||||
<Tooltip key={day}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn(
|
||||
'flex-1 p-0.5 group',
|
||||
)}>
|
||||
<div className={cn(
|
||||
'size-full rounded-sm transition-all group-hover:ring-1 group-hover:ring-emerald-400',
|
||||
colorClass,
|
||||
)}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="border-0 bg-transparent p-0 shadow-none"
|
||||
|
||||
>
|
||||
<ChartTooltipContainer>
|
||||
<ChartTooltipHeader>
|
||||
<div className="text-sm font-medium">
|
||||
{LONG_DAY_NAMES[day]}, {formatHourRange(hour)}
|
||||
</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color="#10b981">
|
||||
<div className="flex items-center justify-between gap-6 font-mono font-medium text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
{activeMetric.label}
|
||||
return (
|
||||
<Tooltip key={day}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('group flex-1 p-0.5')}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-full rounded-sm transition-all group-hover:ring-1 group-hover:ring-emerald-400',
|
||||
colorClass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="border-0 bg-transparent p-0 shadow-none"
|
||||
side="top"
|
||||
>
|
||||
<ChartTooltipContainer>
|
||||
<ChartTooltipHeader>
|
||||
<div className="font-medium text-sm">
|
||||
{LONG_DAY_NAMES[day]}, {formatHourRange(hour)}
|
||||
</div>
|
||||
<div>
|
||||
{activeMetric.unit === 'pct'
|
||||
? `${number.format(value)} %`
|
||||
: number.formatWithUnit(
|
||||
value,
|
||||
activeMetric.unit || null,
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color="#10b981">
|
||||
<div className="flex items-center justify-between gap-6 font-medium font-mono text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
{activeMetric.label}
|
||||
</div>
|
||||
<div>
|
||||
{activeMetric.unit === 'pct'
|
||||
? `${number.format(value)} %`
|
||||
: number.formatWithUnit(
|
||||
value,
|
||||
activeMetric.unit || null
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-48 items-center justify-center text-muted-foreground text-sm">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from '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';
|
||||
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';
|
||||
|
||||
function RevenuePieChart({ percentage }: { percentage: number }) {
|
||||
const size = 16;
|
||||
@@ -17,30 +17,30 @@ function RevenuePieChart({ percentage }: { percentage: number }) {
|
||||
const offset = circumference - percentage * circumference;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="flex-shrink-0">
|
||||
<svg className="flex-shrink-0" height={size} width={size}>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
className="text-def-200"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-def-200"
|
||||
/>
|
||||
{/* Revenue arc */}
|
||||
<circle
|
||||
className="transition-all"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
r={radius}
|
||||
stroke="#3ba974"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidth}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -61,12 +61,12 @@ function SortableHeader({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'row items-center gap-1 hover:opacity-80 transition-opacity',
|
||||
isRightAligned && 'justify-end ml-auto',
|
||||
'row items-center gap-1 transition-opacity hover:opacity-80',
|
||||
isRightAligned && 'ml-auto justify-end'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span>{name}</span>
|
||||
{isSorted ? (
|
||||
@@ -95,7 +95,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
}: Props<T>) => {
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
// Handle column header click for sorting
|
||||
@@ -120,7 +120,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
const sortedData = useMemo(() => {
|
||||
const allData = data ?? [];
|
||||
|
||||
if (!sortColumn || !sortDirection) {
|
||||
if (!(sortColumn && sortDirection)) {
|
||||
// When not sorting, return top 15 (maintain original behavior)
|
||||
return allData;
|
||||
}
|
||||
@@ -142,9 +142,15 @@ export const OverviewWidgetTable = <T,>({
|
||||
const bValue = column.getSortValue!(b);
|
||||
|
||||
// Handle null values
|
||||
if (aValue === null && bValue === null) return 0;
|
||||
if (aValue === null) return 1;
|
||||
if (bValue === null) return -1;
|
||||
if (aValue === null && bValue === null) {
|
||||
return 0;
|
||||
}
|
||||
if (aValue === null) {
|
||||
return 1;
|
||||
}
|
||||
if (bValue === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Compare values
|
||||
let comparison = 0;
|
||||
@@ -176,21 +182,21 @@ export const OverviewWidgetTable = <T,>({
|
||||
key: columnName,
|
||||
name: isSortable ? (
|
||||
<SortableHeader
|
||||
name={columnName}
|
||||
isSorted={isSorted}
|
||||
sortDirection={currentSortDirection}
|
||||
onClick={() => handleSort(columnName)}
|
||||
isRightAligned={isRightAligned}
|
||||
isSorted={isSorted}
|
||||
name={columnName}
|
||||
onClick={() => handleSort(columnName)}
|
||||
sortDirection={currentSortDirection}
|
||||
/>
|
||||
) : (
|
||||
column.name
|
||||
),
|
||||
className: cn(
|
||||
index === 0
|
||||
? 'text-left w-full font-medium min-w-0'
|
||||
? 'w-full min-w-0 text-left font-medium'
|
||||
: 'text-right font-mono',
|
||||
// Remove old responsive logic - now handled by responsive prop
|
||||
column.className,
|
||||
column.className
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -199,15 +205,15 @@ export const OverviewWidgetTable = <T,>({
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<WidgetTable
|
||||
data={sortedData}
|
||||
keyExtractor={keyExtractor}
|
||||
className={'text-sm min-h-[358px] @container'}
|
||||
className={'@container min-h-[358px] text-sm'}
|
||||
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
||||
columns={columnsWithSortableHeaders}
|
||||
data={sortedData}
|
||||
eachRow={(item) => {
|
||||
return (
|
||||
<div className="absolute top-0 left-0 !p-0 w-full h-full">
|
||||
<div className="!p-0 absolute top-0 left-0 h-full w-full">
|
||||
<div
|
||||
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative"
|
||||
className="relative h-full bg-def-200 transition-colors group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900"
|
||||
style={{
|
||||
width: `${getColumnPercentage(item) * 100}%`,
|
||||
}}
|
||||
@@ -215,7 +221,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
columns={columnsWithSortableHeaders}
|
||||
keyExtractor={keyExtractor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -229,9 +235,6 @@ export function OverviewWidgetTableLoading({
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
|
||||
keyExtractor={(item) => item.toString()}
|
||||
getColumnPercentage={() => 0}
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
@@ -244,6 +247,9 @@ export function OverviewWidgetTableLoading({
|
||||
width: '84px',
|
||||
},
|
||||
]}
|
||||
data={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
|
||||
getColumnPercentage={() => 0}
|
||||
keyExtractor={(item) => item.toString()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -283,9 +289,6 @@ export function OverviewWidgetTablePages({
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
@@ -294,15 +297,15 @@ export function OverviewWidgetTablePages({
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<div className="row relative min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.origin} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter('path', item.path);
|
||||
setFilter('origin', item.origin);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{item.path ? (
|
||||
<>
|
||||
@@ -321,10 +324,10 @@ export function OverviewWidgetTablePages({
|
||||
</button>
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
|
||||
<ExternalLinkIcon className="size-3 opacity-0 transition-opacity group-hover/row:opacity-100" />
|
||||
</a>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
@@ -344,7 +347,7 @@ export function OverviewWidgetTablePages({
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<div className="row items-center justify-end gap-2">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
@@ -365,7 +368,7 @@ export function OverviewWidgetTablePages({
|
||||
getSortValue: (item: (typeof data)[number]) => item.pageviews,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<div className="row justify-end gap-2">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.pageviews)}
|
||||
</span>
|
||||
@@ -380,7 +383,7 @@ export function OverviewWidgetTablePages({
|
||||
getSortValue: (item: (typeof data)[number]) => item.sessions,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<div className="row justify-end gap-2">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.sessions)}
|
||||
</span>
|
||||
@@ -389,6 +392,9 @@ export function OverviewWidgetTablePages({
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={data ?? []}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -418,9 +424,6 @@ export function OverviewWidgetTableEntries({
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
@@ -429,15 +432,15 @@ export function OverviewWidgetTableEntries({
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<div className="row relative min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.origin} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter('path', item.path);
|
||||
setFilter('origin', item.origin);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{item.path ? (
|
||||
<>
|
||||
@@ -456,10 +459,10 @@ export function OverviewWidgetTableEntries({
|
||||
</button>
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
|
||||
<ExternalLinkIcon className="size-3 opacity-0 transition-opacity group-hover/row:opacity-100" />
|
||||
</a>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
@@ -479,7 +482,7 @@ export function OverviewWidgetTableEntries({
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<div className="row items-center justify-end gap-2">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
@@ -500,7 +503,7 @@ export function OverviewWidgetTableEntries({
|
||||
getSortValue: (item: (typeof data)[number]) => item.sessions,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<div className="row justify-end gap-2">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.sessions)}
|
||||
</span>
|
||||
@@ -509,6 +512,9 @@ export function OverviewWidgetTableEntries({
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={data ?? []}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -533,9 +539,6 @@ export function OverviewWidgetTableBots({
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
@@ -543,23 +546,23 @@ export function OverviewWidgetTableBots({
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<div className="row relative min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.origin} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter('path', item.path);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{getPath(item.path)}
|
||||
</button>
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
|
||||
<ExternalLinkIcon className="size-3 opacity-0 transition-opacity group-hover/row:opacity-100" />
|
||||
</a>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
@@ -571,7 +574,7 @@ export function OverviewWidgetTableBots({
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<div className="row justify-end gap-2">
|
||||
<span className="font-semibold">Google bot</span>
|
||||
</div>
|
||||
);
|
||||
@@ -582,13 +585,16 @@ export function OverviewWidgetTableBots({
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<div className="row justify-end gap-2">
|
||||
<span className="font-semibold">Google bot</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={data ?? []}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -603,7 +609,7 @@ export function OverviewWidgetTableGeneric({
|
||||
column: {
|
||||
name: string;
|
||||
render: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
item: RouterOutputs['overview']['topGeneric'][number]
|
||||
) => React.ReactNode;
|
||||
};
|
||||
}) {
|
||||
@@ -615,9 +621,6 @@ export function OverviewWidgetTableGeneric({
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.prefix + item.name}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
...column,
|
||||
@@ -631,14 +634,14 @@ export function OverviewWidgetTableGeneric({
|
||||
width: '100px',
|
||||
responsive: { priority: 3 },
|
||||
getSortValue: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
item: RouterOutputs['overview']['topGeneric'][number]
|
||||
) => item.revenue ?? 0,
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<div className="row items-center justify-end gap-2">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
@@ -661,11 +664,11 @@ export function OverviewWidgetTableGeneric({
|
||||
width: '84px',
|
||||
responsive: { priority: 2 },
|
||||
getSortValue: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
item: RouterOutputs['overview']['topGeneric'][number]
|
||||
) => item.pageviews,
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<div className="row justify-end gap-2">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.pageviews)}
|
||||
</span>
|
||||
@@ -680,11 +683,11 @@ export function OverviewWidgetTableGeneric({
|
||||
width: '84px',
|
||||
responsive: { priority: 2 },
|
||||
getSortValue: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
item: RouterOutputs['overview']['topGeneric'][number]
|
||||
) => item.sessions,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<div className="row justify-end gap-2">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.sessions)}
|
||||
</span>
|
||||
@@ -693,6 +696,9 @@ export function OverviewWidgetTableGeneric({
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={data ?? []}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
keyExtractor={(item) => item.prefix + item.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -717,9 +723,6 @@ export function OverviewWidgetTableEvents({
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
getColumnPercentage={(item) => item.count / maxCount}
|
||||
columns={[
|
||||
{
|
||||
name: 'Event',
|
||||
@@ -727,12 +730,12 @@ export function OverviewWidgetTableEvents({
|
||||
responsive: { priority: 1 },
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<div className="row relative min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => onItemClick?.(item.name)}
|
||||
type="button"
|
||||
>
|
||||
{item.name || 'Not set'}
|
||||
</button>
|
||||
@@ -747,7 +750,7 @@ export function OverviewWidgetTableEvents({
|
||||
getSortValue: (item: EventTableItem) => item.count,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<div className="row justify-end gap-2">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.count)}
|
||||
</span>
|
||||
@@ -756,6 +759,9 @@ export function OverviewWidgetTableEvents({
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={data ?? []}
|
||||
getColumnPercentage={(item) => item.count / maxCount}
|
||||
keyExtractor={(item) => item.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useThrottle } from '@/hooks/use-throttle';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronsUpDownIcon, type LucideIcon, SearchIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { Children, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -14,13 +11,15 @@ import {
|
||||
import { Input } from '../ui/input';
|
||||
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
|
||||
import { WidgetHead as WidgetHeadBase } from '../widget';
|
||||
import { useThrottle } from '@/hooks/use-throttle';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
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,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -37,11 +36,11 @@ export function WidgetTitle({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('title text-left row justify-start', className)}
|
||||
className={cn('title row justify-start text-left', className)}
|
||||
{...props}
|
||||
>
|
||||
{Icon && (
|
||||
<div className="rounded-lg bg-def-200 p-1 mr-2">
|
||||
<div className="mr-2 rounded-lg bg-def-200 p-1">
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
)}
|
||||
@@ -58,8 +57,8 @@ export function WidgetAbsoluteButtons({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'row gap-1 absolute right-4 top-1/2 -translate-y-1/2',
|
||||
className,
|
||||
'row absolute top-1/2 right-4 -translate-y-1/2 gap-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -83,11 +82,11 @@ export function WidgetButtons({
|
||||
if (sizes.current.length === 0) {
|
||||
// Get buttons
|
||||
const buttons: HTMLButtonElement[] = Array.from(
|
||||
container.current.querySelectorAll('button'),
|
||||
container.current.querySelectorAll('button')
|
||||
);
|
||||
// Get sizes and cache them
|
||||
sizes.current = buttons.map(
|
||||
(button) => Math.ceil(button.offsetWidth) + gap,
|
||||
(button) => Math.ceil(button.offsetWidth) + gap
|
||||
);
|
||||
}
|
||||
const containerWidth = container.current.offsetWidth;
|
||||
@@ -102,7 +101,7 @@ export function WidgetButtons({
|
||||
}
|
||||
return { index, size: acc.size + size };
|
||||
},
|
||||
{ index: 0, size: 0 },
|
||||
{ index: 0, size: 0 }
|
||||
);
|
||||
|
||||
setSlice(res.index);
|
||||
@@ -123,11 +122,11 @@ export function WidgetButtons({
|
||||
|
||||
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,
|
||||
'-mt-2 -mb-px flex flex-wrap justify-start self-stretch px-4 transition-opacity [&_button.active]:border-black [&_button.active]:border-b-2 [&_button.active]:opacity-100 dark:[&_button.active]:border-white [&_button]:whitespace-nowrap [&_button]:py-1 [&_button]:text-sm [&_button]:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={container}
|
||||
style={{ gap }}
|
||||
{...props}
|
||||
>
|
||||
@@ -136,7 +135,7 @@ export function WidgetButtons({
|
||||
<div
|
||||
className={cn(
|
||||
'flex [&_button]:leading-normal',
|
||||
slice < index ? hidden : 'opacity-100',
|
||||
slice < index ? hidden : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{child}
|
||||
@@ -146,11 +145,11 @@ export function WidgetButtons({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex select-none items-center gap-1',
|
||||
sizes.current.length - 1 === slice ? hidden : 'opacity-50',
|
||||
sizes.current.length - 1 === slice ? hidden : 'opacity-50'
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
More <ChevronsUpDownIcon size={12} />
|
||||
</button>
|
||||
@@ -200,20 +199,24 @@ export function WidgetHeadSearchable<T extends string>({
|
||||
|
||||
const updateGradients = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = el;
|
||||
const hasOverflow = scrollWidth > clientWidth;
|
||||
|
||||
setShowLeftGradient(hasOverflow && scrollLeft > 0);
|
||||
setShowRightGradient(
|
||||
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
|
||||
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateGradients();
|
||||
|
||||
@@ -233,33 +236,33 @@ export function WidgetHeadSearchable<T extends string>({
|
||||
}, [tabs, updateGradients]);
|
||||
|
||||
return (
|
||||
<div className={cn('border-b border-border', className)}>
|
||||
<div className={cn('border-border border-b', className)}>
|
||||
{/* Scrollable tabs container */}
|
||||
<div className="relative">
|
||||
{/* Left gradient */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
|
||||
showLeftGradient ? 'opacity-100' : 'opacity-0',
|
||||
'pointer-events-none absolute top-0 left-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
|
||||
showLeftGradient ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Scrollable tabs */}
|
||||
<div
|
||||
className="hide-scrollbar flex gap-1 overflow-x-auto px-2 py-3"
|
||||
ref={scrollRef}
|
||||
className="flex gap-1 overflow-x-auto px-2 py-3 hide-scrollbar"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md py-1.5 text-sm font-medium transition-colors px-2',
|
||||
'shrink-0 rounded-md px-2 py-1.5 font-medium text-sm transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:bg-def-100 hover:text-foreground',
|
||||
: 'text-muted-foreground hover:bg-def-100 hover:text-foreground'
|
||||
)}
|
||||
key={tab.key}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
@@ -269,8 +272,8 @@ export function WidgetHeadSearchable<T extends string>({
|
||||
{/* Right gradient */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-0 top-0 z-10 bottom-px w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
|
||||
showRightGradient ? 'opacity-100' : 'opacity-0',
|
||||
'pointer-events-none absolute top-0 right-0 bottom-px z-10 w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
|
||||
showRightGradient ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -278,13 +281,13 @@ export function WidgetHeadSearchable<T extends string>({
|
||||
{/* Search input */}
|
||||
{onSearchChange && (
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue ?? ''}
|
||||
className="rounded-none border-0 border-y bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 bg-transparent border-0 text-sm rounded-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0 border-y"
|
||||
placeholder={searchPlaceholder}
|
||||
type="search"
|
||||
value={searchValue ?? ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -300,8 +303,8 @@ export function WidgetFooter({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-b-md border-t bg-def-100 p-2 py-1',
|
||||
className,
|
||||
'flex rounded-b-md border-t bg-def-100 p-2 py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import { differenceInCalendarMonths } from 'date-fns';
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import {
|
||||
getDefaultIntervalByDates,
|
||||
getDefaultIntervalByRange,
|
||||
@@ -15,21 +6,29 @@ import {
|
||||
} from '@openpanel/constants';
|
||||
import type { IChartRange } from '@openpanel/validation';
|
||||
import { mapKeys } from '@openpanel/validation';
|
||||
import { differenceInCalendarMonths } from 'date-fns';
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
|
||||
const nuqsOptions = { history: 'push' } as const;
|
||||
|
||||
export function useOverviewOptions() {
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
'start',
|
||||
parseAsString.withOptions(nuqsOptions),
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [endDate, setEndDate] = useQueryState(
|
||||
'end',
|
||||
parseAsString.withOptions(nuqsOptions),
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [cookieRange, setCookieRange] = useCookieStore<IChartRange>(
|
||||
'range',
|
||||
'7d',
|
||||
'7d'
|
||||
);
|
||||
const [range, setRange] = useQueryState(
|
||||
'range',
|
||||
@@ -38,14 +37,14 @@ export function useOverviewOptions() {
|
||||
.withOptions({
|
||||
...nuqsOptions,
|
||||
clearOnDefault: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const [overrideInterval, setInterval] = useQueryState(
|
||||
'overrideInterval',
|
||||
parseAsStringEnum(mapKeys(intervals)).withOptions({
|
||||
...nuqsOptions,
|
||||
clearOnDefault: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const interval =
|
||||
@@ -55,7 +54,7 @@ export function useOverviewOptions() {
|
||||
|
||||
const [metric, setMetric] = useQueryState(
|
||||
'metric',
|
||||
parseAsInteger.withDefault(0).withOptions(nuqsOptions),
|
||||
parseAsInteger.withDefault(0).withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
import { mapKeys } from '@openpanel/validation';
|
||||
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import type { ReportChartProps } from '../report-chart/context';
|
||||
|
||||
export function useOverviewWidget<T extends string>(
|
||||
@@ -9,14 +7,14 @@ export function useOverviewWidget<T extends string>(
|
||||
widgets: Record<
|
||||
T,
|
||||
{ title: string; btn: string; chart: ReportChartProps; hide?: boolean }
|
||||
>,
|
||||
>
|
||||
) {
|
||||
const keys = Object.keys(widgets) as T[];
|
||||
const [widget, setWidget] = useQueryState<T>(
|
||||
key,
|
||||
parseAsStringEnum(keys)
|
||||
.withDefault(keys[0]!)
|
||||
.withOptions({ history: 'push' }),
|
||||
.withOptions({ history: 'push' })
|
||||
);
|
||||
return [
|
||||
{
|
||||
@@ -33,17 +31,14 @@ export function useOverviewWidget<T extends string>(
|
||||
|
||||
export function useOverviewWidgetV2<T extends string>(
|
||||
key: string,
|
||||
widgets: Record<
|
||||
T,
|
||||
{ title: string; btn: string; meta?: any; hide?: boolean }
|
||||
>,
|
||||
widgets: Record<T, { title: string; btn: string; meta?: any; hide?: boolean }>
|
||||
) {
|
||||
const keys = Object.keys(widgets) as T[];
|
||||
const [widget, setWidget] = useQueryState<T>(
|
||||
key,
|
||||
parseAsStringEnum(keys)
|
||||
.withDefault(keys[0]!)
|
||||
.withOptions({ history: 'push' }),
|
||||
.withOptions({ history: 'push' })
|
||||
);
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -17,10 +17,10 @@ export function PageHeader({
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn('col md:row gap-2', className)}>
|
||||
<div className={'space-y-1 flex-1'}>
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
<div className={'flex-1 space-y-1'}>
|
||||
<h1 className="font-semibold text-2xl">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground font-medium">{description}</p>
|
||||
<p className="font-medium text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface GscBreakdownTableProps {
|
||||
projectId: string;
|
||||
@@ -10,7 +10,11 @@ interface GscBreakdownTableProps {
|
||||
type: 'page' | 'query';
|
||||
}
|
||||
|
||||
export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableProps) {
|
||||
export function GscBreakdownTable({
|
||||
projectId,
|
||||
value,
|
||||
type,
|
||||
}: GscBreakdownTableProps) {
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
|
||||
@@ -23,23 +27,26 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP
|
||||
const pageQuery = useQuery(
|
||||
trpc.gsc.getPageDetails.queryOptions(
|
||||
{ projectId, page: value, ...dateInput },
|
||||
{ enabled: type === 'page' },
|
||||
),
|
||||
{ enabled: type === 'page' }
|
||||
)
|
||||
);
|
||||
|
||||
const queryQuery = useQuery(
|
||||
trpc.gsc.getQueryDetails.queryOptions(
|
||||
{ projectId, query: value, ...dateInput },
|
||||
{ enabled: type === 'query' },
|
||||
),
|
||||
{ enabled: type === 'query' }
|
||||
)
|
||||
);
|
||||
|
||||
const isLoading = type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||
const isLoading =
|
||||
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||
|
||||
const breakdownRows: Record<string, string | number>[] =
|
||||
type === 'page'
|
||||
? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record<string, string | number>[]
|
||||
: ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record<string, string | number>[];
|
||||
? (((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ??
|
||||
[]) as Record<string, string | number>[])
|
||||
: (((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ??
|
||||
[]) as Record<string, string | number>[]);
|
||||
|
||||
const breakdownKey = type === 'page' ? 'query' : 'page';
|
||||
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
||||
@@ -47,7 +54,7 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP
|
||||
|
||||
const maxClicks = Math.max(
|
||||
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
||||
1,
|
||||
1
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -57,22 +64,39 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<OverviewWidgetTable
|
||||
data={[1, 2, 3, 4, 5]}
|
||||
keyExtractor={(i) => String(i)}
|
||||
getColumnPercentage={() => 0}
|
||||
columns={[
|
||||
{ name: breakdownLabel, width: 'w-full', render: () => <Skeleton className="h-4 w-2/3" /> },
|
||||
{ name: 'Clicks', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
|
||||
{ name: 'Impr.', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
|
||||
{ name: 'CTR', width: '60px', render: () => <Skeleton className="h-4 w-8" /> },
|
||||
{ name: 'Pos.', width: '55px', render: () => <Skeleton className="h-4 w-8" /> },
|
||||
{
|
||||
name: breakdownLabel,
|
||||
width: 'w-full',
|
||||
render: () => <Skeleton className="h-4 w-2/3" />,
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
render: () => <Skeleton className="h-4 w-10" />,
|
||||
},
|
||||
{
|
||||
name: 'Impr.',
|
||||
width: '70px',
|
||||
render: () => <Skeleton className="h-4 w-10" />,
|
||||
},
|
||||
{
|
||||
name: 'CTR',
|
||||
width: '60px',
|
||||
render: () => <Skeleton className="h-4 w-8" />,
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
render: () => <Skeleton className="h-4 w-8" />,
|
||||
},
|
||||
]}
|
||||
data={[1, 2, 3, 4, 5]}
|
||||
getColumnPercentage={() => 0}
|
||||
keyExtractor={(i) => String(i)}
|
||||
/>
|
||||
) : (
|
||||
<OverviewWidgetTable
|
||||
data={breakdownRows}
|
||||
keyExtractor={(item) => String(item[breakdownKey])}
|
||||
getColumnPercentage={(item) => (item.clicks as number) / maxClicks}
|
||||
columns={[
|
||||
{
|
||||
name: breakdownLabel,
|
||||
@@ -136,6 +160,9 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={breakdownRows}
|
||||
getColumnPercentage={(item) => (item.clicks as number) / maxClicks}
|
||||
keyExtractor={(item) => String(item[breakdownKey])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,9 @@ const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
@@ -58,7 +60,9 @@ export function GscPositionChart({ data, isLoading }: GscPositionChartProps) {
|
||||
}));
|
||||
|
||||
const positions = chartData.map((d) => d.position).filter((p) => p > 0);
|
||||
const minPos = positions.length ? Math.max(1, Math.floor(Math.min(...positions)) - 2) : 1;
|
||||
const minPos = positions.length
|
||||
? Math.max(1, Math.floor(Math.min(...positions)) - 2)
|
||||
: 1;
|
||||
const maxPos = positions.length ? Math.ceil(Math.max(...positions)) + 2 : 20;
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user