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,13 +1,11 @@
'use client'; 'use client';
import { FeatureCardBackground } from '@/components/feature-card';
import { Section, SectionHeader, SectionLabel } from '@/components/section';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { QuoteIcon } from 'lucide-react'; import { QuoteIcon } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import { FeatureCardBackground } from '@/components/feature-card';
import { Section, SectionHeader, SectionLabel } from '@/components/section';
import { cn } from '@/lib/utils';
const images = [ const images = [
{ {
@@ -65,55 +63,54 @@ const quotes: {
]; ];
export function WhyOpenPanel() { export function WhyOpenPanel() {
const [showMore, setShowMore] = useState(false);
return ( return (
<Section className="container gap-16"> <Section className="container gap-16">
<SectionHeader label="Trusted by founders" title="Who uses OpenPanel?" /> <SectionHeader label="Trusted by founders" title="Who uses OpenPanel?" />
<div className="col overflow-hidden"> <div className="col overflow-hidden">
<SectionLabel className="text-muted-foreground bg-background -mb-2 z-5 self-start pr-4"> <SectionLabel className="z-5 -mb-2 self-start bg-background pr-4 text-muted-foreground">
USED BY USED BY
</SectionLabel> </SectionLabel>
<div className="grid grid-cols-3 md:grid-cols-6 -mx-4 border-y py-4"> <div className="-mx-4 grid grid-cols-3 border-y py-4 md:grid-cols-6">
{images.map((image) => ( {images.map((image) => (
<div key={image.logo} className="px-4 border-r last:border-r-0 "> <div className="border-r px-4 last:border-r-0" key={image.logo}>
<a <a
className={cn('group center-center relative aspect-square')}
href={image.url} href={image.url}
target="_blank"
rel="noopener noreferrer nofollow"
key={image.logo} key={image.logo}
className={cn('relative group center-center aspect-square')} rel="noopener noreferrer nofollow"
target="_blank"
title={image.name} title={image.name}
> >
<FeatureCardBackground /> <FeatureCardBackground />
<Image <Image
src={image.logo}
alt={image.name} alt={image.name}
width={64}
height={64}
className={cn('size-16 object-contain dark:invert')} className={cn('size-16 object-contain dark:invert')}
height={64}
src={image.logo}
width={64}
/> />
</a> </a>
</div> </div>
))} ))}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 -mx-4 border-y py-4"> <div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2">
{quotes.slice(0, showMore ? quotes.length : 2).map((quote) => ( {quotes.map((quote) => (
<figure <figure
className="group px-4 py-4 md:odd:border-r"
key={quote.author} key={quote.author}
className="px-4 py-4 md:odd:border-r group"
> >
<QuoteIcon className="size-10 text-muted-foreground/50 stroke-1 mb-2 group-hover:text-foreground group-hover:rotate-6 transition-all" /> <QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" />
<blockquote className="text-xl prose"> <blockquote className="prose text-xl">
<Markdown>{quote.quote}</Markdown> <Markdown>{quote.quote}</Markdown>
</blockquote> </blockquote>
<figcaption className="row justify-between text-muted-foreground text-sm mt-4"> <figcaption className="row mt-4 justify-between text-muted-foreground text-sm">
<span>{quote.author}</span> <span>{quote.author}</span>
{quote.site && ( {quote.site && (
<cite className="not-italic"> <cite className="not-italic">
<a <a
href={quote.site} href={quote.site}
target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank"
> >
{quote.site.replace('https://', '')} {quote.site.replace('https://', '')}
</a> </a>
@@ -123,14 +120,6 @@ export function WhyOpenPanel() {
</figure> </figure>
))} ))}
</div> </div>
<Button
onClick={() => setShowMore((p) => !p)}
type="button"
variant="outline"
className="self-end mt-4"
>
{showMore ? 'Show less' : 'View more reviews'}
</Button>
</div> </div>
</Section> </Section>
); );

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

View File

@@ -1,119 +1,102 @@
import { LogoSquare } from '@/components/logo';
import { import {
Carousel, Carousel,
CarouselContent, CarouselContent,
CarouselItem, CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel'; } from '@/components/ui/carousel';
import { Link } from '@tanstack/react-router'; import Autoplay from 'embla-carousel-autoplay';
import { CodeIcon, CreditCardIcon, DollarSignIcon } from 'lucide-react'; import { QuoteIcon } from 'lucide-react';
import { SellingPoint } from './selling-points';
const onboardingSellingPoints = [ const testimonials = [
{ {
key: 'get-started', key: 'thomas',
render: () => ( bgImage: '/img-1.webp',
<SellingPoint quote:
bgImage="/img-6.webp" "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.",
title="Get started in minutes" author: 'Thomas Sanlis',
description={ site: 'uneed.best',
<>
<p>
<DollarSignIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Free trial
</p>
<p>
<CreditCardIcon className="size-4 inline-block mr-1 relative -top-0.5" />
No credit card required
</p>
<p>
<CodeIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Add our tracking code and get insights in real-time.
</p>
</>
}
/>
),
}, },
{ {
key: 'welcome', key: 'julien',
render: () => ( bgImage: '/img-2.webp',
<SellingPoint quote:
bgImage="/img-1.webp" 'After testing several product analytics tools, we chose OpenPanel and we are very satisfied. Profiles and Conversion Events are our favorite features.',
title="Best open-source alternative" author: 'Julien Hany',
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring" site: 'strackr.com',
/>
),
}, },
{ {
key: 'selling-point-2', key: 'piotr',
render: () => ( bgImage: '/img-3.webp',
<SellingPoint quote:
bgImage="/img-2.webp" 'The Overview tab is great — it has everything I need. The UI is beautiful, clean, modern, very pleasing to the eye.',
title="Fast and reliable" author: 'Piotr Kulpinski',
description="Never miss a beat with our real-time analytics" site: 'producthunt.com',
/>
),
}, },
{ {
key: 'selling-point-3', key: 'selfhost',
render: () => ( bgImage: '/img-4.webp',
<SellingPoint quote:
bgImage="/img-3.webp" "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.",
title="Easy to use" author: 'Self-hosting user',
description="Compared to other tools we have kept it simple" site: undefined,
/>
),
},
{
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"
/>
),
}, },
]; ];
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() { export function OnboardingLeftPanel() {
return ( return (
<div className="sticky top-0 h-screen overflow-hidden"> <div className="sticky top-0 h-screen overflow-hidden">
{/* Carousel */}
<div className="flex items-center justify-center h-full mt-24"> <div className="flex items-center justify-center h-full mt-24">
<Carousel <Carousel
className="w-full h-full [&>div]:h-full [&>div]:min-h-full" className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
opts={{ opts={{ loop: true, align: 'center' }}
loop: true, plugins={[Autoplay({ delay: 6000, stopOnInteraction: false })]}
align: 'center',
}}
> >
<CarouselContent className="h-full"> <CarouselContent className="h-full">
{onboardingSellingPoints.map((point, index) => ( {testimonials.map((t) => (
<CarouselItem <CarouselItem key={t.key} className="p-8 pb-32 pt-0">
key={`onboarding-point-${point.key}`}
className="p-8 pb-32 pt-0"
>
<div className="rounded-xl min-h-full h-full overflow-hidden bg-card border border-border shadow-lg"> <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> </div>
</CarouselItem> </CarouselItem>
))} ))}
</CarouselContent> </CarouselContent>
<CarouselPrevious className="left-12 bottom-30 top-auto" />
<CarouselNext className="right-12 bottom-30 top-auto" />
</Carousel> </Carousel>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,11 @@
import { useAppParams } from '@/hooks/use-app-params';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { eventQueryFiltersParser } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { Widget, WidgetBody } from '../widget'; import { Widget, WidgetBody } from '../widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget'; import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import { import {
@@ -23,7 +26,9 @@ export default function OverviewTopEvents({
shareId, shareId,
}: OverviewTopEventsProps) { }: OverviewTopEventsProps) {
const { range, startDate, endDate } = useOverviewOptions(); const { range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters(); const [filters] = useEventQueryFilters();
const { organizationId } = useAppParams();
const navigate = useNavigate();
const trpc = useTRPC(); const trpc = useTRPC();
const { data: conversions } = useQuery( const { data: conversions } = useQuery(
trpc.overview.topConversions.queryOptions({ projectId, shareId }), trpc.overview.topConversions.queryOptions({ projectId, shareId }),
@@ -162,11 +167,23 @@ export default function OverviewTopEvents({
<OverviewWidgetTableEvents <OverviewWidgetTableEvents
data={filteredData} data={filteredData}
onItemClick={(name) => { onItemClick={(name) => {
if (widget.meta?.type === 'linkOut') { const filterName =
setFilter('properties.href', name); widget.meta?.type === 'linkOut'
} else { ? 'properties.href'
setFilter('name', name); : '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 { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react'; import { memo } from 'react';
const PAGE_SIZE = 50;
type Props = { type Props = {
query: UseQueryResult<RouterOutputs['profile']['list'], unknown>; query: UseQueryResult<RouterOutputs['profile']['list'], unknown>;
type: 'profiles' | 'power-users'; type: 'profiles' | 'power-users';
pageSize?: number;
}; };
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceProfile[]; const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceProfile[];
export const ProfilesTable = memo( export const ProfilesTable = memo(
({ type, query }: Props) => { ({ type, query, pageSize = PAGE_SIZE }: Props) => {
const { data, isLoading } = query; const { data, isLoading } = query;
const columns = useColumns(type); const columns = useColumns(type);
const { setPage, state: pagination } = useDataTablePagination(); const { setPage, state: pagination } = useDataTablePagination(pageSize);
const { const {
columnVisibility, columnVisibility,
setColumnVisibility, 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> }) { function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {

View File

@@ -24,12 +24,12 @@ export const Route = createFileRoute(
function Component() { function Component() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const trpc = useTRPC(); const trpc = useTRPC();
const { page } = useDataTablePagination(); const { page } = useDataTablePagination(50);
const { debouncedSearch } = useSearchQueryState(); const { debouncedSearch } = useSearchQueryState();
const query = useQuery( const query = useQuery(
trpc.profile.list.queryOptions( trpc.profile.list.queryOptions(
{ {
cursor: (page - 1) * 50, cursor: page - 1,
projectId, projectId,
take: 50, take: 50,
search: debouncedSearch, search: debouncedSearch,

View File

@@ -31,7 +31,7 @@ function Component() {
const query = useQuery( const query = useQuery(
trpc.profile.list.queryOptions( trpc.profile.list.queryOptions(
{ {
cursor: (page - 1) * 50, cursor: page - 1,
projectId, projectId,
take: 50, take: 50,
search: debouncedSearch, search: debouncedSearch,

View File

@@ -23,11 +23,11 @@ export const Route = createFileRoute(
function Component() { function Component() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const trpc = useTRPC(); const trpc = useTRPC();
const { page } = useDataTablePagination(); const { page } = useDataTablePagination(50);
const query = useQuery( const query = useQuery(
trpc.profile.powerUsers.queryOptions( trpc.profile.powerUsers.queryOptions(
{ {
cursor: (page - 1) * 50, cursor: page - 1,
projectId, projectId,
take: 50, take: 50,
}, },

View File

@@ -1,3 +1,15 @@
import { IMPORT_PROVIDERS } from '@openpanel/importer/providers';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import {
CheckCircleIcon,
Download,
InfoIcon,
Loader2Icon,
XCircleIcon,
} from 'lucide-react';
import { toast } from 'sonner';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { import {
IntegrationCard, IntegrationCard,
@@ -19,21 +31,9 @@ import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { IMPORT_PROVIDERS } from '@openpanel/importer/providers';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import {
CheckCircleIcon,
Download,
InfoIcon,
Loader2Icon,
XCircleIcon,
} from 'lucide-react';
import { toast } from 'sonner';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId/settings/_tabs/imports', '/_app/$organizationId/$projectId/settings/_tabs/imports'
)({ )({
component: ImportsSettings, component: ImportsSettings,
}); });
@@ -48,8 +48,8 @@ function ImportsSettings() {
{ projectId }, { projectId },
{ {
refetchInterval: 5000, refetchInterval: 5000,
}, }
), )
); );
const imports = importsQuery.data ?? []; const imports = importsQuery.data ?? [];
@@ -61,7 +61,7 @@ function ImportsSettings() {
}); });
queryClient.invalidateQueries(trpc.import.list.pathFilter()); queryClient.invalidateQueries(trpc.import.list.pathFilter());
}, },
}), })
); );
const retryImport = useMutation( const retryImport = useMutation(
@@ -72,11 +72,11 @@ function ImportsSettings() {
}); });
queryClient.invalidateQueries(trpc.import.list.pathFilter()); queryClient.invalidateQueries(trpc.import.list.pathFilter());
}, },
}), })
); );
const handleProviderSelect = ( const handleProviderSelect = (
provider: (typeof IMPORT_PROVIDERS)[number], provider: (typeof IMPORT_PROVIDERS)[number]
) => { ) => {
pushModal('AddImport', { pushModal('AddImport', {
provider: provider.id, provider: provider.id,
@@ -93,10 +93,10 @@ function ImportsSettings() {
failed: 'destructive', failed: 'destructive',
}; };
const icons: Record<string, React.ReactNode> = { const icons: Record<string, React.ReactNode> = {
pending: <Loader2Icon className="w-4 h-4 animate-spin" />, pending: <Loader2Icon className="h-4 w-4 animate-spin" />,
processing: <Loader2Icon className="w-4 h-4 animate-spin" />, processing: <Loader2Icon className="h-4 w-4 animate-spin" />,
completed: <CheckCircleIcon className="w-4 h-4" />, completed: <CheckCircleIcon className="h-4 w-4" />,
failed: <XCircleIcon className="w-4 h-4" />, failed: <XCircleIcon className="h-4 w-4" />,
}; };
if (status === 'failed') { if (status === 'failed') {
@@ -105,7 +105,7 @@ function ImportsSettings() {
content={errorMessage} content={errorMessage}
tooltipClassName="max-w-xs break-words" tooltipClassName="max-w-xs break-words"
> >
<Badge variant={variants[status] || 'default'} className="capitalize"> <Badge className="capitalize" variant={variants[status] || 'default'}>
{icons[status] || null} {icons[status] || null}
{status} {status}
</Badge> </Badge>
@@ -114,7 +114,7 @@ function ImportsSettings() {
} }
return ( return (
<Badge variant={variants[status] || 'default'} className="capitalize"> <Badge className="capitalize" variant={variants[status] || 'default'}>
{icons[status] || null} {icons[status] || null}
{status} {status}
</Badge> </Badge>
@@ -124,26 +124,26 @@ function ImportsSettings() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{IMPORT_PROVIDERS.map((provider) => ( {IMPORT_PROVIDERS.map((provider) => (
<IntegrationCard <IntegrationCard
key={provider.id} description={provider.description}
icon={ icon={
<IntegrationCardLogoImage <IntegrationCardLogoImage
src={provider.logo}
backgroundColor={provider.backgroundColor} backgroundColor={provider.backgroundColor}
className="p-4" className="p-4"
src={provider.logo}
/> />
} }
key={provider.id}
name={provider.name} name={provider.name}
description={provider.description}
> >
<IntegrationCardFooter className="row justify-end"> <IntegrationCardFooter className="row justify-end">
<Button <Button
variant="ghost"
onClick={() => handleProviderSelect(provider)} onClick={() => handleProviderSelect(provider)}
variant="ghost"
> >
<Download className="w-4 h-4 mr-2" /> <Download className="mr-2 h-4 w-4" />
Import Data Import Data
</Button> </Button>
</IntegrationCardFooter> </IntegrationCardFooter>
@@ -153,9 +153,9 @@ function ImportsSettings() {
</div> </div>
<div> <div>
<h3 className="text-lg font-medium mb-4">Import History</h3> <h3 className="mb-4 font-medium text-lg">Import History</h3>
<div className="border rounded-lg"> <div className="rounded-lg border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -172,8 +172,8 @@ function ImportsSettings() {
<TableRow> <TableRow>
<TableCell colSpan={6}> <TableCell colSpan={6}>
<FullPageEmptyState <FullPageEmptyState
title="No imports yet"
description="Your import history will appear here." description="Your import history will appear here."
title="No imports yet"
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -196,7 +196,7 @@ function ImportsSettings() {
<TableCell> <TableCell>
<Skeleton className="h-4 w-3/5" /> <Skeleton className="h-4 w-3/5" />
</TableCell> </TableCell>
<TableCell className="text-right justify-end row"> <TableCell className="row justify-end text-right">
<Skeleton className="h-4 w-3/5" /> <Skeleton className="h-4 w-3/5" />
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -204,9 +204,9 @@ function ImportsSettings() {
{imports.map((imp) => ( {imports.map((imp) => (
<TableRow key={imp.id}> <TableRow key={imp.id}>
<TableCell className="font-medium capitalize"> <TableCell className="font-medium capitalize">
<div className="row gap-2 items-center"> <div className="row items-center gap-2">
<div>{imp.config.provider}</div> <div>{imp.config.provider}</div>
<Badge variant="outline" className="uppercase"> <Badge className="uppercase" variant="outline">
{imp.config.type} {imp.config.type}
</Badge> </Badge>
</div> </div>
@@ -220,7 +220,7 @@ function ImportsSettings() {
<div className="space-y-1"> <div className="space-y-1">
{getStatusBadge(imp.status, imp.errorMessage)} {getStatusBadge(imp.status, imp.errorMessage)}
{imp.statusMessage && ( {imp.statusMessage && (
<div className="text-xs text-muted-foreground truncate"> <div className="truncate text-muted-foreground text-xs">
{imp.statusMessage} {imp.statusMessage}
</div> </div>
)} )}
@@ -237,13 +237,13 @@ function ImportsSettings() {
tooltipClassName="max-w-xs" tooltipClassName="max-w-xs"
> >
{imp.totalEvents.toLocaleString()}{' '} {imp.totalEvents.toLocaleString()}{' '}
<InfoIcon className="w-4 h-4 inline-block relative -top-px" /> <InfoIcon className="relative -top-px inline-block h-4 w-4" />
</Tooltiper> </Tooltiper>
</div> </div>
{imp.status === 'processing' && ( {imp.status === 'processing' && (
<div className="w-full bg-secondary rounded-full h-1.5"> <div className="h-1.5 w-full rounded-full bg-secondary">
<div <div
className="bg-primary h-1.5 rounded-full transition-all" className="h-1.5 rounded-full bg-primary transition-all"
style={{ style={{
width: `${Math.min(Math.round((imp.processedEvents / imp.totalEvents) * 100), 100)}%`, width: `${Math.min(Math.round((imp.processedEvents / imp.totalEvents) * 100), 100)}%`,
}} }}
@@ -265,7 +265,7 @@ function ImportsSettings() {
<TableCell> <TableCell>
<Tooltiper <Tooltiper
content={ content={
<pre className="font-mono text-sm leading-normal whitespace-pre-wrap break-words"> <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-normal">
{JSON.stringify(imp.config, null, 2)} {JSON.stringify(imp.config, null, 2)}
</pre> </pre>
} }
@@ -274,20 +274,20 @@ function ImportsSettings() {
<Badge>Config</Badge> <Badge>Config</Badge>
</Tooltiper> </Tooltiper>
</TableCell> </TableCell>
<TableCell className="text-right space-x-2"> <TableCell className="space-x-2 text-right">
{imp.status === 'failed' && ( {imp.status === 'failed' && (
<Button <Button
variant="outline"
size="sm"
onClick={() => retryImport.mutate({ id: imp.id })} onClick={() => retryImport.mutate({ id: imp.id })}
size="sm"
variant="outline"
> >
Retry Retry
</Button> </Button>
)} )}
<Button <Button
variant="ghost"
size="sm"
onClick={() => deleteImport.mutate({ id: imp.id })} onClick={() => deleteImport.mutate({ id: imp.id })}
size="sm"
variant="ghost"
> >
Delete Delete
</Button> </Button>

View File

@@ -1,6 +1,6 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { LoginLeftPanel } from '@/components/login-left-panel';
import { LoginNavbar } from '@/components/login-navbar'; import { LoginNavbar } from '@/components/login-navbar';
import { OnboardingLeftPanel } from '@/components/onboarding-left-panel';
export const Route = createFileRoute('/_login')({ export const Route = createFileRoute('/_login')({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
@@ -16,7 +16,7 @@ function AuthLayout() {
<div className="relative grid min-h-screen md:grid-cols-2"> <div className="relative grid min-h-screen md:grid-cols-2">
<LoginNavbar /> <LoginNavbar />
<div className="hidden md:block"> <div className="hidden md:block">
<LoginLeftPanel /> <OnboardingLeftPanel />
</div> </div>
<div className="center-center mx-auto w-full max-w-md px-4"> <div className="center-center mx-auto w-full max-w-md px-4">
<Outlet /> <Outlet />

View File

@@ -57,11 +57,11 @@ function Component() {
<div className="col w-full gap-8 text-left"> <div className="col w-full gap-8 text-left">
<div> <div>
<h1 className="mb-2 font-bold text-3xl text-foreground"> <h1 className="mb-2 font-bold text-3xl text-foreground">
Create an account Start tracking in minutes
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Let's start with creating your account. By creating an account you Join 1,000+ projects already using OpenPanel. By creating an account
accept the{' '} you accept the{' '}
<a <a
className="underline transition-colors hover:text-foreground" className="underline transition-colors hover:text-foreground"
href="https://openpanel.dev/terms" href="https://openpanel.dev/terms"
@@ -111,6 +111,9 @@ function Component() {
<SignInGithub inviteId={inviteId} type="sign-up" /> <SignInGithub inviteId={inviteId} type="sign-up" />
<SignInGoogle inviteId={inviteId} type="sign-up" /> <SignInGoogle inviteId={inviteId} type="sign-up" />
</div> </div>
<p className="text-center text-muted-foreground text-xs">
No credit card required · Free 30-day trial · Cancel anytime
</p>
<Or className="my-6" /> <Or className="my-6" />