Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/client/features/keywords/hooks/usePreferredKeywordLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,31 @@ function savePreferredLocationCode(locationCode: number) {
}
}

export function usePreferredKeywordLocation() {
const [preferredLocationCode, setPreferredLocationCodeState] = useState(
DEFAULT_LOCATION_CODE,
/**
* Preference order: the user's explicit choice (persisted per browser) >
* the project's default market (may arrive async from the projects query) >
* the US fallback.
*/
export function usePreferredKeywordLocation(
projectDefaultLocationCode?: number,
) {
const [chosenLocationCode, setChosenLocationCode] = useState<number | null>(
null,
);

useEffect(() => {
const savedLocationCode = loadPreferredLocationCode();
if (savedLocationCode != null) {
setPreferredLocationCodeState(savedLocationCode);
setChosenLocationCode(savedLocationCode);
}
}, []);

const preferredLocationCode =
chosenLocationCode ?? projectDefaultLocationCode ?? DEFAULT_LOCATION_CODE;

function setPreferredLocationCode(locationCode: number) {
if (!isSupportedLocationCode(locationCode)) return;
setPreferredLocationCodeState(locationCode);
setChosenLocationCode(locationCode);
savePreferredLocationCode(locationCode);
}

Expand Down
14 changes: 12 additions & 2 deletions src/client/features/keywords/state/keywordControllerInternals.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation";
import { saveKeywords } from "@/serverFunctions/keywords";
import { getProjects } from "@/serverFunctions/projects";
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
import type { KeywordResearchRow } from "@/types/keywords";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";

export function useResolvedKeywordLocation(
input: KeywordResearchControllerInput,
) {
// Seed the picker from the project's default market (cached under
// ["projects"], shared with the switcher/settings) until the user picks one.
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
});
const projectDefaultLocationCode = projectsQuery.data?.find(
(project) => project.id === input.projectId,
)?.locationCode;
const { preferredLocationCode, setPreferredLocationCode } =
usePreferredKeywordLocation();
usePreferredKeywordLocation(projectDefaultLocationCode);
const locationCode =
!input.hasExplicitLocationCode && input.keywordInput === ""
? preferredLocationCode
Expand Down
20 changes: 19 additions & 1 deletion src/client/features/projects/CreateProjectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,30 @@ import * as React from "react";
import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { LocationSelect } from "@/client/components/LocationSelect";
import { Modal } from "@/client/components/Modal";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { setLastProjectId } from "@/client/lib/active-project";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { createProject } from "@/serverFunctions/projects";

export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [name, setName] = React.useState("");
const [domain, setDomain] = React.useState("");
const [locationCode, setLocationCode] = React.useState(DEFAULT_LOCATION_CODE);

const createMutation = useMutation({
mutationFn: () =>
createProject({
data: { name: name.trim(), domain: domain.trim() || undefined },
// Language auto-derives server-side from the location's native
// language; the settings page offers the full per-location list.
data: {
name: name.trim(),
domain: domain.trim() || undefined,
locationCode,
},
}),
onSuccess: async (created) => {
setLastProjectId(created.id);
Expand Down Expand Up @@ -88,6 +97,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</span>
</label>

<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Default market</span>
<LocationSelect value={locationCode} onChange={setLocationCode} />
<span className="text-xs text-base-content/50">
Keyword and SERP data defaults to this market when a call doesn't
specify one. Change it later in project settings.
</span>
</label>

<div className="flex justify-end gap-2">
<button
type="button"
Expand Down
50 changes: 49 additions & 1 deletion src/client/features/projects/ProjectSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft } from "lucide-react";
import { toast } from "sonner";
import { LocationSelect } from "@/client/components/LocationSelect";
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
import {
getLanguageCode,
getLanguageOptions,
} from "@/client/features/keywords/locations";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
clearLastProjectId,
Expand Down Expand Up @@ -69,6 +74,17 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
const queryClient = useQueryClient();
const [name, setName] = React.useState(project.name);
const [domain, setDomain] = React.useState(project.domain ?? "");
const [locationCode, setLocationCode] = React.useState(project.locationCode);
const [languageCode, setLanguageCode] = React.useState(project.languageCode);

const languageOptions = getLanguageOptions(locationCode);

const handleLocationChange = (code: number) => {
setLocationCode(code);
// Snap to the new location's native language; the select below still
// offers the location's full served list.
setLanguageCode(getLanguageCode(code));
};

const updateMutation = useMutation({
mutationFn: () =>
Expand All @@ -77,6 +93,8 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
projectId: project.id,
name: name.trim(),
domain: domain.trim() || undefined,
locationCode,
languageCode,
},
}),
onSuccess: async () => {
Expand All @@ -89,7 +107,9 @@ function GeneralSection({ project }: { project: ProjectSummary }) {

const isDirty =
name.trim() !== project.name ||
(domain.trim() || "") !== (project.domain ?? "");
(domain.trim() || "") !== (project.domain ?? "") ||
locationCode !== project.locationCode ||
languageCode !== project.languageCode;

const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
Expand Down Expand Up @@ -130,6 +150,34 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
/>
</label>

<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Default market</span>
<LocationSelect
value={locationCode}
onChange={handleLocationChange}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Language</span>
<select
value={languageCode}
onChange={(event) => setLanguageCode(event.target.value)}
className="select select-bordered w-full"
>
{languageOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</label>
<span className="col-span-full text-xs text-base-content/50">
Keyword, SERP, and domain data defaults to this market when a call
doesn't specify one.
</span>
</div>

<div className="flex justify-end">
<button
type="submit"
Expand Down
3 changes: 3 additions & 0 deletions src/client/features/projects/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ export type ProjectSummary = {
id: string;
name: string;
domain: string | null;
// Default market for the project's data calls.
locationCode: number;
languageCode: string;
createdAt: string;
};
15 changes: 12 additions & 3 deletions src/server/features/projects/repositories/ProjectRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,23 +61,32 @@ async function createProject(
organizationId: string,
name: string,
domain?: string,
// An absent half keeps the column default; spreading writes only the
// provided columns.
market?: { locationCode?: number; languageCode: string },
) {
const id = crypto.randomUUID();
const [row] = await db
.insert(projects)
.values({ id, organizationId, name, domain })
.values({ id, organizationId, name, domain, ...market })
.returning();
return row;
}

async function updateProject(
projectId: string,
organizationId: string,
input: { name: string; domain?: string },
input: {
name: string;
domain?: string;
// An absent half leaves the column untouched (a language-only change
// must not rewrite the location from a pre-read snapshot).
market?: { locationCode?: number; languageCode: string };
},
) {
const [row] = await db
.update(projects)
.set({ name: input.name, domain: input.domain ?? null })
.set({ name: input.name, domain: input.domain ?? null, ...input.market })
.where(
and(
eq(projects.id, projectId),
Expand Down
75 changes: 75 additions & 0 deletions src/server/features/projects/services/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,40 @@ describe("project service", () => {
"org_1",
"Acme",
"acme.com",
undefined,
);
});

it("derives the native language when only the location is given", async () => {
mocks.createProject.mockResolvedValue(namedProject);
const { createProject } = await import("./projects");

await createProject("org_1", {
name: "Acme",
domain: "acme.com",
locationCode: 2704,
});
expect(mocks.createProject).toHaveBeenCalledWith(
"org_1",
"Acme",
"acme.com",
{ locationCode: 2704, languageCode: "vi" },
);
});

it("rejects a language DataForSEO does not serve for the location", async () => {
const { createProject } = await import("./projects");

await expect(
createProject("org_1", {
name: "Acme",
locationCode: 2840,
languageCode: "vi",
}),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.createProject).not.toHaveBeenCalled();
});

it("maps the reserved Default conflict to a friendly CONFLICT", async () => {
mocks.createProject.mockRejectedValue(
new Error(
Expand All @@ -107,6 +138,50 @@ describe("project service", () => {
});

describe("updateProject", () => {
it("validates a language-only change against the stored location and writes only the language", async () => {
mocks.getProjectForOrganization.mockResolvedValue({
...namedProject,
locationCode: 2704,
languageCode: "vi",
});
mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects");

await updateProject("org_1", {
projectId: "project_acme",
name: "Acme",
languageCode: "en",
});
// No locationCode: echoing the pre-read location back into the write
// would let a concurrent location change be silently overwritten.
expect(mocks.updateProject).toHaveBeenCalledWith(
"project_acme",
"org_1",
expect.objectContaining({
market: { languageCode: "en" },
}),
);
});

it("snaps the language on a location-only change without reading the stored row", async () => {
mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects");

await updateProject("org_1", {
projectId: "project_acme",
name: "Acme",
locationCode: 2276,
});
expect(mocks.getProjectForOrganization).not.toHaveBeenCalled();
expect(mocks.updateProject).toHaveBeenCalledWith(
"project_acme",
"org_1",
expect.objectContaining({
market: { locationCode: 2276, languageCode: "de" },
}),
);
});

it("returns the updated project", async () => {
mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects");
Expand Down
Loading