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 { 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 { Pagination } from '@/components/pagination';
import { Input } from '@/components/ui/input';
import { useAppContext } from '@/hooks/use-app-context';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -27,6 +28,7 @@ export function GscCannibalization({
const { apiUrl } = useAppContext();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [page, setPage] = useState(0);
const [search, setSearch] = useState('');
const pageSize = 15;
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;
useEffect(() => {
@@ -67,13 +81,14 @@ export function GscCannibalization({
const rangeStart = items.length ? page * pageSize + 1 : 0;
const rangeEnd = Math.min((page + 1) * pageSize, items.length);
if (!(query.isLoading || items.length)) {
if (!(query.isLoading || allItems.length)) {
return null;
}
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
{items.length > 0 && (
@@ -99,6 +114,20 @@ export function GscCannibalization({
</div>
)}
</div>
<div className="relative">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
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) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search keywords"
type="search"
value={search}
/>
</div>
</div>
<div className="divide-y">
{query.isLoading &&
[1, 2, 3].map((i) => (

View File

@@ -3,11 +3,13 @@ import {
AlertTriangleIcon,
EyeIcon,
MousePointerClickIcon,
SearchIcon,
TrendingUpIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Pagination } from '@/components/pagination';
import { Input } from '@/components/ui/input';
import { useAppContext } from '@/hooks/use-app-context';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -69,6 +71,7 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
const { range, interval, startDate, endDate } = useOverviewOptions();
const { apiUrl } = useAppContext();
const [page, setPage] = useState(0);
const [search, setSearch] = useState('');
const pageSize = 8;
const dateInput = {
@@ -217,35 +220,47 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading;
const pageCount = Math.ceil(insights.length / pageSize) || 1;
const paginatedInsights = useMemo(
() => insights.slice(page * pageSize, (page + 1) * pageSize),
[insights, page, pageSize]
const filteredInsights = useMemo(() => {
if (!search.trim()) {
return insights;
}
const q = search.toLowerCase();
return insights.filter(
(i) =>
i.path.toLowerCase().includes(q) || i.page.toLowerCase().includes(q)
);
const rangeStart = insights.length ? page * pageSize + 1 : 0;
const rangeEnd = Math.min((page + 1) * pageSize, insights.length);
}, [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 (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">Opportunities</h3>
{insights.length > 0 && (
{filteredInsights.length > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
{insights.length}
{filteredInsights.length}
</span>
)}
</div>
{insights.length > 0 && (
{filteredInsights.length > 0 && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{insights.length === 0
{filteredInsights.length === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${insights.length}`}
: `${rangeStart}-${rangeEnd} of ${filteredInsights.length}`}
</span>
<Pagination
canNextPage={page < pageCount - 1}
@@ -257,6 +272,20 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
</div>
)}
</div>
<div className="relative">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
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) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search pages"
type="search"
value={search}
/>
</div>
</div>
<div className="divide-y">
{isLoading &&
[1, 2, 3, 4].map((i) => (
@@ -287,6 +316,16 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
type="button"
>
<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">
<img
alt=""
@@ -301,15 +340,8 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
{insight.path || insight.page}
</span>
<span
className={cn(
'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 className="ml-auto shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
{insight.metrics}
</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
@@ -319,10 +351,6 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
{insight.suggestion}
</p>
</div>
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
{insight.metrics}
</span>
</button>
);
})}

View File

@@ -732,10 +732,10 @@ function GscTable({
)}
</div>
{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" />
<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)}
placeholder={searchPlaceholder ?? 'Search'}
type="search"