Improve rank tracking empty state and add config route (#112)

This commit is contained in:
Ben Senescu 2026-04-14 20:26:58 -04:00 committed by Ben Senescu
parent e68ced7203
commit 0024691da8
6 changed files with 234 additions and 129 deletions

View File

@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { AlertTriangle, Globe, Plus, ChevronRight } from "lucide-react";
import { getRankTrackingConfigSummaries } from "@/serverFunctions/rank-tracking";
@ -13,13 +14,12 @@ type ConfigSummary = Awaited<
export function RankTrackingDomainList({
projectId,
onSelectConfig,
onAddDomain,
}: {
projectId: string;
onSelectConfig: (configId: string) => void;
onAddDomain: () => void;
}) {
const navigate = useNavigate();
const { data: summaries } = useQuery({
queryKey: ["rankTrackingConfigSummaries", projectId],
queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }),
@ -31,7 +31,7 @@ export function RankTrackingDomainList({
<div className="flex items-center justify-between px-5 pt-4 pb-3">
<h2 className="text-sm font-semibold">Tracked Domains</h2>
<button
className="btn btn-primary btn-sm btn-outline gap-1"
className="btn btn-primary btn-sm gap-1"
onClick={onAddDomain}
>
<Plus className="size-3.5" />
@ -39,13 +39,32 @@ export function RankTrackingDomainList({
</button>
</div>
<div className="divide-y divide-base-300">
{(summaries ?? []).map((summary) => (
<DomainRow
key={summary.id}
summary={summary}
onClick={() => onSelectConfig(summary.id)}
/>
))}
{(summaries ?? []).length === 0 ? (
<div className="px-5 py-10 text-center space-y-2">
<div className="mx-auto flex size-10 items-center justify-center rounded-xl bg-base-200">
<Globe className="size-5 text-base-content/40" />
</div>
<p className="text-sm font-medium text-base-content/70">
No tracked domains yet
</p>
<p className="text-xs text-base-content/40">
Add a domain to start monitoring keyword rankings over time.
</p>
</div>
) : (
(summaries ?? []).map((summary) => (
<DomainRow
key={summary.id}
summary={summary}
onClick={() =>
void navigate({
to: "/p/$projectId/rank-tracking/$configId",
params: { projectId, configId: summary.id },
})
}
/>
))
)}
</div>
</div>
</div>

View File

@ -1,107 +0,0 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, TrendingUp } from "lucide-react";
import { getRankTrackingConfigs } from "@/serverFunctions/rank-tracking";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
import { RankTrackingDomainList } from "./RankTrackingDomainList";
import { RankTrackingDomainDetail } from "./RankTrackingDomainDetail";
import { RankTrackingConfigModal } from "./RankTrackingConfigModal";
export function RankTrackingPage({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const [selectedConfigId, setSelectedConfigId] = useState<string | null>(null);
const [showConfigModal, setShowConfigModal] = useState(false);
const [editingConfig, setEditingConfig] = useState<RankTrackingConfig | null>(
null,
);
const { data: configs, isLoading } = useQuery({
queryKey: ["rankTrackingConfigs", projectId],
queryFn: () => getRankTrackingConfigs({ data: { projectId } }),
});
const invalidateConfigs = () => {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigs", projectId],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigSummaries", projectId],
});
};
const selectedConfig =
configs?.find((c) => c.id === selectedConfigId) ?? null;
const openAddModal = () => {
setEditingConfig(null);
setShowConfigModal(true);
};
const openEditModal = (config: RankTrackingConfig) => {
setEditingConfig(config);
setShowConfigModal(true);
};
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Rank Tracking</h1>
<p className="text-sm text-base-content/70">
Track keyword positions across domains
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="size-6 animate-spin text-base-content/50" />
</div>
) : !configs || configs.length === 0 ? (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-8 text-center space-y-3">
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<TrendingUp className="size-6" />
</div>
<h2 className="text-lg font-medium text-base-content/80">
Track your keyword positions over time
</h2>
<p className="text-sm text-base-content/55 max-w-md mx-auto">
Monitor rankings for your saved keywords, see position changes,
and spot trends across desktop and mobile.
</p>
<button className="btn btn-primary btn-sm" onClick={openAddModal}>
Add Domain
</button>
<p className="text-xs text-base-content/50">
Requires saved keywords in this project.
</p>
</section>
) : selectedConfig ? (
<RankTrackingDomainDetail
config={selectedConfig}
projectId={projectId}
onBack={() => setSelectedConfigId(null)}
onEdit={() => openEditModal(selectedConfig)}
/>
) : (
<RankTrackingDomainList
projectId={projectId}
onSelectConfig={setSelectedConfigId}
onAddDomain={openAddModal}
/>
)}
</div>
{showConfigModal && (
<RankTrackingConfigModal
projectId={projectId}
existingConfig={editingConfig}
onClose={() => setShowConfigModal(false)}
onSaved={() => {
setShowConfigModal(false);
invalidateConfigs();
}}
/>
)}
</div>
);
}

View File

@ -34,7 +34,9 @@ import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
import { Route as ProjectPProjectIdAuditIssuesResultIdRouteImport } from './routes/_project/p/$projectId/audit/issues/$resultId'
const VerifyEmailRoute = VerifyEmailRouteImport.update({
@ -161,12 +163,24 @@ const ProjectPProjectIdAiRoute = ProjectPProjectIdAiRouteImport.update({
path: '/ai',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdRankTrackingIndexRoute =
ProjectPProjectIdRankTrackingIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ProjectPProjectIdRankTrackingRoute,
} as any)
const ProjectPProjectIdAuditIndexRoute =
ProjectPProjectIdAuditIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ProjectPProjectIdAuditRoute,
} as any)
const ProjectPProjectIdRankTrackingConfigIdRoute =
ProjectPProjectIdRankTrackingConfigIdRouteImport.update({
id: '/$configId',
path: '/$configId',
getParentRoute: () => ProjectPProjectIdRankTrackingRoute,
} as any)
const ProjectPProjectIdAuditIssuesResultIdRoute =
ProjectPProjectIdAuditIssuesResultIdRouteImport.update({
id: '/issues/$resultId',
@ -193,10 +207,12 @@ export interface FileRoutesByFullPath {
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
'/p/$projectId/rank-tracking/': typeof ProjectPProjectIdRankTrackingIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof ProjectPProjectIdAuditIssuesResultIdRoute
}
export interface FileRoutesByTo {
@ -216,10 +232,11 @@ export interface FileRoutesByTo {
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof ProjectPProjectIdAuditIssuesResultIdRoute
}
export interface FileRoutesById {
@ -246,10 +263,12 @@ export interface FileRoutesById {
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
'/_project/p/$projectId/rank-tracking/': typeof ProjectPProjectIdRankTrackingIndexRoute
'/_project/p/$projectId/audit/issues/$resultId': typeof ProjectPProjectIdAuditIssuesResultIdRoute
}
export interface FileRouteTypes {
@ -276,7 +295,9 @@ export interface FileRouteTypes {
| '/p/$projectId/rank-tracking'
| '/p/$projectId/saved'
| '/p/$projectId/'
| '/p/$projectId/rank-tracking/$configId'
| '/p/$projectId/audit/'
| '/p/$projectId/rank-tracking/'
| '/p/$projectId/audit/issues/$resultId'
fileRoutesByTo: FileRoutesByTo
to:
@ -296,10 +317,11 @@ export interface FileRouteTypes {
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/rank-tracking'
| '/p/$projectId/saved'
| '/p/$projectId'
| '/p/$projectId/rank-tracking/$configId'
| '/p/$projectId/audit'
| '/p/$projectId/rank-tracking'
| '/p/$projectId/audit/issues/$resultId'
id:
| '__root__'
@ -328,7 +350,9 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/saved'
| '/_project/p/$projectId/'
| '/_project/p/$projectId/rank-tracking/$configId'
| '/_project/p/$projectId/audit/'
| '/_project/p/$projectId/rank-tracking/'
| '/_project/p/$projectId/audit/issues/$resultId'
fileRoutesById: FileRoutesById
}
@ -521,6 +545,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdAiRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/rank-tracking/': {
id: '/_project/p/$projectId/rank-tracking/'
path: '/'
fullPath: '/p/$projectId/rank-tracking/'
preLoaderRoute: typeof ProjectPProjectIdRankTrackingIndexRouteImport
parentRoute: typeof ProjectPProjectIdRankTrackingRoute
}
'/_project/p/$projectId/audit/': {
id: '/_project/p/$projectId/audit/'
path: '/'
@ -528,6 +559,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdAuditIndexRouteImport
parentRoute: typeof ProjectPProjectIdAuditRoute
}
'/_project/p/$projectId/rank-tracking/$configId': {
id: '/_project/p/$projectId/rank-tracking/$configId'
path: '/$configId'
fullPath: '/p/$projectId/rank-tracking/$configId'
preLoaderRoute: typeof ProjectPProjectIdRankTrackingConfigIdRouteImport
parentRoute: typeof ProjectPProjectIdRankTrackingRoute
}
'/_project/p/$projectId/audit/issues/$resultId': {
id: '/_project/p/$projectId/audit/issues/$resultId'
path: '/issues/$resultId'
@ -573,13 +611,31 @@ const ProjectPProjectIdAuditRouteWithChildren =
ProjectPProjectIdAuditRouteChildren,
)
interface ProjectPProjectIdRankTrackingRouteChildren {
ProjectPProjectIdRankTrackingConfigIdRoute: typeof ProjectPProjectIdRankTrackingConfigIdRoute
ProjectPProjectIdRankTrackingIndexRoute: typeof ProjectPProjectIdRankTrackingIndexRoute
}
const ProjectPProjectIdRankTrackingRouteChildren: ProjectPProjectIdRankTrackingRouteChildren =
{
ProjectPProjectIdRankTrackingConfigIdRoute:
ProjectPProjectIdRankTrackingConfigIdRoute,
ProjectPProjectIdRankTrackingIndexRoute:
ProjectPProjectIdRankTrackingIndexRoute,
}
const ProjectPProjectIdRankTrackingRouteWithChildren =
ProjectPProjectIdRankTrackingRoute._addFileChildren(
ProjectPProjectIdRankTrackingRouteChildren,
)
interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdAiRoute: typeof ProjectPProjectIdAiRoute
ProjectPProjectIdAuditRoute: typeof ProjectPProjectIdAuditRouteWithChildren
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
}
@ -591,7 +647,8 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute,
ProjectPProjectIdRankTrackingRoute:
ProjectPProjectIdRankTrackingRouteWithChildren,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
}

View File

@ -1,11 +1,22 @@
import { createFileRoute } from "@tanstack/react-router";
import { RankTrackingPage } from "@/client/features/rank-tracking/RankTrackingPage";
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/_project/p/$projectId/rank-tracking")({
component: RankTrackingRoute,
component: RankTrackingLayout,
});
function RankTrackingRoute() {
const { projectId } = Route.useParams();
return <RankTrackingPage projectId={projectId} />;
function RankTrackingLayout() {
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Rank Tracking</h1>
<p className="text-sm text-base-content/70">
Track keyword positions across domains
</p>
</div>
<Outlet />
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getRankTrackingConfigs } from "@/serverFunctions/rank-tracking";
import { RankTrackingDomainDetail } from "@/client/features/rank-tracking/RankTrackingDomainDetail";
import { RankTrackingConfigModal } from "@/client/features/rank-tracking/RankTrackingConfigModal";
export const Route = createFileRoute(
"/_project/p/$projectId/rank-tracking/$configId",
)({
component: RankTrackingConfigRoute,
});
function RankTrackingConfigRoute() {
const { projectId, configId } = Route.useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showConfigModal, setShowConfigModal] = useState(false);
const { data: configs, isLoading } = useQuery({
queryKey: ["rankTrackingConfigs", projectId],
queryFn: () => getRankTrackingConfigs({ data: { projectId } }),
});
const config = configs?.find((c) => c.id === configId) ?? null;
const invalidateConfigs = () => {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigs", projectId],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigSummaries", projectId],
});
};
const handleBack = () => {
void navigate({
to: "/p/$projectId/rank-tracking",
params: { projectId },
});
};
if (isLoading) return null;
if (!config) {
return (
<>
<p className="text-sm text-base-content/70">
Domain configuration not found.
</p>
<button className="btn btn-ghost btn-sm" onClick={handleBack}>
Back to domains
</button>
</>
);
}
return (
<>
<RankTrackingDomainDetail
config={config}
projectId={projectId}
onBack={handleBack}
onEdit={() => setShowConfigModal(true)}
/>
{showConfigModal && (
<RankTrackingConfigModal
projectId={projectId}
existingConfig={config}
onClose={() => setShowConfigModal(false)}
onSaved={() => {
setShowConfigModal(false);
invalidateConfigs();
}}
/>
)}
</>
);
}

View File

@ -0,0 +1,45 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { RankTrackingDomainList } from "@/client/features/rank-tracking/RankTrackingDomainList";
import { RankTrackingConfigModal } from "@/client/features/rank-tracking/RankTrackingConfigModal";
export const Route = createFileRoute("/_project/p/$projectId/rank-tracking/")({
component: RankTrackingIndex,
});
function RankTrackingIndex() {
const { projectId } = Route.useParams();
const queryClient = useQueryClient();
const [showConfigModal, setShowConfigModal] = useState(false);
const invalidateConfigs = () => {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigs", projectId],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigSummaries", projectId],
});
};
return (
<>
<RankTrackingDomainList
projectId={projectId}
onAddDomain={() => setShowConfigModal(true)}
/>
{showConfigModal && (
<RankTrackingConfigModal
projectId={projectId}
existingConfig={null}
onClose={() => setShowConfigModal(false)}
onSaved={() => {
setShowConfigModal(false);
invalidateConfigs();
}}
/>
)}
</>
);
}