Project dashboard: onboarding checklist + domain overview (#398)
This commit is contained in:
parent
040282511a
commit
eda5e1e354
33
drizzle-pg/0012_dashboard.sql
Normal file
33
drizzle-pg/0012_dashboard.sql
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE "backlink_snapshots" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"project_id" text NOT NULL,
|
||||||
|
"domain" text NOT NULL,
|
||||||
|
"rank" integer,
|
||||||
|
"backlinks" integer,
|
||||||
|
"referring_domains" integer,
|
||||||
|
"broken_backlinks" integer,
|
||||||
|
"new_backlinks" integer,
|
||||||
|
"lost_backlinks" integer,
|
||||||
|
"new_referring_domains" integer,
|
||||||
|
"lost_referring_domains" integer,
|
||||||
|
"captured_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "organization_activation_state" (
|
||||||
|
"organization_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"first_mcp_authorized_at" text,
|
||||||
|
"first_mcp_tool_call_at" text,
|
||||||
|
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "project_activation_state" (
|
||||||
|
"project_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"competitor_step_clicked_at" text,
|
||||||
|
"mcp_card_dismissed_at" text,
|
||||||
|
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "backlink_snapshots" ADD CONSTRAINT "backlink_snapshots_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "organization_activation_state" ADD CONSTRAINT "organization_activation_state_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "project_activation_state" ADD CONSTRAINT "project_activation_state_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "backlink_snapshots_project_captured_idx" ON "backlink_snapshots" USING btree ("project_id","captured_at");
|
||||||
3708
drizzle-pg/meta/0012_snapshot.json
Normal file
3708
drizzle-pg/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -85,6 +85,13 @@
|
|||||||
"when": 1784410131656,
|
"when": 1784410131656,
|
||||||
"tag": "0011_friendly_morlun",
|
"tag": "0011_friendly_morlun",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784423181488,
|
||||||
|
"tag": "0012_dashboard",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
32
drizzle/0035_dashboard.sql
Normal file
32
drizzle/0035_dashboard.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
CREATE TABLE `backlink_snapshots` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`domain` text NOT NULL,
|
||||||
|
`rank` integer,
|
||||||
|
`backlinks` integer,
|
||||||
|
`referring_domains` integer,
|
||||||
|
`broken_backlinks` integer,
|
||||||
|
`new_backlinks` integer,
|
||||||
|
`lost_backlinks` integer,
|
||||||
|
`new_referring_domains` integer,
|
||||||
|
`lost_referring_domains` integer,
|
||||||
|
`captured_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `backlink_snapshots_project_captured_idx` ON `backlink_snapshots` (`project_id`,`captured_at`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `organization_activation_state` (
|
||||||
|
`organization_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`first_mcp_authorized_at` text,
|
||||||
|
`first_mcp_tool_call_at` text,
|
||||||
|
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `project_activation_state` (
|
||||||
|
`project_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`competitor_step_clicked_at` text,
|
||||||
|
`mcp_card_dismissed_at` text,
|
||||||
|
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
3373
drizzle/meta/0035_snapshot.json
Normal file
3373
drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -246,6 +246,13 @@
|
|||||||
"when": 1784410130115,
|
"when": 1784410130115,
|
||||||
"tag": "0034_wonderful_skrulls",
|
"tag": "0034_wonderful_skrulls",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1784423179842,
|
||||||
|
"tag": "0035_dashboard",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -57,9 +57,9 @@ function SidebarNavLink({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
{...linkProps}
|
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
activeOptions={{ exact: false, includeSearch: false }}
|
activeOptions={{ exact: false, includeSearch: false }}
|
||||||
|
{...linkProps}
|
||||||
className={navItemClass}
|
className={navItemClass}
|
||||||
activeProps={navItemActiveProps}
|
activeProps={navItemActiveProps}
|
||||||
>
|
>
|
||||||
@ -109,6 +109,15 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Coming back from Chat, land on the dashboard rather than leaving the
|
||||||
|
// conversation filling the content panel next to a Browse nav.
|
||||||
|
const openBrowse = () => {
|
||||||
|
setView("browse");
|
||||||
|
if (!projectId || !onSamRoute) return;
|
||||||
|
void navigate({ to: "/p/$projectId", params: { projectId } });
|
||||||
|
onNavigate?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-60 flex-col bg-base-200">
|
<div className="flex h-full w-60 flex-col bg-base-200">
|
||||||
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
||||||
@ -147,7 +156,7 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
|
|||||||
icon={LayoutGrid}
|
icon={LayoutGrid}
|
||||||
label="Browse"
|
label="Browse"
|
||||||
active={view === "browse"}
|
active={view === "browse"}
|
||||||
onClick={() => setView("browse")}
|
onClick={openBrowse}
|
||||||
/>
|
/>
|
||||||
<SidebarViewTab
|
<SidebarViewTab
|
||||||
icon={MessageCircle}
|
icon={MessageCircle}
|
||||||
|
|||||||
356
src/client/features/dashboard/DashboardCards.tsx
Normal file
356
src/client/features/dashboard/DashboardCards.tsx
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
|
||||||
|
import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatCount,
|
||||||
|
formatCtr,
|
||||||
|
formatPosition,
|
||||||
|
} from "@/client/features/search-performance/SearchPerformanceColumns";
|
||||||
|
import { getSearchPerformanceReport } from "@/serverFunctions/searchPerformance";
|
||||||
|
import {
|
||||||
|
CardShell,
|
||||||
|
EmptyCardBody,
|
||||||
|
formatDay,
|
||||||
|
moreDetailsClass,
|
||||||
|
newLost,
|
||||||
|
PercentDelta,
|
||||||
|
Stat,
|
||||||
|
} from "@/client/features/dashboard/cardParts";
|
||||||
|
import type {
|
||||||
|
DashboardAuditSummary,
|
||||||
|
DashboardBacklinkSummary,
|
||||||
|
DashboardRankSummary,
|
||||||
|
} from "@/server/features/dashboard/services/DashboardService";
|
||||||
|
|
||||||
|
// Plain string-keyed view of the registry: issue types from the DB are not
|
||||||
|
// statically guaranteed to be registry keys.
|
||||||
|
const issueTitles: Record<string, string | undefined> = Object.fromEntries(
|
||||||
|
Object.entries(AUDIT_ISSUE_TYPES).map(([key, value]) => [key, value.title]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function GscCard({
|
||||||
|
projectId,
|
||||||
|
connected,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
connected: boolean;
|
||||||
|
}) {
|
||||||
|
const reportQuery = useQuery({
|
||||||
|
queryKey: ["dashboardGscReport", projectId],
|
||||||
|
queryFn: () =>
|
||||||
|
getSearchPerformanceReport({
|
||||||
|
data: { projectId, dateRange: "last_28_days" },
|
||||||
|
}),
|
||||||
|
enabled: connected,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not connected (or a dead grant discovered by the report call): the
|
||||||
|
// connection card sells and runs the whole flow itself.
|
||||||
|
if (!connected || (reportQuery.data && !reportQuery.data.connected)) {
|
||||||
|
return (
|
||||||
|
<div id="connect-gsc">
|
||||||
|
<SearchConsoleConnectionCard projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = reportQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
title="Search performance"
|
||||||
|
stamp="Google Search Console · last 28 days"
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/search-performance"
|
||||||
|
params={{ projectId }}
|
||||||
|
className={moreDetailsClass}
|
||||||
|
>
|
||||||
|
More details
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reportQuery.isPending ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3" aria-busy>
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div key={i} className="skeleton h-20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : reportQuery.isError ? (
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
Couldn’t load Search Console data. Try again shortly.
|
||||||
|
</p>
|
||||||
|
) : report?.connected ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Stat
|
||||||
|
label="Clicks"
|
||||||
|
value={formatCount(report.totals.clicks)}
|
||||||
|
sub={
|
||||||
|
<PercentDelta
|
||||||
|
current={report.totals.clicks}
|
||||||
|
previous={report.prevTotals.clicks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Impressions"
|
||||||
|
value={formatCount(report.totals.impressions)}
|
||||||
|
sub={
|
||||||
|
<PercentDelta
|
||||||
|
current={report.totals.impressions}
|
||||||
|
previous={report.prevTotals.impressions}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat label="CTR" value={formatCtr(report.totals.ctr)} />
|
||||||
|
<Stat
|
||||||
|
label="Avg position"
|
||||||
|
value={formatPosition(report.totals.position)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RankMovementCard({
|
||||||
|
projectId,
|
||||||
|
rank,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
rank: DashboardRankSummary | null;
|
||||||
|
}) {
|
||||||
|
if (!rank) {
|
||||||
|
return (
|
||||||
|
<CardShell title="Rank tracking">
|
||||||
|
<EmptyCardBody
|
||||||
|
message="Track your money keywords and watch your ranking improve over time."
|
||||||
|
cta={
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/rank-tracking"
|
||||||
|
params={{ projectId }}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
Track your first keyword
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
title="Rank movement"
|
||||||
|
stamp={
|
||||||
|
rank.lastCheckedAt
|
||||||
|
? `Rank data · checked ${formatDay(rank.lastCheckedAt)} · vs 7 days ago`
|
||||||
|
: "Rank data · no completed checks yet"
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/rank-tracking"
|
||||||
|
params={{ projectId }}
|
||||||
|
className={moreDetailsClass}
|
||||||
|
>
|
||||||
|
More details
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Stat label="Tracked" value={String(rank.trackedKeywords)} />
|
||||||
|
<Stat
|
||||||
|
label="Improved"
|
||||||
|
value={`▲ ${rank.improved}`}
|
||||||
|
tone={rank.improved > 0 ? "success" : undefined}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Declined"
|
||||||
|
value={`▼ ${rank.declined}`}
|
||||||
|
tone={rank.declined > 0 ? "error" : undefined}
|
||||||
|
/>
|
||||||
|
<Stat label="Top 10" value={String(rank.top10)} />
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditHealthCard({
|
||||||
|
projectId,
|
||||||
|
audit,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
audit: DashboardAuditSummary | null;
|
||||||
|
}) {
|
||||||
|
if (!audit) {
|
||||||
|
return (
|
||||||
|
<CardShell title="Site audit">
|
||||||
|
<EmptyCardBody
|
||||||
|
message="Crawl your site for broken links, missing tags and indexability problems."
|
||||||
|
cta={
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/audit"
|
||||||
|
params={{ projectId }}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
Run an audit
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
title="Site audit"
|
||||||
|
stamp={`Site audit · ${
|
||||||
|
audit.status === "completed"
|
||||||
|
? `crawled ${audit.pagesCrawled} pages · ${formatDay(audit.startedAt)}`
|
||||||
|
: audit.status === "running"
|
||||||
|
? "crawl in progress"
|
||||||
|
: "last crawl failed"
|
||||||
|
}`}
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/audit"
|
||||||
|
params={{ projectId }}
|
||||||
|
className={moreDetailsClass}
|
||||||
|
>
|
||||||
|
More details
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{audit.topIssues.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
|
<Check className="size-4 text-success" />
|
||||||
|
No issues found — your site looks healthy.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{audit.topIssues.map((issue) => (
|
||||||
|
<li
|
||||||
|
key={issue.issueType}
|
||||||
|
className="flex items-center justify-between gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`size-2 shrink-0 rounded-full ${
|
||||||
|
issue.severity === "critical"
|
||||||
|
? "bg-error"
|
||||||
|
: issue.severity === "warning"
|
||||||
|
? "bg-warning"
|
||||||
|
: "bg-base-content/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{issueTitles[issue.issueType] ?? issue.issueType}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-base-content/60">
|
||||||
|
{issue.count} {issue.count === 1 ? "page" : "pages"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{audit.totalIssueTypes > audit.topIssues.length ? (
|
||||||
|
<li className="text-xs text-base-content/50">
|
||||||
|
+ {audit.totalIssueTypes - audit.topIssues.length} more issue
|
||||||
|
{audit.totalIssueTypes - audit.topIssues.length === 1 ? "" : "s"}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BacklinkPulseCard({
|
||||||
|
projectId,
|
||||||
|
backlinks,
|
||||||
|
refreshing,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
backlinks: DashboardBacklinkSummary | null;
|
||||||
|
refreshing: boolean;
|
||||||
|
}) {
|
||||||
|
if (!backlinks && refreshing) {
|
||||||
|
return (
|
||||||
|
<CardShell title="Backlink pulse" stamp="Taking your first snapshot…">
|
||||||
|
<div className="grid grid-cols-2 gap-3" aria-busy>
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div key={i} className="skeleton h-20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backlinks) {
|
||||||
|
return (
|
||||||
|
<CardShell title="Backlink pulse">
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
We’ll snapshot who links to your domain — nothing to set up.
|
||||||
|
</p>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
title="Backlink pulse"
|
||||||
|
stamp={`Backlinks · snapshot ${formatDay(backlinks.capturedAt)}${
|
||||||
|
refreshing ? " · refreshing…" : ""
|
||||||
|
}`}
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/backlinks"
|
||||||
|
params={{ projectId }}
|
||||||
|
search={{ target: backlinks.domain, scope: "domain" }}
|
||||||
|
className={moreDetailsClass}
|
||||||
|
>
|
||||||
|
More details
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Stat
|
||||||
|
label="Ref. domains"
|
||||||
|
value={
|
||||||
|
backlinks.referringDomains === null
|
||||||
|
? "—"
|
||||||
|
: backlinks.referringDomains.toLocaleString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Backlinks"
|
||||||
|
value={
|
||||||
|
backlinks.backlinks === null
|
||||||
|
? "—"
|
||||||
|
: backlinks.backlinks.toLocaleString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="New links"
|
||||||
|
value={`▲ ${newLost(backlinks.newBacklinks)}`}
|
||||||
|
tone={
|
||||||
|
backlinks.newBacklinks && backlinks.newBacklinks > 0
|
||||||
|
? "success"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Lost links"
|
||||||
|
value={`▼ ${newLost(backlinks.lostBacklinks)}`}
|
||||||
|
tone={
|
||||||
|
backlinks.lostBacklinks && backlinks.lostBacklinks > 0
|
||||||
|
? "error"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
376
src/client/features/dashboard/DashboardPage.tsx
Normal file
376
src/client/features/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import {
|
||||||
|
computeNextStep,
|
||||||
|
isStepDone,
|
||||||
|
STEP_ORDER,
|
||||||
|
} from "@/client/features/dashboard/dashboardSteps";
|
||||||
|
import {
|
||||||
|
AuditHealthCard,
|
||||||
|
BacklinkPulseCard,
|
||||||
|
GscCard,
|
||||||
|
RankMovementCard,
|
||||||
|
} from "@/client/features/dashboard/DashboardCards";
|
||||||
|
import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
|
||||||
|
import {
|
||||||
|
getDashboardActivation,
|
||||||
|
getDashboardOverview,
|
||||||
|
markDashboardCompetitorClicked,
|
||||||
|
refreshDashboardBacklinkSnapshot,
|
||||||
|
} from "@/serverFunctions/dashboard";
|
||||||
|
import { setProjectDomain } from "@/serverFunctions/projects";
|
||||||
|
import type { DashboardHeroStep } from "@/types/schemas/dashboard";
|
||||||
|
|
||||||
|
const HERO_COPY: Record<
|
||||||
|
DashboardHeroStep,
|
||||||
|
{ title: string; body: string; cta: string }
|
||||||
|
> = {
|
||||||
|
domain: {
|
||||||
|
title: "What site are you working on?",
|
||||||
|
body: "Set your project's domain and every card on this page starts working for it — backlinks, audits, rank tracking.",
|
||||||
|
cta: "Save",
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
title: "Connect your AI agent",
|
||||||
|
body: "OpenSEO is built to be used from agents like Claude. Connect once, then ask it to use OpenSEO to help build your SEO strategy.",
|
||||||
|
cta: "Show me how",
|
||||||
|
},
|
||||||
|
gsc: {
|
||||||
|
title: "Connect Search Console",
|
||||||
|
body: "Your real queries and clicks, straight from Google.",
|
||||||
|
cta: "Connect",
|
||||||
|
},
|
||||||
|
competitor: {
|
||||||
|
title: "Size up a competitor",
|
||||||
|
body: "Paste a competitor's domain to see what they rank for and who links to them.",
|
||||||
|
cta: "Open domain lookup",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function scrollToCard(id: string) {
|
||||||
|
document.getElementById(id)?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users paste full URLs; store the bare host like settings expects.
|
||||||
|
function normalizeDomainInput(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/^https?:\/\//i, "")
|
||||||
|
.replace(/\/.*$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardingChecklist({
|
||||||
|
projectId,
|
||||||
|
activation,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
activation: DashboardActivation;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [domainInput, setDomainInput] = useState("");
|
||||||
|
// null = follow the first actionable step; set once the user pages with ‹ ›.
|
||||||
|
const [viewedIndex, setViewedIndex] = useState<number | null>(null);
|
||||||
|
const invalidateActivation = () =>
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardActivation", projectId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const competitorClickMutation = useMutation({
|
||||||
|
mutationFn: () => markDashboardCompetitorClicked({ data: { projectId } }),
|
||||||
|
onSuccess: invalidateActivation,
|
||||||
|
});
|
||||||
|
const domainMutation = useMutation({
|
||||||
|
mutationFn: (domain: string) =>
|
||||||
|
setProjectDomain({ data: { projectId, domain } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateActivation();
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardOverview", projectId],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(
|
||||||
|
getStandardErrorMessage(error, "Couldn't save the domain. Try again."),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hidden once every step is done.
|
||||||
|
const nextStep = computeNextStep(activation);
|
||||||
|
if (!nextStep) return null;
|
||||||
|
|
||||||
|
const index = viewedIndex ?? STEP_ORDER.indexOf(nextStep);
|
||||||
|
const step = STEP_ORDER[index];
|
||||||
|
const copy = HERO_COPY[step];
|
||||||
|
const done = isStepDone(activation, step);
|
||||||
|
|
||||||
|
const page = (delta: number) =>
|
||||||
|
setViewedIndex(Math.min(Math.max(index + delta, 0), STEP_ORDER.length - 1));
|
||||||
|
|
||||||
|
const onSubmitDomain = () => {
|
||||||
|
const domain = normalizeDomainInput(domainInput);
|
||||||
|
if (!domain) return;
|
||||||
|
captureClientEvent("dashboard:next_move_click", { step: "domain" });
|
||||||
|
domainMutation.mutate(domain);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only the gsc/competitor steps use the fallback CTA button — domain
|
||||||
|
// renders an inline form and mcp renders a Link.
|
||||||
|
const onCta = () => {
|
||||||
|
captureClientEvent("dashboard:next_move_click", { step });
|
||||||
|
if (step === "gsc") {
|
||||||
|
scrollToCard("connect-gsc");
|
||||||
|
} else if (step === "competitor") {
|
||||||
|
competitorClickMutation.mutate();
|
||||||
|
void navigate({ to: "/p/$projectId/domain", params: { projectId } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-primary/25 bg-primary/5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 px-5 pt-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-primary">
|
||||||
|
Onboarding checklist
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-ghost btn-xs btn-square ${
|
||||||
|
index === 0 ? "invisible" : ""
|
||||||
|
}`}
|
||||||
|
aria-label="Previous step"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => page(-1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs tabular-nums text-base-content/60">
|
||||||
|
{index + 1} / {STEP_ORDER.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-ghost btn-xs btn-square ${
|
||||||
|
index === STEP_ORDER.length - 1 ? "invisible" : ""
|
||||||
|
}`}
|
||||||
|
aria-label="Next step"
|
||||||
|
disabled={index === STEP_ORDER.length - 1}
|
||||||
|
onClick={() => page(1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row flex-wrap items-center justify-between gap-4 p-5 pt-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold">{copy.title}</h2>
|
||||||
|
<p className="mt-1 max-w-xl text-sm text-base-content/70">
|
||||||
|
{copy.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-3">
|
||||||
|
{done ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-success">
|
||||||
|
<Check className="size-4" />
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
) : step === "domain" ? (
|
||||||
|
<form
|
||||||
|
className="join"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmitDomain();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered join-item w-52"
|
||||||
|
placeholder="acme.com"
|
||||||
|
value={domainInput}
|
||||||
|
onChange={(event) => setDomainInput(event.target.value)}
|
||||||
|
aria-label="Your site's domain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary join-item"
|
||||||
|
disabled={
|
||||||
|
domainMutation.isPending ||
|
||||||
|
normalizeDomainInput(domainInput) === ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copy.cta}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : step === "mcp" ? (
|
||||||
|
<Link
|
||||||
|
to="/ai"
|
||||||
|
className="link link-primary text-sm font-medium"
|
||||||
|
onClick={() =>
|
||||||
|
captureClientEvent("dashboard:next_move_click", { step })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copy.cta} →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={onCta}>
|
||||||
|
{copy.cta}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage({ projectId }: { projectId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const activationQuery = useQuery({
|
||||||
|
queryKey: ["dashboardActivation", projectId],
|
||||||
|
queryFn: () => getDashboardActivation({ data: { projectId } }),
|
||||||
|
});
|
||||||
|
const overviewQuery = useQuery({
|
||||||
|
queryKey: ["dashboardOverview", projectId],
|
||||||
|
queryFn: () => getDashboardOverview({ data: { projectId } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activation = activationQuery.data;
|
||||||
|
const overview = overviewQuery.data;
|
||||||
|
|
||||||
|
// Visit-triggered backlink snapshot: fire once per page view when the
|
||||||
|
// overview reports a missing or stale snapshot for a project with a domain.
|
||||||
|
// The server re-checks freshness, so a stray double-fire costs nothing.
|
||||||
|
const refreshMutation = useMutation({
|
||||||
|
mutationFn: () => refreshDashboardBacklinkSnapshot({ data: { projectId } }),
|
||||||
|
onSuccess: () =>
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardOverview", projectId],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const refreshFiredRef = useRef(false);
|
||||||
|
const needsSnapshot =
|
||||||
|
activation?.domain != null &&
|
||||||
|
overview !== undefined &&
|
||||||
|
(overview.backlinks === null || overview.backlinks.stale);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!needsSnapshot || refreshFiredRef.current) return;
|
||||||
|
refreshFiredRef.current = true;
|
||||||
|
refreshMutation.mutate();
|
||||||
|
}, [needsSnapshot, refreshMutation]);
|
||||||
|
|
||||||
|
if (activationQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-4 md:px-6 md:py-6">
|
||||||
|
<div className="alert alert-error">
|
||||||
|
{getStandardErrorMessage(activationQuery.error)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activation) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-auto flex max-w-5xl flex-col gap-5 px-4 py-4 md:px-6 md:py-6"
|
||||||
|
aria-busy
|
||||||
|
>
|
||||||
|
<div className="skeleton h-8 w-52" />
|
||||||
|
<div className="skeleton h-36" />
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<div className="skeleton h-44" />
|
||||||
|
<div className="skeleton h-44" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showBacklinks = activation.domain !== null;
|
||||||
|
const gscConnected = activation.gsc.connected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-4 pb-24 md:px-6 md:py-6 md:pb-8">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-col gap-5">
|
||||||
|
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
||||||
|
|
||||||
|
<OnboardingChecklist projectId={projectId} activation={activation} />
|
||||||
|
|
||||||
|
{/* Every card is half width on large screens (only the checklist spans).
|
||||||
|
Cards with data render before setup pitches and empty states. */}
|
||||||
|
<div className="grid items-start gap-5 lg:grid-cols-2">
|
||||||
|
{[
|
||||||
|
// Array order is the within-bucket order after the data-first sort:
|
||||||
|
// the MCP pitch leads the setup cards.
|
||||||
|
...(activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: "mcp",
|
||||||
|
hasData: false,
|
||||||
|
node: (
|
||||||
|
<McpConnectCard
|
||||||
|
projectId={projectId}
|
||||||
|
activation={activation}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
key: "gsc",
|
||||||
|
hasData: gscConnected,
|
||||||
|
node: <GscCard projectId={projectId} connected={gscConnected} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rank",
|
||||||
|
hasData: overview?.rank != null,
|
||||||
|
node: (
|
||||||
|
<RankMovementCard
|
||||||
|
projectId={projectId}
|
||||||
|
rank={overview?.rank ?? null}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "audit",
|
||||||
|
hasData: overview?.audit != null,
|
||||||
|
node: (
|
||||||
|
<AuditHealthCard
|
||||||
|
projectId={projectId}
|
||||||
|
audit={overview?.audit ?? null}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(showBacklinks
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "backlinks",
|
||||||
|
hasData:
|
||||||
|
overview?.backlinks != null || refreshMutation.isPending,
|
||||||
|
node: (
|
||||||
|
<BacklinkPulseCard
|
||||||
|
projectId={projectId}
|
||||||
|
backlinks={overview?.backlinks ?? null}
|
||||||
|
refreshing={refreshMutation.isPending}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
.toSorted((a, b) => Number(b.hasData) - Number(a.hasData))
|
||||||
|
.map((card) => (
|
||||||
|
<div key={card.key}>{card.node}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/client/features/dashboard/McpConnectCard.tsx
Normal file
126
src/client/features/dashboard/McpConnectCard.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { CopyButton } from "@/client/features/ai-mcp/SetupControls";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
|
||||||
|
import { dismissDashboardMcpCard } from "@/serverFunctions/dashboard";
|
||||||
|
|
||||||
|
function firstPrompts(domain: string | null): string[] {
|
||||||
|
const site = domain ?? "my site";
|
||||||
|
return [
|
||||||
|
`Review ${site}. Ideas for what keywords we could target? Use OpenSEO`,
|
||||||
|
"Research my competitors top pages and keywords and tell me what's working. Use OpenSEO",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP activation card. Pitches the agent workflow and links to the
|
||||||
|
* AI & MCP page for setup; disappears for good after the org's first
|
||||||
|
* external tool call (or an explicit "I already connected").
|
||||||
|
*/
|
||||||
|
export function McpConnectCard({
|
||||||
|
projectId,
|
||||||
|
activation,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
activation: DashboardActivation;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const dismissMutation = useMutation({
|
||||||
|
mutationFn: () => dismissDashboardMcpCard({ data: { projectId } }),
|
||||||
|
onSuccess: () =>
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardActivation", projectId],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = activation.mcp.authorizedAt !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<h2 className="text-base font-semibold leading-tight">
|
||||||
|
Connect your AI agent
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{connected ? (
|
||||||
|
<span className="badge badge-success badge-outline badge-sm">
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs text-base-content/60"
|
||||||
|
disabled={dismissMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
captureClientEvent("dashboard:mcp_already_connected");
|
||||||
|
dismissMutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I already connected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 border-t border-base-300 p-5">
|
||||||
|
{connected ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
Your agent is connected. Try asking it:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{firstPrompts(activation.domain).map((prompt) => (
|
||||||
|
<li
|
||||||
|
key={prompt}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border border-base-300 bg-base-200/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-xs text-base-content/80">
|
||||||
|
{prompt}
|
||||||
|
</span>
|
||||||
|
<CopyButton
|
||||||
|
value={prompt}
|
||||||
|
successMessage="Prompt copied"
|
||||||
|
iconOnly
|
||||||
|
onCopy={() =>
|
||||||
|
captureClientEvent("dashboard:mcp_prompt_copy")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-base-content/50">
|
||||||
|
Waiting for your first call — this card disappears once your agent
|
||||||
|
talks to OpenSEO.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 text-sm text-base-content/70">
|
||||||
|
<p>
|
||||||
|
OpenSEO is designed to give your AI agent the data it needs to
|
||||||
|
build a great SEO strategy and help you execute it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This way you aren’t limited on “AI credits”.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can work with your agent to figure out what automations make
|
||||||
|
sense for you and it can help you write content too.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/ai"
|
||||||
|
className="link link-primary text-sm font-medium"
|
||||||
|
onClick={() => captureClientEvent("dashboard:mcp_setup_open")}
|
||||||
|
>
|
||||||
|
Set up in AI & MCP →
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/client/features/dashboard/cardParts.tsx
Normal file
111
src/client/features/dashboard/cardParts.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// Shared building blocks for the dashboard cards. Same visual language as
|
||||||
|
// the GSC IntegrationCard (rounded-xl, shadow-sm, header row + divider) so
|
||||||
|
// the embedded SearchConsoleConnectionCard doesn't read as a different
|
||||||
|
// design system.
|
||||||
|
export function CardShell({
|
||||||
|
title,
|
||||||
|
stamp,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
stamp?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<h2 className="text-base font-semibold leading-tight">{title}</h2>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-base-300 p-5">
|
||||||
|
{children}
|
||||||
|
{stamp ? (
|
||||||
|
<p className="mt-4 text-[11px] text-base-content/45">{stamp}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyCardBody({
|
||||||
|
message,
|
||||||
|
cta,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
cta: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<p className="text-sm text-base-content/70">{message}</p>
|
||||||
|
{cta}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "success" | "error";
|
||||||
|
sub?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const toneClass =
|
||||||
|
tone === "success" ? "text-success" : tone === "error" ? "text-error" : "";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-base-content/60">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`text-2xl font-semibold tabular-nums ${toneClass}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{sub}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PercentDelta({
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
}: {
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
}) {
|
||||||
|
if (previous <= 0) return null;
|
||||||
|
const pct = ((current - previous) / previous) * 100;
|
||||||
|
if (!Number.isFinite(pct)) return null;
|
||||||
|
const rounded = Math.round(pct);
|
||||||
|
const tone = rounded > 0 ? "text-success" : rounded < 0 ? "text-error" : "";
|
||||||
|
return (
|
||||||
|
<p className={`text-xs tabular-nums ${tone}`}>
|
||||||
|
{rounded > 0 ? "▲" : rounded < 0 ? "▼" : ""} {Math.abs(rounded)}%
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moreDetailsClass = "btn btn-ghost btn-xs";
|
||||||
|
|
||||||
|
export function newLost(value: number | null): string {
|
||||||
|
return value === null ? "—" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDay(timestamp: string): string {
|
||||||
|
const ms = Date.parse(
|
||||||
|
// SQLite's current_timestamp default has no timezone marker; treat it as
|
||||||
|
// UTC rather than letting the browser parse it as local time.
|
||||||
|
/^\d{4}-\d{2}-\d{2} /.test(timestamp)
|
||||||
|
? `${timestamp.replace(" ", "T")}Z`
|
||||||
|
: timestamp,
|
||||||
|
);
|
||||||
|
if (Number.isNaN(ms)) return timestamp;
|
||||||
|
return new Date(ms).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
42
src/client/features/dashboard/dashboardSteps.ts
Normal file
42
src/client/features/dashboard/dashboardSteps.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
|
||||||
|
import type { DashboardHeroStep } from "@/types/schemas/dashboard";
|
||||||
|
|
||||||
|
export const STEP_ORDER: DashboardHeroStep[] = [
|
||||||
|
"domain",
|
||||||
|
"mcp",
|
||||||
|
"gsc",
|
||||||
|
"competitor",
|
||||||
|
];
|
||||||
|
|
||||||
|
// A step is "done" when the underlying product state exists, regardless of
|
||||||
|
// how it got there. The MCP hero step completes at authorization (the card
|
||||||
|
// below owns the connected-but-no-call-yet coaching) or when the user said
|
||||||
|
// "I already connected".
|
||||||
|
export function isStepDone(
|
||||||
|
activation: DashboardActivation,
|
||||||
|
step: DashboardHeroStep,
|
||||||
|
): boolean {
|
||||||
|
switch (step) {
|
||||||
|
case "domain":
|
||||||
|
return activation.domain !== null;
|
||||||
|
case "mcp":
|
||||||
|
return (
|
||||||
|
activation.mcp.authorizedAt !== null ||
|
||||||
|
activation.mcp.cardDismissedAt !== null
|
||||||
|
);
|
||||||
|
case "gsc":
|
||||||
|
return activation.gsc.connected;
|
||||||
|
case "competitor":
|
||||||
|
return activation.competitorClickedAt !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single step the hero should coach next; null = coaching is over. */
|
||||||
|
export function computeNextStep(
|
||||||
|
activation: DashboardActivation,
|
||||||
|
): DashboardHeroStep | null {
|
||||||
|
for (const step of STEP_ORDER) {
|
||||||
|
if (!isStepDone(activation, step)) return step;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -96,6 +96,14 @@ export function SearchConsoleConnectionCard({
|
|||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["searchPerformanceTable", projectId],
|
queryKey: ["searchPerformanceTable", projectId],
|
||||||
});
|
});
|
||||||
|
// The dashboard embeds this card and swaps it for the Search
|
||||||
|
// performance stats card once activation reports the connection.
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardActivation", projectId],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardGscReport", projectId],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
||||||
});
|
});
|
||||||
@ -116,6 +124,12 @@ export function SearchConsoleConnectionCard({
|
|||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["searchPerformanceTable", projectId],
|
queryKey: ["searchPerformanceTable", projectId],
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardActivation", projectId],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardGscReport", projectId],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
||||||
});
|
});
|
||||||
@ -177,7 +191,8 @@ export function SearchConsoleConnectionCard({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/70">
|
||||||
Connect your Google Search Console to get insights in OpenSEO.
|
Connect GSC to see how your website is actually performing in Google
|
||||||
|
Search.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
|
import { ArrowRight, Check } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import {
|
import {
|
||||||
@ -22,7 +22,7 @@ type PostSignupOnboardingProps = {
|
|||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onFinish: (mcpSetupIntent: "yes" | "no") => void;
|
onFinish: () => void;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
accountMenu: ReactNode;
|
accountMenu: ReactNode;
|
||||||
};
|
};
|
||||||
@ -122,27 +122,20 @@ export function PostSignupOnboarding({
|
|||||||
otherValue={answers.sourceOther}
|
otherValue={answers.sourceOther}
|
||||||
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
|
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
|
||||||
/>
|
/>
|
||||||
) : step === 3 ? (
|
|
||||||
<SearchConsoleOnboardingStep />
|
|
||||||
) : (
|
) : (
|
||||||
<McpRecommendation
|
<SearchConsoleOnboardingStep />
|
||||||
isSaving={isSaving}
|
|
||||||
onBack={onBack}
|
|
||||||
onSetup={() => onFinish("yes")}
|
|
||||||
onSkip={() => onFinish("no")}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step < ONBOARDING_LAST_STEP ? (
|
<div className="mt-5 flex items-center justify-between gap-3">
|
||||||
<div className="mt-5 flex items-center justify-between gap-3">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="btn btn-ghost"
|
||||||
className="btn btn-ghost"
|
disabled={step === 0 || isSaving}
|
||||||
disabled={step === 0 || isSaving}
|
onClick={onBack}
|
||||||
onClick={onBack}
|
>
|
||||||
>
|
Back
|
||||||
Back
|
</button>
|
||||||
</button>
|
{step < ONBOARDING_LAST_STEP ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -162,79 +155,23 @@ export function PostSignupOnboarding({
|
|||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : null}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onFinish}
|
||||||
|
>
|
||||||
|
Finish
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function McpRecommendation({
|
|
||||||
isSaving,
|
|
||||||
onBack,
|
|
||||||
onSetup,
|
|
||||||
onSkip,
|
|
||||||
}: {
|
|
||||||
isSaving: boolean;
|
|
||||||
onBack: () => void;
|
|
||||||
onSetup: () => void;
|
|
||||||
onSkip: () => void;
|
|
||||||
}) {
|
|
||||||
const capabilities = [
|
|
||||||
"Keyword research",
|
|
||||||
"Competitor research",
|
|
||||||
"Link prospecting",
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm -ml-2 mb-2 self-start gap-1.5 text-base-content/60"
|
|
||||||
disabled={isSaving}
|
|
||||||
onClick={onBack}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<h2 className="text-lg font-semibold">Set up OpenSEO MCP?</h2>
|
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-base-content/70">
|
|
||||||
The most powerful way to use OpenSEO — use AI to supercharge your SEO
|
|
||||||
skills.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="mt-4 w-full space-y-2">
|
|
||||||
{capabilities.map((capability) => (
|
|
||||||
<li key={capability} className="flex items-center gap-2.5 text-sm">
|
|
||||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-base-200 text-base-content">
|
|
||||||
<Check className="size-3" />
|
|
||||||
</span>
|
|
||||||
<span className="text-base-content/80">{capability}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary mt-5 w-full"
|
|
||||||
disabled={isSaving}
|
|
||||||
onClick={onSetup}
|
|
||||||
>
|
|
||||||
Yes, set up MCP
|
|
||||||
<ArrowRight className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm mt-2 w-full text-base-content/60"
|
|
||||||
disabled={isSaving}
|
|
||||||
onClick={onSkip}
|
|
||||||
>
|
|
||||||
Not now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OnboardingChoiceGroup({
|
function OnboardingChoiceGroup({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { getOnboardingAnswers } from "@/serverFunctions/onboarding";
|
import { getOnboardingAnswers } from "@/serverFunctions/onboarding";
|
||||||
|
|
||||||
export const ONBOARDING_LAST_STEP = 4;
|
export const ONBOARDING_LAST_STEP = 3;
|
||||||
|
|
||||||
export const INTEREST_OPTIONS = [
|
export const INTEREST_OPTIONS = [
|
||||||
"AI workflows with Claude or Codex (MCP)",
|
"AI workflows with Claude or Codex (MCP)",
|
||||||
@ -32,6 +32,7 @@ export const CLIENT_WEBSITE_COUNT_OPTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const SOURCE_OPTIONS = [
|
export const SOURCE_OPTIONS = [
|
||||||
|
"Product Hunt",
|
||||||
"Google",
|
"Google",
|
||||||
"Reddit",
|
"Reddit",
|
||||||
"X / Twitter",
|
"X / Twitter",
|
||||||
@ -110,7 +111,7 @@ export function restoreOnboardingAnswers(
|
|||||||
export function buildOnboardingPayload(
|
export function buildOnboardingPayload(
|
||||||
answers: OnboardingAnswers,
|
answers: OnboardingAnswers,
|
||||||
step: number,
|
step: number,
|
||||||
extra: { mcpSetupIntent?: "yes" | "no"; completed?: boolean } = {},
|
extra: { completed?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const interestedFeatures = answers.selectedInterests.map((value) =>
|
const interestedFeatures = answers.selectedInterests.map((value) =>
|
||||||
value === "Other" && answers.interestOther.trim()
|
value === "Other" && answers.interestOther.trim()
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function ProjectSwitcher({
|
|||||||
if (project.id === activeProjectId) return;
|
if (project.id === activeProjectId) return;
|
||||||
setLastProjectId(project.id);
|
setLastProjectId(project.id);
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/p/$projectId/keywords",
|
to: "/p/$projectId",
|
||||||
params: { projectId: project.id },
|
params: { projectId: project.id },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Globe,
|
Globe,
|
||||||
|
LayoutDashboard,
|
||||||
Link2,
|
Link2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
@ -13,6 +14,14 @@ import { linkOptions } from "@tanstack/react-router";
|
|||||||
import { GoogleGlyphMuted } from "@/client/features/gsc/GoogleGlyph";
|
import { GoogleGlyphMuted } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
|
||||||
const projectNavItems = [
|
const projectNavItems = [
|
||||||
|
{
|
||||||
|
to: "/p/$projectId" as const,
|
||||||
|
label: "Dashboard",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
// Without exact matching, the index path is a prefix of every project
|
||||||
|
// route and the Dashboard item would render active everywhere.
|
||||||
|
activeOptions: { exact: true, includeSearch: false },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/p/$projectId/keywords" as const,
|
to: "/p/$projectId/keywords" as const,
|
||||||
label: "Keyword Research",
|
label: "Keyword Research",
|
||||||
@ -90,6 +99,10 @@ export function getProjectNavGroups(projectId: string) {
|
|||||||
all.find((i) => i.to === path)!;
|
all.find((i) => i.to === path)!;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
items: [byPath("/p/$projectId")],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Research",
|
label: "Research",
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@ -350,3 +350,71 @@ export const rankSnapshots = sqliteTable(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dashboard activation milestones. Organization-scoped: MCP OAuth grants are
|
||||||
|
// user-level, so any member connecting an external MCP client satisfies the
|
||||||
|
// milestone for the whole organization. Timestamps are first-occurrence only
|
||||||
|
// and never move once set.
|
||||||
|
export const organizationActivationState = sqliteTable(
|
||||||
|
"organization_activation_state",
|
||||||
|
{
|
||||||
|
organizationId: text("organization_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
|
firstMcpAuthorizedAt: text("first_mcp_authorized_at"),
|
||||||
|
firstMcpToolCallAt: text("first_mcp_tool_call_at"),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-project state for the dashboard's onboarding checklist. Most steps
|
||||||
|
// complete via real product state (projects.domain, gsc_connections, MCP
|
||||||
|
// activation); the competitor step completes on click-through.
|
||||||
|
export const projectActivationState = sqliteTable("project_activation_state", {
|
||||||
|
projectId: text("project_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
competitorStepClickedAt: text("competitor_step_clicked_at"),
|
||||||
|
// "I already connected" on the MCP card: hides the card for this project
|
||||||
|
// without faking the org-level first-tool-call milestone, which stays
|
||||||
|
// truthful and self-heals when a real external call lands.
|
||||||
|
mcpCardDismissedAt: text("mcp_card_dismissed_at"),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Point-in-time backlink profile summaries for the project's own domain,
|
||||||
|
// written by the dashboard's visit-triggered refresh. DataForSEO's summary
|
||||||
|
// already carries new/lost counts, so one snapshot renders a full card;
|
||||||
|
// rows accumulate into history for future trend views. The domain is stored
|
||||||
|
// per row so a later project-domain change doesn't rewrite history.
|
||||||
|
export const backlinkSnapshots = sqliteTable(
|
||||||
|
"backlink_snapshots",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
domain: text("domain").notNull(),
|
||||||
|
rank: integer("rank"),
|
||||||
|
backlinks: integer("backlinks"),
|
||||||
|
referringDomains: integer("referring_domains"),
|
||||||
|
brokenBacklinks: integer("broken_backlinks"),
|
||||||
|
newBacklinks: integer("new_backlinks"),
|
||||||
|
lostBacklinks: integer("lost_backlinks"),
|
||||||
|
newReferringDomains: integer("new_referring_domains"),
|
||||||
|
lostReferringDomains: integer("lost_referring_domains"),
|
||||||
|
capturedAt: text("captured_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("backlink_snapshots_project_captured_idx").on(
|
||||||
|
table.projectId,
|
||||||
|
table.capturedAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@ -343,3 +343,65 @@ export const rankSnapshots = pgTable(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dashboard activation milestones. Organization-scoped: MCP OAuth grants are
|
||||||
|
// user-level, so any member connecting an external MCP client satisfies the
|
||||||
|
// milestone for the whole organization. Timestamps are first-occurrence only
|
||||||
|
// and never move once set.
|
||||||
|
export const organizationActivationState = pgTable(
|
||||||
|
"organization_activation_state",
|
||||||
|
{
|
||||||
|
organizationId: text("organization_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
|
firstMcpAuthorizedAt: timestampColumn("first_mcp_authorized_at"),
|
||||||
|
firstMcpToolCallAt: timestampColumn("first_mcp_tool_call_at"),
|
||||||
|
updatedAt: timestampColumn("updated_at").notNull().default(isoNow),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-project state for the dashboard's onboarding checklist. Most steps
|
||||||
|
// complete via real product state (projects.domain, gsc_connections, MCP
|
||||||
|
// activation); the competitor step completes on click-through.
|
||||||
|
export const projectActivationState = pgTable("project_activation_state", {
|
||||||
|
projectId: text("project_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
competitorStepClickedAt: timestampColumn("competitor_step_clicked_at"),
|
||||||
|
// "I already connected" on the MCP card: hides the card for this project
|
||||||
|
// without faking the org-level first-tool-call milestone, which stays
|
||||||
|
// truthful and self-heals when a real external call lands.
|
||||||
|
mcpCardDismissedAt: timestampColumn("mcp_card_dismissed_at"),
|
||||||
|
updatedAt: timestampColumn("updated_at").notNull().default(isoNow),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Point-in-time backlink profile summaries for the project's own domain,
|
||||||
|
// written by the dashboard's visit-triggered refresh. DataForSEO's summary
|
||||||
|
// already carries new/lost counts, so one snapshot renders a full card;
|
||||||
|
// rows accumulate into history for future trend views. The domain is stored
|
||||||
|
// per row so a later project-domain change doesn't rewrite history.
|
||||||
|
export const backlinkSnapshots = pgTable(
|
||||||
|
"backlink_snapshots",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
domain: text("domain").notNull(),
|
||||||
|
rank: integer("rank"),
|
||||||
|
backlinks: integer("backlinks"),
|
||||||
|
referringDomains: integer("referring_domains"),
|
||||||
|
brokenBacklinks: integer("broken_backlinks"),
|
||||||
|
newBacklinks: integer("new_backlinks"),
|
||||||
|
lostBacklinks: integer("lost_backlinks"),
|
||||||
|
newReferringDomains: integer("new_referring_domains"),
|
||||||
|
lostReferringDomains: integer("lost_referring_domains"),
|
||||||
|
capturedAt: timestampColumn("captured_at").notNull().default(isoNow),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("backlink_snapshots_project_captured_idx").on(
|
||||||
|
table.projectId,
|
||||||
|
table.capturedAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@ -72,6 +72,9 @@ export const {
|
|||||||
rankTrackingKeywords,
|
rankTrackingKeywords,
|
||||||
rankCheckRuns,
|
rankCheckRuns,
|
||||||
rankSnapshots,
|
rankSnapshots,
|
||||||
|
organizationActivationState,
|
||||||
|
projectActivationState,
|
||||||
|
backlinkSnapshots,
|
||||||
audits,
|
audits,
|
||||||
auditPages,
|
auditPages,
|
||||||
auditLinks,
|
auditLinks,
|
||||||
|
|||||||
@ -40,7 +40,7 @@ function IndexRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/p/$projectId/keywords",
|
to: "/p/$projectId",
|
||||||
params: { projectId: (target ?? data[0]).id },
|
params: { projectId: (target ?? data[0]).id },
|
||||||
});
|
});
|
||||||
}, [data, navigate]);
|
}, [data, navigate]);
|
||||||
|
|||||||
@ -81,10 +81,7 @@ function OnboardingFlow({
|
|||||||
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
|
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (extra: {
|
mutationFn: (extra: { completed?: boolean }) =>
|
||||||
mcpSetupIntent?: "yes" | "no";
|
|
||||||
completed?: boolean;
|
|
||||||
}) =>
|
|
||||||
saveOnboardingAnswers({
|
saveOnboardingAnswers({
|
||||||
data: buildOnboardingPayload(answers, step, extra),
|
data: buildOnboardingPayload(answers, step, extra),
|
||||||
}),
|
}),
|
||||||
@ -113,9 +110,9 @@ function OnboardingFlow({
|
|||||||
goToStep(step + 1);
|
goToStep(step + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinish = async (mcpSetupIntent: "yes" | "no") => {
|
const handleFinish = async () => {
|
||||||
try {
|
try {
|
||||||
await saveMutation.mutateAsync({ mcpSetupIntent, completed: true });
|
await saveMutation.mutateAsync({ completed: true });
|
||||||
// Refresh the shared cache so the destination's onboarding-redirect guard
|
// Refresh the shared cache so the destination's onboarding-redirect guard
|
||||||
// sees the completed state and doesn't bounce the user back here.
|
// sees the completed state and doesn't bounce the user back here.
|
||||||
await queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] });
|
await queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] });
|
||||||
@ -126,13 +123,9 @@ function OnboardingFlow({
|
|||||||
interests: answers.selectedInterests,
|
interests: answers.selectedInterests,
|
||||||
work_for: answers.workFor,
|
work_for: answers.workFor,
|
||||||
source: answers.source,
|
source: answers.source,
|
||||||
wants_mcp_setup: mcpSetupIntent === "yes",
|
|
||||||
});
|
});
|
||||||
if (mcpSetupIntent === "yes") {
|
// The dashboard's onboarding checklist owns MCP coaching now.
|
||||||
void navigate({ to: "/ai", replace: true });
|
void navigate({ to: "/", replace: true });
|
||||||
} else {
|
|
||||||
void navigate({ to: "/", replace: true });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { DashboardPage } from "@/client/features/dashboard/DashboardPage";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_project/p/$projectId/")({
|
export const Route = createFileRoute("/_project/p/$projectId/")({
|
||||||
beforeLoad: ({ params }) => {
|
component: DashboardRoute,
|
||||||
throw redirect({
|
|
||||||
to: "/p/$projectId/keywords",
|
|
||||||
params: { projectId: params.projectId },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function DashboardRoute() {
|
||||||
|
const { projectId } = Route.useParams();
|
||||||
|
return <DashboardPage projectId={projectId} />;
|
||||||
|
}
|
||||||
|
|||||||
36
src/server/features/activation/mcpActivation.ts
Normal file
36
src/server/features/activation/mcpActivation.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
|
||||||
|
|
||||||
|
// Orgs whose first external MCP tool call is already recorded (or in flight)
|
||||||
|
// in this isolate. Only *first* timestamps matter, so after one successful
|
||||||
|
// write the tool-call hot path never touches the DB again for that org.
|
||||||
|
const recordedToolCallOrgs = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milestone writes for the dashboard's MCP activation card. Both are awaited
|
||||||
|
* inline by their callers (not waitUntil) so the upsert runs inside the
|
||||||
|
* request's DB client scope, and both swallow errors: activation tracking
|
||||||
|
* must never fail an OAuth flow or a tool call.
|
||||||
|
*/
|
||||||
|
export async function recordMcpAuthorized(
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ActivationRepository.recordFirstMcpAuthorized(organizationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("activation: recordMcpAuthorized failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordExternalMcpToolCall(
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (recordedToolCallOrgs.has(organizationId)) return;
|
||||||
|
recordedToolCallOrgs.add(organizationId);
|
||||||
|
try {
|
||||||
|
await ActivationRepository.recordFirstMcpToolCall(organizationId);
|
||||||
|
} catch (error) {
|
||||||
|
// Allow a retry on a later call rather than losing the milestone.
|
||||||
|
recordedToolCallOrgs.delete(organizationId);
|
||||||
|
console.error("activation: recordExternalMcpToolCall failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
organizationActivationState,
|
||||||
|
projectActivationState,
|
||||||
|
} from "@/db/schema";
|
||||||
|
|
||||||
|
type OrganizationActivationState =
|
||||||
|
typeof organizationActivationState.$inferSelect;
|
||||||
|
type ProjectActivationState = typeof projectActivationState.$inferSelect;
|
||||||
|
|
||||||
|
async function getOrganizationActivation(
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<OrganizationActivationState | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(organizationActivationState)
|
||||||
|
.where(eq(organizationActivationState.organizationId, organizationId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectActivation(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<ProjectActivationState | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(projectActivationState)
|
||||||
|
.where(eq(projectActivationState.projectId, projectId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-occurrence timestamps only: concurrent writers race harmlessly because
|
||||||
|
// COALESCE keeps whichever value landed first.
|
||||||
|
async function recordFirstMcpAuthorized(organizationId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.insert(organizationActivationState)
|
||||||
|
.values({ organizationId, firstMcpAuthorizedAt: now, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: organizationActivationState.organizationId,
|
||||||
|
set: {
|
||||||
|
firstMcpAuthorizedAt: sql`coalesce(${organizationActivationState.firstMcpAuthorizedAt}, ${now})`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFirstMcpToolCall(organizationId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.insert(organizationActivationState)
|
||||||
|
.values({ organizationId, firstMcpToolCallAt: now, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: organizationActivationState.organizationId,
|
||||||
|
set: {
|
||||||
|
firstMcpToolCallAt: sql`coalesce(${organizationActivationState.firstMcpToolCallAt}, ${now})`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markCompetitorStepClicked(projectId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.insert(projectActivationState)
|
||||||
|
.values({ projectId, competitorStepClickedAt: now, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: projectActivationState.projectId,
|
||||||
|
set: {
|
||||||
|
competitorStepClickedAt: sql`coalesce(${projectActivationState.competitorStepClickedAt}, ${now})`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markMcpCardDismissed(projectId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.insert(projectActivationState)
|
||||||
|
.values({ projectId, mcpCardDismissedAt: now, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: projectActivationState.projectId,
|
||||||
|
set: {
|
||||||
|
mcpCardDismissedAt: sql`coalesce(${projectActivationState.mcpCardDismissedAt}, ${now})`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActivationRepository = {
|
||||||
|
getOrganizationActivation,
|
||||||
|
getProjectActivation,
|
||||||
|
recordFirstMcpAuthorized,
|
||||||
|
recordFirstMcpToolCall,
|
||||||
|
markCompetitorStepClicked,
|
||||||
|
markMcpCardDismissed,
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { countDistinct, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { auditIssues } from "@/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct-page counts per issue type for one audit — link-level issues
|
||||||
|
* write one row per occurrence, and consumers phrase this as "N pages".
|
||||||
|
* Lives beside AuditRepository (same pattern as rank-tracking's
|
||||||
|
* snapshotQueries) to keep the main repository under the file-size limit.
|
||||||
|
*/
|
||||||
|
export async function getIssueTypePageCountsForAudit(auditId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
issueType: auditIssues.issueType,
|
||||||
|
severity: auditIssues.severity,
|
||||||
|
pages: countDistinct(auditIssues.pageUrl),
|
||||||
|
})
|
||||||
|
.from(auditIssues)
|
||||||
|
.where(eq(auditIssues.auditId, auditId))
|
||||||
|
.groupBy(auditIssues.issueType, auditIssues.severity);
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { backlinkSnapshots } from "@/db/schema";
|
||||||
|
|
||||||
|
type BacklinkSnapshot = typeof backlinkSnapshots.$inferSelect;
|
||||||
|
|
||||||
|
async function getLatestForProject(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<BacklinkSnapshot | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(backlinkSnapshots)
|
||||||
|
.where(eq(backlinkSnapshots.projectId, projectId))
|
||||||
|
// id, not capturedAt: autoincrement is monotonic and immune to the
|
||||||
|
// sqlite-vs-pg timestamp text-format difference.
|
||||||
|
.orderBy(desc(backlinkSnapshots.id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insert(
|
||||||
|
values: typeof backlinkSnapshots.$inferInsert,
|
||||||
|
): Promise<BacklinkSnapshot> {
|
||||||
|
const [row] = await db.insert(backlinkSnapshots).values(values).returning();
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("Failed to insert backlink_snapshot");
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BacklinkSnapshotRepository = {
|
||||||
|
getLatestForProject,
|
||||||
|
insert,
|
||||||
|
};
|
||||||
273
src/server/features/dashboard/services/DashboardService.ts
Normal file
273
src/server/features/dashboard/services/DashboardService.ts
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
|
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
|
||||||
|
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||||
|
import { getIssueTypePageCountsForAudit } from "@/server/features/audit/repositories/auditSummaryQueries";
|
||||||
|
import { BacklinkSnapshotRepository } from "@/server/features/dashboard/repositories/BacklinkSnapshotRepository";
|
||||||
|
import { GscConnectionRepository } from "@/server/features/gsc/repositories/GscConnectionRepository";
|
||||||
|
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||||
|
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
|
||||||
|
import {
|
||||||
|
createDataforseoClient,
|
||||||
|
normalizeBacklinksTarget,
|
||||||
|
} from "@/server/lib/dataforseo";
|
||||||
|
|
||||||
|
// Daily cadence: fresh numbers each visit without per-visit spend; a dormant
|
||||||
|
// project costs nothing because refreshes are visit-triggered.
|
||||||
|
const SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
// Bounds the per-config result reads on the overview path; projects rarely
|
||||||
|
// have more than a couple of configs.
|
||||||
|
const MAX_CONFIGS_FOR_OVERVIEW = 5;
|
||||||
|
|
||||||
|
export type DashboardActivation = {
|
||||||
|
domain: string | null;
|
||||||
|
gsc: { connected: boolean; siteUrl: string | null };
|
||||||
|
mcp: {
|
||||||
|
authorizedAt: string | null;
|
||||||
|
firstToolCallAt: string | null;
|
||||||
|
cardDismissedAt: string | null;
|
||||||
|
};
|
||||||
|
competitorClickedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardRankSummary = {
|
||||||
|
trackedKeywords: number;
|
||||||
|
improved: number;
|
||||||
|
declined: number;
|
||||||
|
top10: number;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardAuditSummary = {
|
||||||
|
status: "running" | "completed" | "failed";
|
||||||
|
pagesCrawled: number;
|
||||||
|
startedAt: string;
|
||||||
|
// Top issue types by severity then affected-page count, for the card's list.
|
||||||
|
topIssues: {
|
||||||
|
issueType: string;
|
||||||
|
severity: "critical" | "warning" | "info";
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
totalIssueTypes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardBacklinkSummary = {
|
||||||
|
domain: string;
|
||||||
|
rank: number | null;
|
||||||
|
backlinks: number | null;
|
||||||
|
referringDomains: number | null;
|
||||||
|
newBacklinks: number | null;
|
||||||
|
lostBacklinks: number | null;
|
||||||
|
newReferringDomains: number | null;
|
||||||
|
lostReferringDomains: number | null;
|
||||||
|
capturedAt: string;
|
||||||
|
stale: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardOverview = {
|
||||||
|
rank: DashboardRankSummary | null;
|
||||||
|
audit: DashboardAuditSummary | null;
|
||||||
|
backlinks: DashboardBacklinkSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getActivation(input: {
|
||||||
|
projectId: string;
|
||||||
|
organizationId: string;
|
||||||
|
domain: string | null;
|
||||||
|
}): Promise<DashboardActivation> {
|
||||||
|
const [gsc, orgActivation, projectActivation] = await Promise.all([
|
||||||
|
GscConnectionRepository.getByProjectId(input.projectId),
|
||||||
|
ActivationRepository.getOrganizationActivation(input.organizationId),
|
||||||
|
ActivationRepository.getProjectActivation(input.projectId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: input.domain,
|
||||||
|
gsc: { connected: gsc !== null, siteUrl: gsc?.siteUrl ?? null },
|
||||||
|
mcp: {
|
||||||
|
authorizedAt: orgActivation?.firstMcpAuthorizedAt ?? null,
|
||||||
|
firstToolCallAt: orgActivation?.firstMcpToolCallAt ?? null,
|
||||||
|
cardDismissedAt: projectActivation?.mcpCardDismissedAt ?? null,
|
||||||
|
},
|
||||||
|
competitorClickedAt: projectActivation?.competitorStepClickedAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOverview(input: {
|
||||||
|
projectId: string;
|
||||||
|
domain: string | null;
|
||||||
|
}): Promise<DashboardOverview> {
|
||||||
|
const [rank, audit, backlinks] = await Promise.all([
|
||||||
|
getRankSummary(input.projectId),
|
||||||
|
getAuditSummary(input.projectId),
|
||||||
|
getBacklinkSummary(input.projectId, input.domain),
|
||||||
|
]);
|
||||||
|
return { rank, audit, backlinks };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRankSummary(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<DashboardRankSummary | null> {
|
||||||
|
const configs = await RankTrackingRepository.getConfigsForProject(projectId);
|
||||||
|
if (configs.length === 0) return null;
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
configs
|
||||||
|
.slice(0, MAX_CONFIGS_FOR_OVERVIEW)
|
||||||
|
.map((config) => getLatestResults(config.id, projectId, "7d")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary: DashboardRankSummary = {
|
||||||
|
trackedKeywords: 0,
|
||||||
|
improved: 0,
|
||||||
|
declined: 0,
|
||||||
|
top10: 0,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
summary.trackedKeywords += result.rows.length;
|
||||||
|
if (
|
||||||
|
result.run &&
|
||||||
|
(!summary.lastCheckedAt ||
|
||||||
|
result.run.lastCheckedAt > summary.lastCheckedAt)
|
||||||
|
) {
|
||||||
|
summary.lastCheckedAt = result.run.lastCheckedAt;
|
||||||
|
}
|
||||||
|
for (const row of result.rows) {
|
||||||
|
for (const device of ["desktop", "mobile"] as const) {
|
||||||
|
const { position, previousPosition } = row[device];
|
||||||
|
if (position !== null && position <= 10) summary.top10 += 1;
|
||||||
|
if (position === null || previousPosition === null) continue;
|
||||||
|
// Lower position number = better ranking.
|
||||||
|
if (position < previousPosition) summary.improved += 1;
|
||||||
|
else if (position > previousPosition) summary.declined += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuditSummary(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<DashboardAuditSummary | null> {
|
||||||
|
const audit = await AuditRepository.getLatestAuditForProject(projectId);
|
||||||
|
if (!audit) return null;
|
||||||
|
|
||||||
|
const typeRows = await getIssueTypePageCountsForAudit(audit.id);
|
||||||
|
|
||||||
|
const severityRank = { critical: 0, warning: 1, info: 2 };
|
||||||
|
const sorted = typeRows
|
||||||
|
.map((row) => ({
|
||||||
|
issueType: row.issueType,
|
||||||
|
severity: row.severity,
|
||||||
|
count: row.pages,
|
||||||
|
}))
|
||||||
|
.toSorted(
|
||||||
|
(a, b) =>
|
||||||
|
severityRank[a.severity] - severityRank[b.severity] ||
|
||||||
|
b.count - a.count,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: audit.status,
|
||||||
|
pagesCrawled: audit.pagesCrawled,
|
||||||
|
startedAt: audit.startedAt,
|
||||||
|
topIssues: sorted.slice(0, 3),
|
||||||
|
totalIssueTypes: sorted.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSnapshotFresh(capturedAt: string): boolean {
|
||||||
|
const capturedMs = Date.parse(capturedAt);
|
||||||
|
if (Number.isNaN(capturedMs)) return false;
|
||||||
|
return Date.now() - capturedMs < SNAPSHOT_MAX_AGE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBacklinkSummary(
|
||||||
|
projectId: string,
|
||||||
|
domain: string | null,
|
||||||
|
): Promise<DashboardBacklinkSummary | null> {
|
||||||
|
if (!domain) return null;
|
||||||
|
const snapshot =
|
||||||
|
await BacklinkSnapshotRepository.getLatestForProject(projectId);
|
||||||
|
if (!snapshot || snapshot.domain !== domain) return null;
|
||||||
|
return {
|
||||||
|
domain: snapshot.domain,
|
||||||
|
rank: snapshot.rank,
|
||||||
|
backlinks: snapshot.backlinks,
|
||||||
|
referringDomains: snapshot.referringDomains,
|
||||||
|
newBacklinks: snapshot.newBacklinks,
|
||||||
|
lostBacklinks: snapshot.lostBacklinks,
|
||||||
|
newReferringDomains: snapshot.newReferringDomains,
|
||||||
|
lostReferringDomains: snapshot.lostReferringDomains,
|
||||||
|
capturedAt: snapshot.capturedAt,
|
||||||
|
stale: !isSnapshotFresh(snapshot.capturedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visit-triggered snapshot refresh. Fetches only the DataForSEO backlinks
|
||||||
|
* summary (not the history endpoint the backlinks page also pays for) and is
|
||||||
|
* a no-op while the latest snapshot for the current domain is under a day
|
||||||
|
* old. Concurrent loads racing the freshness check can each pay a metered
|
||||||
|
* call — every call is metered, so the race duplicates customer spend on
|
||||||
|
* identical data but never leaks revenue; accepted for now. On a fetch
|
||||||
|
* failure with a stale snapshot in hand, the stale snapshot is returned
|
||||||
|
* rather than surfacing an error card.
|
||||||
|
*/
|
||||||
|
async function ensureBacklinkSnapshot(input: {
|
||||||
|
projectId: string;
|
||||||
|
domain: string | null;
|
||||||
|
billingCustomer: BillingCustomerContext;
|
||||||
|
}): Promise<DashboardBacklinkSummary | null> {
|
||||||
|
const { projectId, domain } = input;
|
||||||
|
if (!domain) return null;
|
||||||
|
|
||||||
|
const latest =
|
||||||
|
await BacklinkSnapshotRepository.getLatestForProject(projectId);
|
||||||
|
const latestMatchesDomain = latest !== null && latest.domain === domain;
|
||||||
|
if (latest && latestMatchesDomain && isSnapshotFresh(latest.capturedAt)) {
|
||||||
|
return getBacklinkSummary(projectId, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeBacklinksTarget(domain, { scope: "domain" });
|
||||||
|
const dataforseo = createDataforseoClient(input.billingCustomer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await dataforseo.backlinks.summary({
|
||||||
|
target: normalized.apiTarget,
|
||||||
|
});
|
||||||
|
await BacklinkSnapshotRepository.insert({
|
||||||
|
projectId,
|
||||||
|
domain,
|
||||||
|
rank: summary.rank ?? null,
|
||||||
|
backlinks: summary.backlinks ?? null,
|
||||||
|
referringDomains: summary.referring_domains ?? null,
|
||||||
|
brokenBacklinks: summary.broken_backlinks ?? null,
|
||||||
|
newBacklinks: summary.new_backlinks ?? null,
|
||||||
|
lostBacklinks: summary.lost_backlinks ?? null,
|
||||||
|
newReferringDomains:
|
||||||
|
summary.new_referring_domains ?? summary.new_reffering_domains ?? null,
|
||||||
|
lostReferringDomains:
|
||||||
|
summary.lost_referring_domains ??
|
||||||
|
summary.lost_reffering_domains ??
|
||||||
|
null,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (latestMatchesDomain) {
|
||||||
|
console.error("dashboard: backlink snapshot refresh failed", error);
|
||||||
|
return getBacklinkSummary(projectId, domain);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getBacklinkSummary(projectId, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardService = {
|
||||||
|
getActivation,
|
||||||
|
getOverview,
|
||||||
|
ensureBacklinkSnapshot,
|
||||||
|
};
|
||||||
@ -101,6 +101,31 @@ async function updateProject(
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writes only the domain column, for the dashboard's inline domain input.
|
||||||
|
async function updateProjectDomain(
|
||||||
|
projectId: string,
|
||||||
|
organizationId: string,
|
||||||
|
domain: string,
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.update(projects)
|
||||||
|
.set({ domain })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projects.id, projectId),
|
||||||
|
eq(projects.organizationId, organizationId),
|
||||||
|
isNull(projects.archivedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError("NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
// Writes only the market columns. Onboarding sets the project's market before
|
// Writes only the market columns. Onboarding sets the project's market before
|
||||||
// the user has named the project or picked a domain, so it must not go through
|
// the user has named the project or picked a domain, so it must not go through
|
||||||
// updateProject, whose `domain: input.domain ?? null` would clear the domain.
|
// updateProject, whose `domain: input.domain ?? null` would clear the domain.
|
||||||
@ -197,6 +222,7 @@ export const ProjectRepository = {
|
|||||||
getProjectById,
|
getProjectById,
|
||||||
createProject,
|
createProject,
|
||||||
updateProject,
|
updateProject,
|
||||||
|
updateProjectDomain,
|
||||||
updateProjectMarket,
|
updateProjectMarket,
|
||||||
tryCreateDefaultProject,
|
tryCreateDefaultProject,
|
||||||
archiveProject,
|
archiveProject,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
listProjects,
|
listProjects,
|
||||||
listProjectsEnsuringOne,
|
listProjectsEnsuringOne,
|
||||||
restoreProject,
|
restoreProject,
|
||||||
|
setProjectDomain,
|
||||||
setProjectMarket,
|
setProjectMarket,
|
||||||
updateProject,
|
updateProject,
|
||||||
} from "@/server/features/projects/services/projects";
|
} from "@/server/features/projects/services/projects";
|
||||||
@ -15,6 +16,7 @@ export const ProjectService = {
|
|||||||
listProjectsEnsuringOne,
|
listProjectsEnsuringOne,
|
||||||
createProject,
|
createProject,
|
||||||
updateProject,
|
updateProject,
|
||||||
|
setProjectDomain,
|
||||||
setProjectMarket,
|
setProjectMarket,
|
||||||
archiveProject,
|
archiveProject,
|
||||||
restoreProject,
|
restoreProject,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
createProject: vi.fn(),
|
createProject: vi.fn(),
|
||||||
updateProject: vi.fn(),
|
updateProject: vi.fn(),
|
||||||
|
updateProjectDomain: vi.fn(),
|
||||||
archiveProject: vi.fn(),
|
archiveProject: vi.fn(),
|
||||||
restoreProject: vi.fn(),
|
restoreProject: vi.fn(),
|
||||||
countProjects: vi.fn(),
|
countProjects: vi.fn(),
|
||||||
@ -208,6 +209,51 @@ describe("project service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("setProjectDomain", () => {
|
||||||
|
it("canonicalizes a pasted URL to the bare host before writing", async () => {
|
||||||
|
mocks.updateProjectDomain.mockResolvedValue(namedProject);
|
||||||
|
const { setProjectDomain } = await import("./projects");
|
||||||
|
|
||||||
|
await setProjectDomain("org_1", {
|
||||||
|
projectId: "project_acme",
|
||||||
|
domain: "https://www.Acme.com/pricing?ref=x",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.updateProjectDomain).toHaveBeenCalledWith(
|
||||||
|
"project_acme",
|
||||||
|
"org_1",
|
||||||
|
"acme.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects junk that the backlink fetch would later refuse", async () => {
|
||||||
|
const { setProjectDomain } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
setProjectDomain("org_1", {
|
||||||
|
projectId: "project_acme",
|
||||||
|
domain: "not a domain",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Enter a valid domain");
|
||||||
|
expect(mocks.updateProjectDomain).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateProject domain validation", () => {
|
||||||
|
it("rejects a junk domain instead of storing it", async () => {
|
||||||
|
const { updateProject } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateProject("org_1", {
|
||||||
|
projectId: "project_acme",
|
||||||
|
name: "Acme",
|
||||||
|
domain: "999.999.999.999",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Enter a valid domain");
|
||||||
|
expect(mocks.updateProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("setProjectMarket", () => {
|
describe("setProjectMarket", () => {
|
||||||
it("writes only the market columns, leaving name and domain untouched", async () => {
|
it("writes only the market columns, leaving name and domain untouched", async () => {
|
||||||
// Onboarding sets the market before the project is named or given a
|
// Onboarding sets the market before the project is named or given a
|
||||||
|
|||||||
@ -2,10 +2,12 @@ import type {
|
|||||||
ArchiveProjectInput,
|
ArchiveProjectInput,
|
||||||
CreateProjectInput,
|
CreateProjectInput,
|
||||||
RestoreProjectInput,
|
RestoreProjectInput,
|
||||||
|
SetProjectDomainInput,
|
||||||
SetProjectMarketInput,
|
SetProjectMarketInput,
|
||||||
UpdateProjectInput,
|
UpdateProjectInput,
|
||||||
} from "@/types/schemas/projects";
|
} from "@/types/schemas/projects";
|
||||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||||
|
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import { assertLanguageForLocation } from "@/server/lib/market";
|
import { assertLanguageForLocation } from "@/server/lib/market";
|
||||||
import { getLanguageCode } from "@/shared/keyword-locations";
|
import { getLanguageCode } from "@/shared/keyword-locations";
|
||||||
@ -83,6 +85,24 @@ export async function listProjectsEnsuringOne(organizationId: string) {
|
|||||||
return listProjects(organizationId);
|
return listProjects(organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and canonicalizes a project domain (lowercase bare host, www and
|
||||||
|
* protocol/path stripped) with the same rules the backlink fetch will apply
|
||||||
|
* later, so junk fails at save time instead of at the first paid call.
|
||||||
|
* Undefined passes through — updateProject uses that to clear the domain.
|
||||||
|
*/
|
||||||
|
function normalizeProjectDomain(domain: string | undefined) {
|
||||||
|
if (domain === undefined) return undefined;
|
||||||
|
try {
|
||||||
|
return normalizeBacklinksTarget(domain, { scope: "domain" }).apiTarget;
|
||||||
|
} catch {
|
||||||
|
throw new AppError(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
"Enter a valid domain, like acme.com.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProject(
|
export async function createProject(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
input: CreateProjectInput,
|
input: CreateProjectInput,
|
||||||
@ -91,7 +111,7 @@ export async function createProject(
|
|||||||
const row = await ProjectRepository.createProject(
|
const row = await ProjectRepository.createProject(
|
||||||
organizationId,
|
organizationId,
|
||||||
input.name,
|
input.name,
|
||||||
input.domain,
|
normalizeProjectDomain(input.domain),
|
||||||
resolveMarketInput(input),
|
resolveMarketInput(input),
|
||||||
);
|
);
|
||||||
return mapProject(row);
|
return mapProject(row);
|
||||||
@ -113,7 +133,7 @@ export async function updateProject(
|
|||||||
organizationId,
|
organizationId,
|
||||||
{
|
{
|
||||||
name: input.name,
|
name: input.name,
|
||||||
domain: input.domain,
|
domain: normalizeProjectDomain(input.domain),
|
||||||
market: resolveMarketInput(input),
|
market: resolveMarketInput(input),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -126,6 +146,27 @@ export async function updateProject(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a project's domain on its own, for the dashboard hero's inline input.
|
||||||
|
* Writing just this column keeps the write from echoing a name/market the
|
||||||
|
* caller never edited.
|
||||||
|
*/
|
||||||
|
export async function setProjectDomain(
|
||||||
|
organizationId: string,
|
||||||
|
input: SetProjectDomainInput,
|
||||||
|
) {
|
||||||
|
const domain = normalizeProjectDomain(input.domain);
|
||||||
|
if (domain === undefined) {
|
||||||
|
throw new AppError("VALIDATION_ERROR", "Enter a valid domain.");
|
||||||
|
}
|
||||||
|
const row = await ProjectRepository.updateProjectDomain(
|
||||||
|
input.projectId,
|
||||||
|
organizationId,
|
||||||
|
domain,
|
||||||
|
);
|
||||||
|
return mapProject(row);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a project's default market on its own, for surfaces that only ask for
|
* Sets a project's default market on its own, for surfaces that only ask for
|
||||||
* the market (onboarding). Writing just these two columns keeps the write from
|
* the market (onboarding). Writing just these two columns keeps the write from
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { AppError } from "@/server/lib/errors";
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
captureServerError: vi.fn(),
|
captureServerError: vi.fn(),
|
||||||
captureServerEvent: vi.fn(),
|
captureServerEvent: vi.fn(),
|
||||||
|
recordExternalMcpToolCall: vi.fn(),
|
||||||
incrementSelfHostMcpToolCallCount: vi.fn(),
|
incrementSelfHostMcpToolCallCount: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -24,6 +25,12 @@ vi.mock("@/server/lib/posthog", () => ({
|
|||||||
captureServerEvent: mocks.captureServerEvent,
|
captureServerEvent: mocks.captureServerEvent,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The real module pulls in @/db (cloudflare:workers env) — mock it out and
|
||||||
|
// assert the milestone hook at this boundary instead.
|
||||||
|
vi.mock("@/server/features/activation/mcpActivation", () => ({
|
||||||
|
recordExternalMcpToolCall: mocks.recordExternalMcpToolCall,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/server/lib/self-host-telemetry", () => ({
|
vi.mock("@/server/lib/self-host-telemetry", () => ({
|
||||||
incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount,
|
incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount,
|
||||||
}));
|
}));
|
||||||
@ -56,6 +63,7 @@ describe("instrumentMcpToolHandler", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.captureServerError.mockReset();
|
mocks.captureServerError.mockReset();
|
||||||
mocks.captureServerEvent.mockReset();
|
mocks.captureServerEvent.mockReset();
|
||||||
|
mocks.recordExternalMcpToolCall.mockReset();
|
||||||
mocks.incrementSelfHostMcpToolCallCount.mockReset();
|
mocks.incrementSelfHostMcpToolCallCount.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -172,5 +180,45 @@ describe("instrumentMcpToolHandler", () => {
|
|||||||
await wrapped({}, toolExtra);
|
await wrapped({}, toolExtra);
|
||||||
|
|
||||||
expect(mocks.captureServerEvent).not.toHaveBeenCalled();
|
expect(mocks.captureServerEvent).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the activation milestone for a successful external call", async () => {
|
||||||
|
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||||
|
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
|
||||||
|
okResult({ items: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
|
||||||
|
|
||||||
|
expect(mocks.recordExternalMcpToolCall).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
"org-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the activation milestone for first-party (null clientId) calls", async () => {
|
||||||
|
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||||
|
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
|
||||||
|
okResult({ items: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runWithMcpToolAuthContext({ ...authContext, clientId: null }, () =>
|
||||||
|
wrapped({}, toolExtra),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the activation milestone when the call fails", async () => {
|
||||||
|
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||||
|
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
|
||||||
|
throw new AppError("NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)),
|
||||||
|
).rejects.toThrow("NOT_FOUND");
|
||||||
|
|
||||||
|
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
type ZodRawShapeCompat,
|
type ZodRawShapeCompat,
|
||||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||||
import { asAppError } from "@/server/lib/errors";
|
import { asAppError } from "@/server/lib/errors";
|
||||||
|
import { recordExternalMcpToolCall } from "@/server/features/activation/mcpActivation";
|
||||||
import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
|
import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
|
||||||
import { getAuth, type ToolExtra } from "@/server/mcp/context";
|
import { getAuth, type ToolExtra } from "@/server/mcp/context";
|
||||||
@ -114,6 +115,21 @@ export function instrumentMcpToolHandler<TArgs>(
|
|||||||
? { success: false, errorCode: "MCP_OUTPUT_VALIDATION" }
|
? { success: false, errorCode: "MCP_OUTPUT_VALIDATION" }
|
||||||
: { success: !result.isError },
|
: { success: !result.isError },
|
||||||
);
|
);
|
||||||
|
// Dashboard activation milestone: a successful call from an external
|
||||||
|
// MCP client (OAuth clientId; SAM and the self-hosted transport are
|
||||||
|
// first-party with clientId null). Awaited so the write stays inside
|
||||||
|
// the request's DB scope; a per-isolate memo keeps this off the hot
|
||||||
|
// path after the first call.
|
||||||
|
if (!result.isError && !outputValidationFailed) {
|
||||||
|
try {
|
||||||
|
const auth = getAuth(extra);
|
||||||
|
if (auth.clientId) {
|
||||||
|
await recordExternalMcpToolCall(auth.organizationId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no auth context — skip milestone tracking
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const appError = asAppError(error);
|
const appError = asAppError(error);
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
MCP_SCOPE,
|
MCP_SCOPE,
|
||||||
} from "@/lib/oauth-resource";
|
} from "@/lib/oauth-resource";
|
||||||
import { asAppError } from "@/server/lib/errors";
|
import { asAppError } from "@/server/lib/errors";
|
||||||
|
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
|
||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import {
|
import {
|
||||||
createWorkersOAuthMcpProps,
|
createWorkersOAuthMcpProps,
|
||||||
@ -366,6 +367,8 @@ async function handleOAuthConsentResponse(
|
|||||||
props,
|
props,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await recordMcpAuthorized(context.organizationId);
|
||||||
|
|
||||||
waitUntil(
|
waitUntil(
|
||||||
captureServerEvent({
|
captureServerEvent({
|
||||||
distinctId: context.userId,
|
distinctId: context.userId,
|
||||||
|
|||||||
63
src/serverFunctions/dashboard.ts
Normal file
63
src/serverFunctions/dashboard.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
|
||||||
|
import { DashboardService } from "@/server/features/dashboard/services/DashboardService";
|
||||||
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||||
|
import { dashboardProjectInputSchema } from "@/types/schemas/dashboard";
|
||||||
|
|
||||||
|
export const getDashboardActivation = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.validator(dashboardProjectInputSchema)
|
||||||
|
.handler(({ context }) =>
|
||||||
|
DashboardService.getActivation({
|
||||||
|
projectId: context.projectId,
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
domain: context.project.domain,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getDashboardOverview = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.validator(dashboardProjectInputSchema)
|
||||||
|
.handler(({ context }) =>
|
||||||
|
DashboardService.getOverview({
|
||||||
|
projectId: context.projectId,
|
||||||
|
domain: context.project.domain,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Visit-triggered: the client calls this when the overview reports a missing
|
||||||
|
// or stale backlink snapshot. Metered against org credits at most once per
|
||||||
|
// project per day (the service re-checks freshness server-side).
|
||||||
|
export const refreshDashboardBacklinkSnapshot = createServerFn({
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.validator(dashboardProjectInputSchema)
|
||||||
|
.handler(({ context }) =>
|
||||||
|
DashboardService.ensureBacklinkSnapshot({
|
||||||
|
projectId: context.projectId,
|
||||||
|
domain: context.project.domain,
|
||||||
|
billingCustomer: context,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const markDashboardCompetitorClicked = createServerFn({
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.validator(dashboardProjectInputSchema)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
await ActivationRepository.markCompetitorStepClicked(context.projectId);
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
// "I already connected" on the MCP card. Hides the card for this project;
|
||||||
|
// the org-level milestone stays untouched and self-corrects on the next
|
||||||
|
// real external tool call.
|
||||||
|
export const dismissDashboardMcpCard = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.validator(dashboardProjectInputSchema)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
await ActivationRepository.markMcpCardDismissed(context.projectId);
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
archiveProjectSchema,
|
archiveProjectSchema,
|
||||||
createProjectSchema,
|
createProjectSchema,
|
||||||
restoreProjectSchema,
|
restoreProjectSchema,
|
||||||
|
setProjectDomainSchema,
|
||||||
setProjectMarketSchema,
|
setProjectMarketSchema,
|
||||||
updateProjectSchema,
|
updateProjectSchema,
|
||||||
} from "@/types/schemas/projects";
|
} from "@/types/schemas/projects";
|
||||||
@ -35,6 +36,13 @@ export const updateProject = createServerFn({ method: "POST" })
|
|||||||
ProjectService.updateProject(context.organizationId, data),
|
ProjectService.updateProject(context.organizationId, data),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const setProjectDomain = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.validator(setProjectDomainSchema)
|
||||||
|
.handler(async ({ data, context }) =>
|
||||||
|
ProjectService.setProjectDomain(context.organizationId, data),
|
||||||
|
);
|
||||||
|
|
||||||
export const setProjectMarket = createServerFn({ method: "POST" })
|
export const setProjectMarket = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(setProjectMarketSchema)
|
.validator(setProjectMarketSchema)
|
||||||
|
|||||||
13
src/types/schemas/dashboard.ts
Normal file
13
src/types/schemas/dashboard.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const dashboardHeroStepSchema = z.enum([
|
||||||
|
"domain",
|
||||||
|
"mcp",
|
||||||
|
"gsc",
|
||||||
|
"competitor",
|
||||||
|
]);
|
||||||
|
export type DashboardHeroStep = z.infer<typeof dashboardHeroStepSchema>;
|
||||||
|
|
||||||
|
export const dashboardProjectInputSchema = z.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
});
|
||||||
@ -63,6 +63,13 @@ export const updateProjectSchema = z
|
|||||||
})
|
})
|
||||||
.refine(hasLocationForLanguage, marketPairMessage);
|
.refine(hasLocationForLanguage, marketPairMessage);
|
||||||
|
|
||||||
|
// Domain on its own, for the dashboard hero's inline input. Same loose shape
|
||||||
|
// as updateProjectSchema's domain field, but required.
|
||||||
|
export const setProjectDomainSchema = z.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
domain: z.string().trim().min(1).max(255),
|
||||||
|
});
|
||||||
|
|
||||||
// Market-only update (onboarding). Both halves are required: the caller picks
|
// Market-only update (onboarding). Both halves are required: the caller picks
|
||||||
// them together, so the service can validate the pair without a stored row.
|
// them together, so the service can validate the pair without a stored row.
|
||||||
export const setProjectMarketSchema = z.object({
|
export const setProjectMarketSchema = z.object({
|
||||||
@ -89,6 +96,7 @@ export const restoreProjectSchema = z.object({
|
|||||||
|
|
||||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||||
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||||
|
export type SetProjectDomainInput = z.infer<typeof setProjectDomainSchema>;
|
||||||
export type SetProjectMarketInput = z.infer<typeof setProjectMarketSchema>;
|
export type SetProjectMarketInput = z.infer<typeof setProjectMarketSchema>;
|
||||||
export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
|
export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
|
||||||
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;
|
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user