Skip to content

Commit c399d42

Browse files
chore(release): v0.10.0
GitOrigin-RevId: cd32cc0ef6ebaf75cccfea832fd025394e3db947
1 parent f843561 commit c399d42

282 files changed

Lines changed: 4851 additions & 4100 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.storybook/browser-runtime-stubs.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ export async function updatePresenceInspectionBudget(input: {
5454
};
5555
}
5656

57+
export async function loadStoredGoogleProperties(input: { provider: "ga4" | "gsc" }) {
58+
return {
59+
preferredProperty: input.provider === "gsc" ? "sc-domain:example.com" : "properties/123456",
60+
properties: [],
61+
provider: input.provider,
62+
};
63+
}
64+
65+
export async function saveStoredGoogleProperty(input: { property: string }) {
66+
return { property: input.property, status: "saved" as const };
67+
}
68+
5769
export default function Link({ children, href = "#", ...props }: LinkProps) {
5870
return React.createElement("a", { ...props, href }, children);
5971
}

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## Unreleased
44

5+
## [0.10.0] - 2026-08-12
6+
7+
- Streamlined onboarding with one-field website setup, unified data connections, prefilled provider costs, clearer Search Console actions, keyword imports, and tracking defaults.
8+
9+
- Added property switching for connected Google Search Console and Analytics sources without requiring account reconnection.
10+
11+
- Renamed the dashboard keyword workspace to Rank Tracker at `/rank-tracker`, standardized the Keyword Research title, and made operator Slack alerts easier to scan.
12+
13+
- Added a Discord community link to the signed-in user menu.
14+
515
## [0.9.0] - 2026-08-11
616

717
- Improved Google Search Console and Analytics reliability with reconnect notifications, paused sync during reauthorization, and correct OAuth project callbacks.

app/(auth)/login/page.test.tsx

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { redirect } from "@/tests/next-navigation";
12
import { renderToStaticMarkup } from "react-dom/server";
23
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
import { dynamic } from "./page";
@@ -7,7 +8,6 @@ const mocks = vi.hoisted(() => ({
78
getSession: vi.fn(),
89
getSignInCapacity: vi.fn(),
910
loginForm: vi.fn(),
10-
redirect: vi.fn(),
1111
}));
1212

1313
vi.mock("server-only", () => ({}));
@@ -24,10 +24,6 @@ vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession }));
2424
vi.mock("@/lib/auth/auth", () => {
2525
throw new Error("The login page must not initialize the full auth server");
2626
});
27-
vi.mock("next/navigation", async (importOriginal) => ({
28-
...(await importOriginal<typeof import("next/navigation")>()),
29-
redirect: mocks.redirect,
30-
}));
3127
vi.mock("@/components/auth/LoginForm", () => ({
3228
LoginForm: (props: Record<string, unknown>) => {
3329
mocks.loginForm(props);
@@ -97,7 +93,6 @@ async function renderLoginPage(
9793

9894
beforeEach(() => {
9995
mocks.getSession.mockResolvedValue(null);
100-
mocks.redirect.mockClear();
10196
});
10297

10398
afterEach(() => {
@@ -205,12 +200,12 @@ describe("login page runtime rendering", () => {
205200
const selfHosted = await renderLoginPage({
206201
DEPLOYMENT_MODE: "self-host",
207202
LEGAL_PRIVACY_URL: "/operator-privacy",
208-
LEGAL_TERMS_URL: "https://operator.example/terms",
203+
LEGAL_TERMS_URL: "https://operator.example.com/terms",
209204
});
210205

211206
expect(selfHosted.props.legalConsentLinks).toEqual({
212207
privacyHref: "/operator-privacy",
213-
termsHref: "https://operator.example/terms",
208+
termsHref: "https://operator.example.com/terms",
214209
});
215210
});
216211

@@ -275,33 +270,33 @@ describe("session-aware sign in", () => {
275270
it("redirects a signed-in visitor to the default home", async () => {
276271
mocks.getSession.mockResolvedValue({ user: { id: "usr_1" } });
277272
await renderLoginPage({}, {});
278-
expect(mocks.redirect).toHaveBeenCalledWith("/app");
273+
expect(redirect).toHaveBeenCalledWith("/app");
279274
expect(mocks.loginForm).not.toHaveBeenCalled();
280275
});
281276

282277
it("honors a validated next destination", async () => {
283278
mocks.getSession.mockResolvedValue({ user: { id: "usr_1" } });
284279
await renderLoginPage({}, { next: "/cloud/import" });
285-
expect(mocks.redirect).toHaveBeenCalledWith("/cloud/import");
280+
expect(redirect).toHaveBeenCalledWith("/cloud/import");
286281
});
287282

288283
it("rejects an off-origin next destination", async () => {
289284
mocks.getSession.mockResolvedValue({ user: { id: "usr_1" } });
290285
await renderLoginPage({}, { next: "https://evil.example.com/steal" });
291-
expect(mocks.redirect).toHaveBeenCalledWith("/app");
286+
expect(redirect).toHaveBeenCalledWith("/app");
292287
});
293288

294289
it("renders the form for an explicit account switch", async () => {
295290
mocks.getSession.mockResolvedValue({ user: { id: "usr_1" } });
296291
await renderLoginPage({}, { switch: "1" });
297-
expect(mocks.redirect).not.toHaveBeenCalled();
292+
expect(redirect).not.toHaveBeenCalled();
298293
expect(mocks.loginForm).toHaveBeenCalled();
299294
});
300295

301296
it("renders the form with no session", async () => {
302297
mocks.getSession.mockResolvedValue(null);
303298
await renderLoginPage({}, {});
304-
expect(mocks.redirect).not.toHaveBeenCalled();
299+
expect(redirect).not.toHaveBeenCalled();
305300
expect(mocks.loginForm).toHaveBeenCalled();
306301
});
307302
});

app/(auth)/two-factor/page.test.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import { redirect } from "@/tests/next-navigation";
12
import type { ReactNode } from "react";
23
import { renderToStaticMarkup } from "react-dom/server";
34
import { beforeEach, describe, expect, it, vi } from "vitest";
45

56
const mocks = vi.hoisted(() => ({
67
getSession: vi.fn(),
7-
redirect: vi.fn(),
88
twoFactorChallengeForm: vi.fn(),
99
}));
1010

@@ -20,7 +20,6 @@ vi.mock("next/link", () => ({
2020
<a href={href}>{children}</a>
2121
),
2222
}));
23-
vi.mock("next/navigation", () => ({ redirect: mocks.redirect }));
2423

2524
import TwoFactorPage from "./page";
2625

@@ -53,14 +52,14 @@ describe("two-factor page", () => {
5352
it("redirects an active session to the validated destination", async () => {
5453
const returnTo = "/oauth/consent?client_id=client_1&scope=openid";
5554
mocks.getSession.mockResolvedValue({ user: { id: "user_1" } });
56-
mocks.redirect.mockImplementation((destination: string) => {
55+
redirect.mockImplementation((destination: string) => {
5756
throw new Error(`redirect:${destination}`);
5857
});
5958

6059
await expect(
6160
TwoFactorPage({ searchParams: Promise.resolve({ next: returnTo }) }),
6261
).rejects.toThrow(`redirect:${returnTo}`);
63-
expect(mocks.redirect).toHaveBeenCalledWith(returnTo);
62+
expect(redirect).toHaveBeenCalledWith(returnTo);
6463
expect(mocks.twoFactorChallengeForm).not.toHaveBeenCalled();
6564
});
6665
});

app/app/(workspace)/[project]/alerts/page.test.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@ vi.mock("@/lib/queries/integrations", () => ({
2828
isProviderConnected: mocks.isProviderConnected,
2929
}));
3030
vi.mock("@/lib/queries/workspaces", () => ({ listWorkspaces: mocks.listWorkspaces }));
31-
vi.mock("next/navigation", () => ({
32-
notFound: vi.fn(),
33-
useRouter: () => ({ refresh: vi.fn() }),
34-
}));
3531

3632
describe("AlertsPage", () => {
3733
beforeEach(() => {
@@ -78,7 +74,7 @@ describe("AlertsPage", () => {
7874
);
7975

8076
const action = screen.getByRole("link", { name: /add keyword/i });
81-
expect(action).toHaveAttribute("href", appPath("prj_abcdefghijklmnopqrstuvwx", "keywords"));
77+
expect(action).toHaveAttribute("href", appPath("prj_abcdefghijklmnopqrstuvwx", "rank-tracker"));
8278
expect(screen.queryByRole("button", { name: /create a rule/i })).not.toBeInTheDocument();
8379
expect(mocks.getAlertsView).toHaveBeenCalledWith("prj_abcdefghijklmnopqrstuvwx");
8480
expect(mocks.getAlertFeedStats).toHaveBeenCalledWith("project_1");

app/app/(workspace)/[project]/competitors/page.test.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,6 @@ vi.mock("@/lib/actions/saved-views", () => ({
2626
deleteSavedView: vi.fn(),
2727
}));
2828
vi.mock("@/lib/queries/workspaces", () => ({ listWorkspaces: mocks.listWorkspaces }));
29-
vi.mock("next/navigation", () => ({
30-
notFound: vi.fn(),
31-
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
32-
}));
3329

3430
describe("CompetitorsPage", () => {
3531
beforeEach(() => {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { ProviderActionHandlers } from "@/lib/integrations/types";
2+
import { render } from "@testing-library/react";
3+
import type { ReactNode } from "react";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import IntegrationsPage from "./page";
6+
7+
const mocks = vi.hoisted(() => ({
8+
completeGooglePropertySelection: vi.fn(),
9+
loadStoredGoogleProperties: vi.fn(),
10+
getIntegrationsView: vi.fn(),
11+
integrationCategory: vi.fn(),
12+
requireReadableProject: vi.fn(),
13+
resolveProjectAccess: vi.fn(),
14+
saveStoredGoogleProperty: vi.fn(),
15+
}));
16+
17+
vi.mock("@/components/integrations/IntegrationCategory", () => ({
18+
IntegrationCategory: (props: unknown) => {
19+
mocks.integrationCategory(props);
20+
return null;
21+
},
22+
}));
23+
vi.mock("@/components/shell/PageContent", () => ({
24+
PageContent: ({ children }: { children: ReactNode }) => children,
25+
}));
26+
vi.mock("@/components/ui", () => ({
27+
Card: ({ children }: { children: ReactNode }) => children,
28+
}));
29+
vi.mock("@/lib/actions/providers", () => ({
30+
completeGooglePropertySelection: mocks.completeGooglePropertySelection,
31+
connectProvider: vi.fn(),
32+
disconnectProvider: vi.fn(),
33+
loadStoredGoogleProperties: mocks.loadStoredGoogleProperties,
34+
saveStoredGoogleProperty: mocks.saveStoredGoogleProperty,
35+
testConnection: vi.fn(),
36+
updateProviderCost: vi.fn(),
37+
updateProviderRate: vi.fn(),
38+
updateProviderSettings: vi.fn(),
39+
}));
40+
vi.mock("@/lib/actions/traffic-sync", () => ({ syncProjectTraffic: vi.fn() }));
41+
vi.mock("@/lib/auth/authorize", () => ({ getProjectRole: () => "owner" }));
42+
vi.mock("@/lib/auth/capabilities", () => ({ canProjectAction: () => true }));
43+
vi.mock("@/lib/providers/analytics/google-oauth-pending", () => ({
44+
getPendingGoogleOAuthSetup: vi.fn(),
45+
}));
46+
vi.mock("@/lib/queries/_auth", () => ({
47+
requireReadableProject: mocks.requireReadableProject,
48+
resolveProjectAccess: mocks.resolveProjectAccess,
49+
}));
50+
vi.mock("@/lib/queries/integrations", () => ({
51+
getIntegrationsView: mocks.getIntegrationsView,
52+
}));
53+
vi.mock("@phosphor-icons/react/dist/ssr", () => ({ KeyIcon: () => null }));
54+
55+
describe("IntegrationsPage", () => {
56+
beforeEach(() => {
57+
vi.clearAllMocks();
58+
mocks.resolveProjectAccess.mockResolvedValue({
59+
publicId: "prj_abcdefghijklmnopqrstuvwx",
60+
});
61+
mocks.requireReadableProject.mockResolvedValue({
62+
actor: { id: "user_1" },
63+
project: { id: "project_1" },
64+
});
65+
mocks.getIntegrationsView.mockResolvedValue({
66+
categories: [{ id: "analytics", items: [], title: "Analytics" }],
67+
connectionCount: 0,
68+
});
69+
});
70+
71+
it("wires verified Google property selection into integration drawers", async () => {
72+
render(
73+
await IntegrationsPage({
74+
params: Promise.resolve({ project: "prj_abcdefghijklmnopqrstuvwx" }),
75+
}),
76+
);
77+
78+
const props = mocks.integrationCategory.mock.calls[0]?.[0] as {
79+
actions: ProviderActionHandlers;
80+
};
81+
expect(props.actions.completeGooglePropertySelection).toBe(
82+
mocks.completeGooglePropertySelection,
83+
);
84+
expect(props.actions.loadStoredGoogleProperties).toBe(mocks.loadStoredGoogleProperties);
85+
expect(props.actions.saveStoredGoogleProperty).toBe(mocks.saveStoredGoogleProperty);
86+
});
87+
});

app/app/(workspace)/[project]/integrations/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { IntegrationCategory } from "@/components/integrations/IntegrationCatego
22
import { PageContent } from "@/components/shell/PageContent";
33
import { Card } from "@/components/ui";
44
import {
5+
completeGooglePropertySelection,
56
connectProvider,
67
disconnectProvider,
8+
loadStoredGoogleProperties,
9+
saveStoredGoogleProperty,
710
testConnection,
811
updateProviderCost,
912
updateProviderRate,
@@ -19,15 +22,21 @@ import { requireReadableProject, resolveProjectAccess } from "@/lib/queries/_aut
1922
import { getIntegrationsView } from "@/lib/queries/integrations";
2023
import { KeyIcon as Key } from "@phosphor-icons/react/dist/ssr";
2124

25+
type IntegrationsProviderActions = ProviderActionHandlers &
26+
Required<Pick<ProviderActionHandlers, "completeGooglePropertySelection">>;
27+
2228
const providerActions = {
29+
completeGooglePropertySelection,
2330
connectProvider,
2431
disconnectProvider,
32+
loadStoredGoogleProperties,
33+
saveStoredGoogleProperty,
2534
syncProjectTraffic,
2635
testProviderConnection: testConnection,
2736
updateProviderSettings,
2837
updateProviderCost,
2938
updateProviderRate,
30-
} satisfies ProviderActionHandlers;
39+
} satisfies IntegrationsProviderActions;
3140

3241
type IntegrationsPageProps = {
3342
params: Promise<{ project: string }>;

app/app/(workspace)/[project]/overview/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ function OverviewSections({
138138
<Button
139139
component={Link}
140140
endIcon={<CaretRight size={15} weight="bold" />}
141-
href={appPath(projectRef, "keywords")}
141+
href={appPath(projectRef, "rank-tracker")}
142142
sx={{
143143
alignSelf: "flex-start",
144144
minHeight: 38,

app/app/(workspace)/[project]/keywords/[id]/loading.test.ts renamed to app/app/(workspace)/[project]/rank-tracker/[id]/loading.test.ts

File renamed without changes.

0 commit comments

Comments
 (0)