fix(dashboard): pagination and login

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-01 13:33:55 +01:00
parent b801d6a8ef
commit 6251d143d1
11 changed files with 218 additions and 221 deletions

View File

@@ -1,9 +1,9 @@
import { useAppContext } from '@/hooks/use-app-context';
import { cn } from '@/utils/cn';
import { MenuIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { LogoSquare } from './logo';
import { Button, LinkButton } from './ui/button';
import { Button } from './ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { cn } from '@/utils/cn';
export function LoginNavbar({ className }: { className?: string }) {
const { isSelfHosted } = useAppContext();
@@ -12,59 +12,61 @@ export function LoginNavbar({ className }: { className?: string }) {
return (
<div
className={cn(
'absolute top-0 left-0 w-full row justify-between items-center p-8 z-10',
className,
'row absolute top-0 left-0 z-10 w-full items-center justify-between p-8',
className
)}
>
<a href="https://openpanel.dev" className="row items-center gap-2">
<a className="row items-center gap-2" href="https://openpanel.dev">
<LogoSquare className="size-8 shrink-0" />
<span className="font-medium text-sm text-muted-foreground">
<span className="font-medium text-muted-foreground text-sm">
{isSelfHosted ? 'Self-hosted analytics' : 'OpenPanel.dev'}
</span>
</a>
<nav className="max-md:hidden">
<ul className="row gap-4 items-center [&>li>a]:text-sm [&>li>a]:text-muted-foreground [&>li>a]:hover:underline">
<li>
<a href="https://openpanel.dev">OpenPanel Cloud</a>
</li>
<li>
<a href="https://openpanel.dev/compare/mixpanel-alternative">
Mixpanel alternative
</a>
</li>
<li>
<a href="https://openpanel.dev/compare/posthog-alternative">
Posthog alternative
</a>
</li>
<li>
<a href="https://openpanel.dev/articles/open-source-web-analytics">
Open source analytics
</a>
</li>
</ul>
</nav>
<div className="md:hidden relative">
{isSelfHosted && (
<nav className="max-md:hidden">
<ul className="row items-center gap-4 [&>li>a]:text-muted-foreground [&>li>a]:text-sm [&>li>a]:hover:underline">
<li>
<a href="https://openpanel.dev">OpenPanel Cloud</a>
</li>
<li>
<a href="https://openpanel.dev/compare/mixpanel-alternative">
Mixpanel alternative
</a>
</li>
<li>
<a href="https://openpanel.dev/compare/posthog-alternative">
Posthog alternative
</a>
</li>
<li>
<a href="https://openpanel.dev/articles/open-source-web-analytics">
Open source analytics
</a>
</li>
</ul>
</nav>
)}
<div className="relative md:hidden">
<Button
onClick={() => setMobileMenuOpen((prev) => !prev)}
size="icon"
variant="ghost"
onClick={() => setMobileMenuOpen((prev) => !prev)}
>
{mobileMenuOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
</Button>
{mobileMenuOpen && (
<>
<button
type="button"
onClick={() => setMobileMenuOpen(false)}
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onClick={() => setMobileMenuOpen(false)}
type="button"
/>
<nav className="absolute right-0 top-full mt-2 z-50 bg-card border border-border rounded-md shadow-lg min-w-48 py-2">
<ul className="flex flex-col *:text-sm *:text-muted-foreground">
<nav className="absolute top-full right-0 z-50 mt-2 min-w-48 rounded-md border border-border bg-card py-2 shadow-lg">
<ul className="flex flex-col *:text-muted-foreground *:text-sm">
<li>
<a
href="https://openpanel.dev"
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
href="https://openpanel.dev"
onClick={() => setMobileMenuOpen(false)}
>
OpenPanel Cloud
@@ -72,8 +74,8 @@ export function LoginNavbar({ className }: { className?: string }) {
</li>
<li>
<a
href="https://openpanel.dev/compare/mixpanel-alternative"
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
href="https://openpanel.dev/compare/mixpanel-alternative"
onClick={() => setMobileMenuOpen(false)}
>
Posthog alternative
@@ -81,8 +83,8 @@ export function LoginNavbar({ className }: { className?: string }) {
</li>
<li>
<a
href="https://openpanel.dev/compare/mixpanel-alternative"
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
href="https://openpanel.dev/compare/mixpanel-alternative"
onClick={() => setMobileMenuOpen(false)}
>
Mixpanel alternative
@@ -90,8 +92,8 @@ export function LoginNavbar({ className }: { className?: string }) {
</li>
<li>
<a
href="https://openpanel.dev/articles/open-source-web-analytics"
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
href="https://openpanel.dev/articles/open-source-web-analytics"
onClick={() => setMobileMenuOpen(false)}
>
Open source analytics

View File

@@ -1,119 +1,102 @@
import { LogoSquare } from '@/components/logo';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Link } from '@tanstack/react-router';
import { CodeIcon, CreditCardIcon, DollarSignIcon } from 'lucide-react';
import { SellingPoint } from './selling-points';
import Autoplay from 'embla-carousel-autoplay';
import { QuoteIcon } from 'lucide-react';
const onboardingSellingPoints = [
const testimonials = [
{
key: 'get-started',
render: () => (
<SellingPoint
bgImage="/img-6.webp"
title="Get started in minutes"
description={
<>
<p>
<DollarSignIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Free trial
</p>
<p>
<CreditCardIcon className="size-4 inline-block mr-1 relative -top-0.5" />
No credit card required
</p>
<p>
<CodeIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Add our tracking code and get insights in real-time.
</p>
</>
}
/>
),
key: 'thomas',
bgImage: '/img-1.webp',
quote:
"OpenPanel is BY FAR the best open-source analytics I've ever seen. Better UX/UI, many more features, and incredible support from the founder.",
author: 'Thomas Sanlis',
site: 'uneed.best',
},
{
key: 'welcome',
render: () => (
<SellingPoint
bgImage="/img-1.webp"
title="Best open-source alternative"
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring"
/>
),
key: 'julien',
bgImage: '/img-2.webp',
quote:
'After testing several product analytics tools, we chose OpenPanel and we are very satisfied. Profiles and Conversion Events are our favorite features.',
author: 'Julien Hany',
site: 'strackr.com',
},
{
key: 'selling-point-2',
render: () => (
<SellingPoint
bgImage="/img-2.webp"
title="Fast and reliable"
description="Never miss a beat with our real-time analytics"
/>
),
key: 'piotr',
bgImage: '/img-3.webp',
quote:
'The Overview tab is great — it has everything I need. The UI is beautiful, clean, modern, very pleasing to the eye.',
author: 'Piotr Kulpinski',
site: 'producthunt.com',
},
{
key: 'selling-point-3',
render: () => (
<SellingPoint
bgImage="/img-3.webp"
title="Easy to use"
description="Compared to other tools we have kept it simple"
/>
),
},
{
key: 'selling-point-4',
render: () => (
<SellingPoint
bgImage="/img-4.webp"
title="Privacy by default"
description="We have built our platform with privacy at its heart"
/>
),
},
{
key: 'selling-point-5',
render: () => (
<SellingPoint
bgImage="/img-5.webp"
title="Open source"
description="You can inspect the code and self-host if you choose"
/>
),
key: 'selfhost',
bgImage: '/img-4.webp',
quote:
"After paying a lot to PostHog for years, OpenPanel gives us the same — in many ways better — analytics while keeping full ownership of our data. We don't want to run any business without OpenPanel anymore.",
author: 'Self-hosting user',
site: undefined,
},
];
function TestimonialSlide({
bgImage,
quote,
author,
site,
}: {
bgImage: string;
quote: string;
author: string;
site?: string;
}) {
return (
<div className="relative flex flex-col justify-end h-full p-10 select-none">
<img
src={bgImage}
className="absolute inset-0 w-full h-full object-cover"
alt=""
/>
<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">
{quote}
</blockquote>
<figcaption className="text-white/60 text-sm">
{author}
{site && <span className="ml-1 text-white/40">· {site}</span>}
</figcaption>
</div>
</div>
);
}
export function OnboardingLeftPanel() {
return (
<div className="sticky top-0 h-screen overflow-hidden">
{/* Carousel */}
<div className="flex items-center justify-center h-full mt-24">
<Carousel
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
opts={{
loop: true,
align: 'center',
}}
opts={{ loop: true, align: 'center' }}
plugins={[Autoplay({ delay: 6000, stopOnInteraction: false })]}
>
<CarouselContent className="h-full">
{onboardingSellingPoints.map((point, index) => (
<CarouselItem
key={`onboarding-point-${point.key}`}
className="p-8 pb-32 pt-0"
>
{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">
{point.render()}
<TestimonialSlide
bgImage={t.bgImage}
quote={t.quote}
author={t.author}
site={t.site}
/>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-12 bottom-30 top-auto" />
<CarouselNext className="right-12 bottom-30 top-auto" />
</Carousel>
</div>
</div>

View File

@@ -1,8 +1,11 @@
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 { Widget, WidgetBody } from '../widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
@@ -23,7 +26,9 @@ export default function OverviewTopEvents({
shareId,
}: OverviewTopEventsProps) {
const { range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [filters] = useEventQueryFilters();
const { organizationId } = useAppParams();
const navigate = useNavigate();
const trpc = useTRPC();
const { data: conversions } = useQuery(
trpc.overview.topConversions.queryOptions({ projectId, shareId }),
@@ -162,11 +167,23 @@ export default function OverviewTopEvents({
<OverviewWidgetTableEvents
data={filteredData}
onItemClick={(name) => {
if (widget.meta?.type === 'linkOut') {
setFilter('properties.href', name);
} else {
setFilter('name', name);
}
const filterName =
widget.meta?.type === 'linkOut'
? 'properties.href'
: 'name';
const f = eventQueryFiltersParser.serialize([
{
id: filterName,
name: filterName,
operator: 'is',
value: [name],
},
]);
navigate({
to: '/$organizationId/$projectId/events/events',
params: { organizationId, projectId },
search: { f },
});
}}
/>
)}

View File

@@ -18,19 +18,22 @@ import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react';
const PAGE_SIZE = 50;
type Props = {
query: UseQueryResult<RouterOutputs['profile']['list'], unknown>;
type: 'profiles' | 'power-users';
pageSize?: number;
};
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceProfile[];
export const ProfilesTable = memo(
({ type, query }: Props) => {
({ type, query, pageSize = PAGE_SIZE }: Props) => {
const { data, isLoading } = query;
const columns = useColumns(type);
const { setPage, state: pagination } = useDataTablePagination();
const { setPage, state: pagination } = useDataTablePagination(pageSize);
const {
columnVisibility,
setColumnVisibility,
@@ -83,7 +86,7 @@ export const ProfilesTable = memo(
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'type']),
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']),
);
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {