fix: add search for Opportunities and Cannibalization

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-11 11:30:19 +01:00
parent 6e1daf2c76
commit 05cf6bb39f
3 changed files with 130 additions and 73 deletions

View File

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

View File

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

View File

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