fix: add search for Opportunities and Cannibalization
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import type { IChartRange, IInterval } from '@openpanel/validation';
|
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react';
|
import { AlertCircleIcon, ChevronsUpDownIcon, SearchIcon } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Pagination } from '@/components/pagination';
|
import { Pagination } from '@/components/pagination';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { useAppContext } from '@/hooks/use-app-context';
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
@@ -27,6 +28,7 @@ export function GscCannibalization({
|
|||||||
const { apiUrl } = useAppContext();
|
const { apiUrl } = useAppContext();
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const pageSize = 15;
|
const pageSize = 15;
|
||||||
|
|
||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
@@ -54,7 +56,19 @@ export function GscCannibalization({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = query.data ?? [];
|
const allItems = query.data ?? [];
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
if (!search.trim()) {
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return allItems.filter(
|
||||||
|
(item) =>
|
||||||
|
item.query.toLowerCase().includes(q) ||
|
||||||
|
item.pages.some((p) => p.page.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [allItems, search]);
|
||||||
|
|
||||||
const pageCount = Math.ceil(items.length / pageSize) || 1;
|
const pageCount = Math.ceil(items.length / pageSize) || 1;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,37 +81,52 @@ export function GscCannibalization({
|
|||||||
const rangeStart = items.length ? page * pageSize + 1 : 0;
|
const rangeStart = items.length ? page * pageSize + 1 : 0;
|
||||||
const rangeEnd = Math.min((page + 1) * pageSize, items.length);
|
const rangeEnd = Math.min((page + 1) * pageSize, items.length);
|
||||||
|
|
||||||
if (!(query.isLoading || items.length)) {
|
if (!(query.isLoading || allItems.length)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card overflow-hidden">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
<div className="border-b">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{items.length}
|
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||||
</span>
|
{items.length === 0
|
||||||
|
? '0 results'
|
||||||
|
: `${rangeStart}-${rangeEnd} of ${items.length}`}
|
||||||
|
</span>
|
||||||
|
<Pagination
|
||||||
|
canNextPage={page < pageCount - 1}
|
||||||
|
canPreviousPage={page > 0}
|
||||||
|
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||||
|
pageIndex={page}
|
||||||
|
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{items.length > 0 && (
|
<div className="relative">
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
<Input
|
||||||
{items.length === 0
|
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||||
? '0 results'
|
onChange={(e) => {
|
||||||
: `${rangeStart}-${rangeEnd} of ${items.length}`}
|
setSearch(e.target.value);
|
||||||
</span>
|
setPage(0);
|
||||||
<Pagination
|
}}
|
||||||
canNextPage={page < pageCount - 1}
|
placeholder="Search keywords"
|
||||||
canPreviousPage={page > 0}
|
type="search"
|
||||||
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
value={search}
|
||||||
pageIndex={page}
|
/>
|
||||||
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{query.isLoading &&
|
{query.isLoading &&
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import {
|
|||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
MousePointerClickIcon,
|
MousePointerClickIcon,
|
||||||
|
SearchIcon,
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { Pagination } from '@/components/pagination';
|
import { Pagination } from '@/components/pagination';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { useAppContext } from '@/hooks/use-app-context';
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
@@ -69,6 +71,7 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
|||||||
const { range, interval, startDate, endDate } = useOverviewOptions();
|
const { range, interval, startDate, endDate } = useOverviewOptions();
|
||||||
const { apiUrl } = useAppContext();
|
const { apiUrl } = useAppContext();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const pageSize = 8;
|
const pageSize = 8;
|
||||||
|
|
||||||
const dateInput = {
|
const dateInput = {
|
||||||
@@ -217,45 +220,71 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
|||||||
|
|
||||||
const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading;
|
const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading;
|
||||||
|
|
||||||
const pageCount = Math.ceil(insights.length / pageSize) || 1;
|
const filteredInsights = useMemo(() => {
|
||||||
const paginatedInsights = useMemo(
|
if (!search.trim()) {
|
||||||
() => insights.slice(page * pageSize, (page + 1) * pageSize),
|
return insights;
|
||||||
[insights, page, pageSize]
|
}
|
||||||
);
|
const q = search.toLowerCase();
|
||||||
const rangeStart = insights.length ? page * pageSize + 1 : 0;
|
return insights.filter(
|
||||||
const rangeEnd = Math.min((page + 1) * pageSize, insights.length);
|
(i) =>
|
||||||
|
i.path.toLowerCase().includes(q) || i.page.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [insights, search]);
|
||||||
|
|
||||||
if (!isLoading && !insights.length) {
|
const pageCount = Math.ceil(filteredInsights.length / pageSize) || 1;
|
||||||
|
const paginatedInsights = useMemo(
|
||||||
|
() => filteredInsights.slice(page * pageSize, (page + 1) * pageSize),
|
||||||
|
[filteredInsights, page, pageSize]
|
||||||
|
);
|
||||||
|
const rangeStart = filteredInsights.length ? page * pageSize + 1 : 0;
|
||||||
|
const rangeEnd = Math.min((page + 1) * pageSize, filteredInsights.length);
|
||||||
|
|
||||||
|
if (!(isLoading || insights.length)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card overflow-hidden">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
<div className="border-b">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<h3 className="font-medium text-sm">Opportunities</h3>
|
<div className="flex items-center gap-2">
|
||||||
{insights.length > 0 && (
|
<h3 className="font-medium text-sm">Opportunities</h3>
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
{filteredInsights.length > 0 && (
|
||||||
{insights.length}
|
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||||
</span>
|
{filteredInsights.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filteredInsights.length > 0 && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||||
|
{filteredInsights.length === 0
|
||||||
|
? '0 results'
|
||||||
|
: `${rangeStart}-${rangeEnd} of ${filteredInsights.length}`}
|
||||||
|
</span>
|
||||||
|
<Pagination
|
||||||
|
canNextPage={page < pageCount - 1}
|
||||||
|
canPreviousPage={page > 0}
|
||||||
|
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||||
|
pageIndex={page}
|
||||||
|
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{insights.length > 0 && (
|
<div className="relative">
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
<Input
|
||||||
{insights.length === 0
|
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||||
? '0 results'
|
onChange={(e) => {
|
||||||
: `${rangeStart}-${rangeEnd} of ${insights.length}`}
|
setSearch(e.target.value);
|
||||||
</span>
|
setPage(0);
|
||||||
<Pagination
|
}}
|
||||||
canNextPage={page < pageCount - 1}
|
placeholder="Search pages"
|
||||||
canPreviousPage={page > 0}
|
type="search"
|
||||||
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
value={search}
|
||||||
pageIndex={page}
|
/>
|
||||||
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{isLoading &&
|
{isLoading &&
|
||||||
@@ -287,6 +316,16 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="col min-w-0 flex-1 gap-2">
|
<div className="col min-w-0 flex-1 gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'row shrink-0 items-center gap-1 self-start rounded-md px-1 py-0.5 font-medium text-xs',
|
||||||
|
config.color,
|
||||||
|
config.bg
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
alt=""
|
alt=""
|
||||||
@@ -301,15 +340,8 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
|||||||
{insight.path || insight.page}
|
{insight.path || insight.page}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span className="ml-auto shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||||
className={cn(
|
{insight.metrics}
|
||||||
'row shrink-0 items-center gap-1 rounded-md px-1 py-0.5 font-medium text-xs',
|
|
||||||
config.color,
|
|
||||||
config.bg
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-3" />
|
|
||||||
{config.label}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
@@ -319,10 +351,6 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
|||||||
{insight.suggestion}
|
{insight.suggestion}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
|
||||||
{insight.metrics}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -732,10 +732,10 @@ function GscTable({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onSearchChange != null && (
|
{onSearchChange != null && (
|
||||||
<div className="relative border-t">
|
<div className="relative">
|
||||||
<SearchIcon className="absolute top-1/2 left-3 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
|
<Input
|
||||||
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"
|
className="rounded-none border-0 border-t 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)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder={searchPlaceholder ?? 'Search'}
|
placeholder={searchPlaceholder ?? 'Search'}
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
Reference in New Issue
Block a user