fix(dashboard): pagination and login
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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> }) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user