chore:little fixes and formating and linting and patches

This commit is contained in:
2026-03-31 15:50:54 +02:00
parent a1ce71ffb6
commit 9b197abcfa
815 changed files with 22960 additions and 8982 deletions

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>
);
};

View File

@@ -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),
},
}),
})
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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');

View File

@@ -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>{' '}

View File

@@ -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 }}
>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>
);

View File

@@ -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';
// ============================================================================

View File

@@ -1,5 +1,5 @@
import { cn } from '@/utils/cn';
import { useEffect, useRef } from 'react';
import { cn } from '@/utils/cn';
type Props = {
className?: string;

View File

@@ -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';

View File

@@ -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>

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>}

View File

@@ -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}
/>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
>

View File

@@ -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 ? (

View File

@@ -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',

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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"
/>
),
},

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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 />

View File

@@ -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,

View File

@@ -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 />

View File

@@ -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} />;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />
</>
);
};

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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' && (

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />

View File

@@ -1,5 +1,4 @@
import { ScanEyeIcon } from 'lucide-react';
import { Button, type ButtonProps } from '../ui/button';
type Props = Omit<ButtonProps, 'children'>;

View File

@@ -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>
);

View File

@@ -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}
/>
);
}

View File

@@ -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>(
)}
</>
);
},
}
);

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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}`}
/>
);
}

View File

@@ -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>

View File

@@ -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"
/>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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}
/>
);
}

View File

@@ -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}
>

View File

@@ -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 {

View File

@@ -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 [
{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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