Skip to content

Commit

Permalink
Fix bugs, improve loading, add features
Browse files Browse the repository at this point in the history
  • Loading branch information
Sakib25800 committed May 11, 2024
1 parent 2f46dc3 commit d89e63b
Show file tree
Hide file tree
Showing 28 changed files with 1,754 additions and 935 deletions.
765 changes: 733 additions & 32 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions plugins/semrush/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"react-dom": "^18",
"react-error-boundary": "^4.0.13",
"react-intersection-observer": "^9.10.1",
"react-query": "^3.39.3",
"recharts": "^2.12.6",
"usehooks-ts": "^3.1.0",
"valibot": "^0.30.0",
Expand Down
24 changes: 21 additions & 3 deletions plugins/semrush/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { useRef } from "react";
import { useEffect, useRef } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { usePluginResizeObserver } from "./hooks/usePluginResizeObserver";
import { useResizeObserver } from "usehooks-ts";
import { routes } from "./routes";
import { useNavigationStore } from "./stores/navigationStore";
import { PluginContainer } from "./components/PluginContainer";
import { framer } from "framer-plugin";

function usePluginResizeObserver(ref: React.RefObject<HTMLDivElement>) {
const { width = 260, height = 95 } = useResizeObserver({
ref,
box: "content-box",
});

useEffect(() => {
framer.showUI({
title: "Semrush",
width,
height,
});
}, [width, height]);

return { width, height };
}

export function App() {
const pathname = useNavigationStore((state) => state.pathname);
Expand All @@ -24,7 +42,7 @@ export function App() {
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<div className="col-lg items-center min-w-[200px]">
<div className="col-lg justify-center items-center w-full">
<p className="text-framer-red">{error.message}</p>
<button
className="bg-transparent hover:bg-transparent active:bg-transparent text-blue-600 outline-none"
Expand Down
250 changes: 250 additions & 0 deletions plugins/semrush/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import SemrushClient, { Issue } from "./semrush";
import { useEffect, useState } from "react";
import { ISSUE_DESCRIPTIONS } from "./constants";
import { useSemrushProjectStore } from "./stores/semrushProjectStore";
import { framer } from "framer-plugin";

const semrush = new SemrushClient({
apiKey: useSemrushProjectStore.getState().apiKey,
projectId: useSemrushProjectStore.getState().projectId,
});

function formatNumWithMetricPrefix(num: number) {
return Intl.NumberFormat("en-US", {
maximumFractionDigits: 1,
notation: "compact",
}).format(num);
}

function timeAgo(isoDate: number) {
const date = new Date(isoDate);
const formatter = new Intl.RelativeTimeFormat("en");
const ranges = [
["years", 3600 * 24 * 365],
["months", 3600 * 24 * 30],
["weeks", 3600 * 24 * 7],
["days", 3600 * 24],
["hours", 3600],
["minutes", 60],
["seconds", 1],
] as const;
const secondsElapsed = (date.getTime() - Date.now()) / 1000;

for (const [rangeType, rangeVal] of ranges) {
if (rangeVal < Math.abs(secondsElapsed)) {
const delta = secondsElapsed / rangeVal;
return formatter.format(Math.round(delta), rangeType);
}
}
}

/**
* Removes unrelated issues and annotates the rest with their type and description
*/
function annotateIssues(issues: Issue[], type: "error" | "warning" | "notice") {
const applicableAuditIssues = issues.filter((issue) => issue.count > 0);
return applicableAuditIssues.map((issue) => ({
...issue,
type,
description: ISSUE_DESCRIPTIONS[String(issue.id)] || issue.id,
}));
}

const transformKeywordRow = (
cell: Awaited<ReturnType<typeof semrush.keywordSearch>>[0]
) => ({
keyword: cell.Keyword,
searchVolume: formatNumWithMetricPrefix(Number(cell["Search Volume"])),
trends: cell.Trends.split(","),
cpc: cell.CPC,
difficulty: cell["Keyword Difficulty Index"],
totalResults: formatNumWithMetricPrefix(Number(cell["Number of Results"])),
// Intent is the only possible empty cell
intentCodes: cell.Intent === "" ? null : cell.Intent.split(","),
});

export function useValidateApiKeyMutation({
onSuccess,
}: {
onSuccess: () => void;
}) {
const queryClient = useQueryClient();
const [setApiKey, setProjectId] = useSemrushProjectStore((state) => [
state.setApiKey,
state.setProjectId,
]);

return useMutation({
mutationFn: (apiKey: string) => semrush.validateApiKey(apiKey),
onSuccess: async (validatedApiKey) => {
// Persist API key
setApiKey(validatedApiKey);

queryClient.prefetchQuery({
queryKey: ["project"],
queryFn: async () => {
const project = await semrush.projects.getOrCreate();

// Persist project ID
setProjectId(project.project_id);

return project;
},
});

onSuccess();
},
});
}

export function useProjectQuery() {
return useQuery({
queryKey: ["project"],
queryFn: () => semrush.projects.getOrCreate(),
throwOnError: true,
});
}

export function useDeleteProjectMutation({
onSuccess,
}: {
onSuccess: () => void;
}) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: () => semrush.projects.delete(),
onSuccess: () => {
queryClient.clear();
onSuccess();
},
});
}

export function useInfiniteKeywordSearchQuery(
args: Omit<Parameters<typeof semrush.keywordSearch>[0], "offset">
) {
const { keyword, database, limit, type, sort } = args;

return useInfiniteQuery({
queryKey: ["keywords", { keyword, database, type, sort }],
enabled: !!keyword,
initialPageParam: 0,
placeholderData: keepPreviousData,
queryFn: ({ pageParam }) => {
const offset = pageParam * limit;
return semrush.keywordSearch({
...args,
offset,
limit: offset + limit,
});
},
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
return lastPage.length === 0 ? undefined : lastPageParam + 1;
},
getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => {
return firstPageParam <= 1 ? undefined : firstPageParam - 1;
},
select: (data) => {
return data.pages.flatMap((page) =>
page.map((row) => transformKeywordRow(row))
);
},
});
}

export function useAuditQuery() {
const [refetchInterval, setRefetchInterval] = useState(4500);
const { data, ...rest } = useQuery({
throwOnError: true,
queryKey: ["audit"],
queryFn: () => semrush.audit.get(),
select: (data) => {
if (data.current_snapshot === null) {
return {
...data,
timeAgo: undefined,
annotatedIssues: {
errors: [],
warnings: [],
notices: [],
},
};
}

const { errors, warnings, notices } = data.current_snapshot;

return {
...data,
timeAgo: timeAgo(data.current_snapshot.finish_date),
annotatedIssues: {
errors: annotateIssues(errors, "error"),
warnings: annotateIssues(warnings, "warning"),
notices: annotateIssues(notices, "notice"),
},
};
},
refetchInterval,
});
const isAuditFinished = data?.status === "FINISHED";

// Poll for audit status
useEffect(() => {
if (isAuditFinished || !data) {
setRefetchInterval(0);
} else {
setRefetchInterval(4500);
}
}, [isAuditFinished, data]);

return { data, ...rest };
}

export function useRunAuditMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async () => semrush.audit.run(),
onSuccess: () => queryClient.refetchQueries({ queryKey: ["audit"] }),
});
}

export function useEditAuditMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (audit: Parameters<typeof semrush.audit.update>[0]) => {
return semrush.audit.update(audit);
},
onSuccess: () => {
framer.notify("Audit settings saved.", { variant: "success" });
return queryClient.refetchQueries({ queryKey: ["audit"] });
},
});
}

export function useIssueMutation() {
return useMutation({
mutationFn: (args: { snapshotId: string; issueId: number }) => {
return semrush.audit.getIssue(args.snapshotId, args.issueId);
},
});
}

export const usePrefetchAuditQuery = () => {
const queryClient = useQueryClient();

return () => {
queryClient.prefetchQuery({
queryKey: ["audit"],
queryFn: () => semrush.audit.get(),
});
};
};

0 comments on commit d89e63b

Please sign in to comment.