11 Commits

Author SHA1 Message Date
towfiqi
7c6c7fc3d1 chore(release): 0.3.4 2024-01-15 23:24:07 +06:00
towfiqi
cca9f95358 fix: fixes local SC data not being removed on deleting domain. 2024-01-15 23:19:57 +06:00
towfiqi
faa88c9254 feat: adds ability to add multiple domains at once. 2024-01-15 23:05:35 +06:00
towfiqi
8b0ee562cf test: Updates test for SingleKeyword component. 2024-01-13 20:18:04 +06:00
towfiqi
2f08bb3f62 docs: Adds docs for Google Search console related functions 2024-01-13 20:06:04 +06:00
towfiqi
897aa0b7d7 chore: removes mocks from production build. 2024-01-13 12:04:15 +06:00
towfiqi
e166b588aa fix: Resolves incorrect keyword average SC data values in Tracker 2024-01-13 11:58:16 +06:00
towfiqi
c897a52550 feat: Adds the ability to show/hide Keys & Passwords in Settings Panel 2024-01-13 11:31:40 +06:00
towfiqi
df3a738788 fix: resolves newly added Domain's Update time rendering issue 2024-01-13 10:25:10 +06:00
towfiqi
4a47cedad8 refactor: improves Performance & Code Readability
- Replaces useEffet with useMemo & useLayoutEffect where necessary.
- Converts some useEffects to separate hooks.
- Moves the functions defined within components to utils.
- Splits the Keywords renderPosition function to its own component.
2024-01-13 10:10:49 +06:00
towfiqi
2783de5c65 refactor: separates client and backend utils. 2024-01-12 22:40:28 +06:00
42 changed files with 413 additions and 263 deletions

View File

@@ -2,6 +2,21 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [0.3.4](https://github.com/towfiqi/serpbear/compare/v0.3.3...v0.3.4) (2024-01-15)
### Features
* adds ability to add multiple domains at once. ([faa88c9](https://github.com/towfiqi/serpbear/commit/faa88c92542194f19b5cfe2b5cfd07d7d4f7ee46))
* Adds the ability to show/hide Keys & Passwords in Settings Panel ([c897a52](https://github.com/towfiqi/serpbear/commit/c897a525509baf5b9e8df18d82f5e87aec64f66e))
### Bug Fixes
* fixes local SC data not being removed on deleting domain. ([cca9f95](https://github.com/towfiqi/serpbear/commit/cca9f95358b2d3ea06edb33595cdbf616a175469))
* Resolves incorrect keyword average SC data values in Tracker ([e166b58](https://github.com/towfiqi/serpbear/commit/e166b588aa6c8db55d61b5bc13db66514575c745))
* resolves newly added Domain's Update time rendering issue ([df3a738](https://github.com/towfiqi/serpbear/commit/df3a738788fa957e7246a0feefe395a9eadd5baf))
### [0.3.3](https://github.com/towfiqi/serpbear/compare/v0.3.2...v0.3.3) (2023-11-12)

View File

@@ -12,6 +12,7 @@ WORKDIR /app
COPY --from=deps /app ./
RUN rm -rf /app/data
RUN rm -rf /app/__tests__
RUN rm -rf /app/__mocks__
RUN npm run build

View File

@@ -1,9 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MockResponseInitFunction } from 'jest-fetch-mock';
import SingleDomain from '../../pages/domain/[slug]';
import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains';
import { useAddKeywords, useDeleteKeywords, useFavKeywords, useFetchKeywords, useRefreshKeywords } from '../../services/keywords';
import { useAddKeywords, useDeleteKeywords,
useFavKeywords, useFetchKeywords, useRefreshKeywords, useFetchSingleKeyword } from '../../services/keywords';
import { dummyDomain, dummyKeywords, dummySettings } from '../../__mocks__/data';
import { useFetchSettings } from '../../services/settings';
@@ -31,6 +31,7 @@ const useAddKeywordsFunc = useAddKeywords as jest.Mock<any>;
const useUpdateDomainFunc = useUpdateDomain as jest.Mock<any>;
const useDeleteDomainFunc = useDeleteDomain as jest.Mock<any>;
const useFetchSettingsFunc = useFetchSettings as jest.Mock<any>;
const useFetchSingleKeywordFunc = useFetchSingleKeyword as jest.Mock<any>;
describe('SingleDomain Page', () => {
const queryClient = new QueryClient();
@@ -38,6 +39,8 @@ describe('SingleDomain Page', () => {
useFetchSettingsFunc.mockImplementation(() => ({ data: { settings: dummySettings }, isLoading: false }));
useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false }));
useFetchKeywordsFunc.mockImplementation(() => ({ keywordsData: { keywords: dummyKeywords }, keywordsLoading: false }));
const fetchPayload = { history: dummyKeywords[0].history || [], searchResult: dummyKeywords[0].lastResult || [] };
useFetchSingleKeywordFunc.mockImplementation(() => ({ data: fetchPayload, isLoading: false }));
useDeleteKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useFavKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useRefreshKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
@@ -67,16 +70,7 @@ describe('SingleDomain Page', () => {
const keywords = document.querySelectorAll('.keyword');
const firstKeyword = keywords && keywords[0].querySelector('a');
if (firstKeyword) fireEvent.click(firstKeyword);
const fn: MockResponseInitFunction = async () => {
return new Promise((resolve) => {
resolve({
body: JSON.stringify({ keyword: dummyKeywords[0] }),
status: 200,
});
});
};
fetchMock.mockIf(`${window.location.origin}/api/keyword?id=${dummyKeywords[0].ID}`, fn);
expect(useFetchSingleKeyword).toHaveBeenCalled();
expect(screen.getByTestId('keywordDetails')).toBeVisible();
});
it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => {

View File

@@ -221,6 +221,14 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
</g>
</svg>
}
{type === 'eye-closed'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<g fill="none" stroke={color} strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M6.873 17.129c-1.845-1.31-3.305-3.014-4.13-4.09a1.693 1.693 0 0 1 0-2.077C4.236 9.013 7.818 5 12 5c1.876 0 3.63.807 5.13 1.874"/>
<path d="M14.13 9.887a3 3 0 1 0-4.243 4.242M4 20L20 4M10 18.704A7.124 7.124 0 0 0 12 19c4.182 0 7.764-4.013 9.257-5.962a1.694 1.694 0 0 0-.001-2.078A22.939 22.939 0 0 0 19.57 9"/>
</g>
</svg>
}
{type === 'target'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M19.938 13A8.004 8.004 0 0 1 13 19.938V22h-2v-2.062A8.004 8.004 0 0 1 4.062 13H2v-2h2.062A8.004 8.004 0 0 1 11 4.062V2h2v2.062A8.004 8.004 0 0 1 19.938 11H22v2h-2.062zM12 18a6 6 0 1 0 0-12a6 6 0 0 0 0 12zm0-3a3 3 0 1 0 0-6a3 3 0 0 0 0 6z" fill={color} fillRule="nonzero"/>

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import React from 'react';
import Icon from './Icon';
import useOnKey from '../../hooks/useOnKey';
type ModalProps = {
children: React.ReactNode,
@@ -9,17 +10,7 @@ type ModalProps = {
}
const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
closeModal();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeModal]);
useOnKey('Escape', closeModal);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();

View File

@@ -0,0 +1,37 @@
import { useState } from 'react';
import Icon from './Icon';
type SecretFieldProps = {
label: string;
value: string;
onChange: Function;
placeholder?: string;
classNames?: string;
hasError?: boolean;
}
const SecretField = ({ label = '', value = '', placeholder = '', onChange, hasError = false }: SecretFieldProps) => {
const [showValue, setShowValue] = useState(false);
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div className="settings__section__secret mb-5 relative">
<label className={labelStyle}>{label}</label>
<span
className="absolute top-8 right-0 px-2 py-1 cursor-pointer text-gray-400 select-none"
onClick={() => setShowValue(!showValue)}>
<Icon type={showValue ? 'eye-closed' : 'eye'} size={18} />
</span>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none
focus:border-blue-200 ${hasError ? ' border-red-400 focus:border-red-400' : ''} `}
type={showValue ? 'text' : 'password'}
value={value}
onChange={(event) => onChange(event.target.value)}
autoComplete="off"
placeholder={placeholder}
/>
</div>
);
};
export default SecretField;

View File

@@ -1,54 +1,60 @@
import React, { useState } from 'react';
import Modal from '../common/Modal';
import { useAddDomain } from '../../services/domains';
import { isValidDomain } from '../../utils/validators';
import { isValidDomain } from '../../utils/client/validators';
type AddDomainProps = {
domains: DomainType[],
closeModal: Function
}
const AddDomain = ({ closeModal }: AddDomainProps) => {
const AddDomain = ({ closeModal, domains = [] }: AddDomainProps) => {
const [newDomain, setNewDomain] = useState<string>('');
const [newDomainError, setNewDomainError] = useState<boolean>(false);
const [newDomainError, setNewDomainError] = useState('');
const { mutate: addMutate, isLoading: isAdding } = useAddDomain(() => closeModal());
const addDomain = () => {
// console.log('ADD NEW DOMAIN', newDomain);
if (isValidDomain(newDomain.trim())) {
setNewDomainError(false);
setNewDomainError('');
const existingDomains = domains.map((d) => d.domain);
const insertedDomains = newDomain.split('\n');
const domainsTobeAdded:string[] = [];
const invalidDomains:string[] = [];
insertedDomains.forEach((dom) => {
const domain = dom.trim();
if (isValidDomain(domain)) {
if (!existingDomains.includes(domain)) {
domainsTobeAdded.push(domain);
}
} else {
invalidDomains.push(domain);
}
});
if (invalidDomains.length > 0) {
setNewDomainError(`Please Insert Valid Domain names. Invalid Domains: ${invalidDomains.join(', ')}`);
} else if (domainsTobeAdded.length > 0) {
// TODO: Domain Action
addMutate(newDomain.trim());
} else {
setNewDomainError(true);
addMutate(domainsTobeAdded);
}
};
const handleDomainInput = (e:React.FormEvent<HTMLInputElement>) => {
if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(false); }
const handleDomainInput = (e:React.ChangeEvent<HTMLTextAreaElement>) => {
if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(''); }
setNewDomain(e.currentTarget.value);
};
return (
<Modal closeModal={() => { closeModal(false); }} title={'Add New Domain'}>
<div data-testid="adddomain_modal">
<h4 className='text-sm mt-4'>
Domain Name {newDomainError && <span className=' ml-2 block float-right text-red-500 text-xs font-semibold'>Not a Valid Domain</span>}
</h4>
<input
className={`w-full p-2 border border-gray-200 rounded mt-2 mb-3 focus:outline-none focus:border-blue-200
${newDomainError ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
<h4 className='text-sm mt-4'>Domain Names</h4>
<textarea
className={`w-full h-40 border rounded border-gray-200 p-4 outline-none
focus:border-indigo-300 ${newDomainError ? ' border-red-400 focus:border-red-400' : ''}`}
placeholder="Type or Paste Domains here. Insert Each Domain in a New line."
value={newDomain}
placeholder={'example.com'}
onChange={handleDomainInput}
autoFocus={true}
onKeyDown={(e) => {
if (e.code === 'Enter') {
e.preventDefault();
addDomain();
}
}}
/>
onChange={handleDomainInput}>
</textarea>
{newDomainError && <div><span className=' ml-2 block float-right text-red-500 text-xs font-semibold'>{newDomainError}</span></div>}
<div className='mt-6 text-right text-sm font-semibold'>
<button className='py-2 px-5 rounded cursor-pointer bg-indigo-50 text-slate-500 mr-3' onClick={() => closeModal(false)}>Cancel</button>
<button className='py-2 px-5 rounded cursor-pointer bg-blue-700 text-white' onClick={() => !isAdding && addDomain() }>

View File

@@ -1,5 +1,5 @@
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
import { useDeleteDomain, useUpdateDomain } from '../../services/domains';
@@ -18,7 +18,10 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
const router = useRouter();
const [showRemoveDomain, setShowRemoveDomain] = useState<boolean>(false);
const [settingsError, setSettingsError] = useState<DomainSettingsError>({ type: '', msg: '' });
const [domainSettings, setDomainSettings] = useState<DomainSettings>({ notification_interval: 'never', notification_emails: '' });
const [domainSettings, setDomainSettings] = useState<DomainSettings>(() => ({
notification_interval: domain && domain.notification_interval ? domain.notification_interval : 'never',
notification_emails: domain && domain.notification_emails ? domain.notification_emails : '',
}));
const { mutate: updateMutate } = useUpdateDomain(() => closeModal(false));
const { mutate: deleteMutate } = useDeleteDomain(() => {
@@ -26,12 +29,6 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
router.push('/domains');
});
useEffect(() => {
if (domain) {
setDomainSettings({ notification_interval: domain.notification_interval, notification_emails: domain.notification_emails });
}
}, [domain]);
const updateNotiEmails = (event:React.FormEvent<HTMLInputElement>) => {
setDomainSettings({ ...domainSettings, notification_emails: event.currentTarget.value });
};

View File

@@ -1,6 +1,7 @@
import React from 'react';
import countries from '../../utils/countries';
import Icon from '../common/Icon';
import { formattedNum } from '../../utils/client/helpers';
type InsightItemProps = {
item: SCInsightItem,
@@ -16,7 +17,6 @@ const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
firstItem = date && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(date));
}
if (type === 'countries') { firstItem = countries[country] && countries[country][0]; }
const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);
return (
<div
@@ -35,7 +35,6 @@ const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
{Math.round(position)}
</div>
{/* <div className='keyword_imp text-center inline-block lg:flex-1'>{formattedNum(clicks)}</div> */}
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-14 p-2 text-base mt-[-55px] rounded right-5 lg:relative
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
{formattedNum(clicks)}

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState, useEffect } from 'react';
import React, { useMemo } from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { formattedNum } from '../../utils/client/helpers';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
@@ -12,21 +13,15 @@ type InsightStatsProps = {
}
const InsightStats = ({ stats = [], totalKeywords = 0, totalPages = 0 }:InsightStatsProps) => {
const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);
const [totalStat, setTotalStat] = useState({ impressions: 0, clicks: 0, ctr: 0, position: 0 });
useEffect(() => {
if (stats.length > 0) {
const totalStats = stats.reduce((acc, item) => {
return {
impressions: item.impressions + acc.impressions,
clicks: item.clicks + acc.clicks,
ctr: item.ctr + acc.ctr,
position: item.position + acc.position,
};
}, { impressions: 0, clicks: 0, ctr: 0, position: 0 });
setTotalStat(totalStats);
}
const totalStat = useMemo(() => {
return stats.reduce((acc, item) => {
return {
impressions: item.impressions + acc.impressions,
clicks: item.clicks + acc.clicks,
ctr: item.ctr + acc.ctr,
position: item.position + acc.position,
};
}, { impressions: 0, clicks: 0, ctr: 0, position: 0 });
}, [stats]);
const chartData = useMemo(() => {

View File

@@ -4,7 +4,8 @@ import dayjs from 'dayjs';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import ChartSlim from '../common/ChartSlim';
import { generateTheChartData } from '../common/generateChartData';
import KeywordPosition from './KeywordPosition';
import { generateTheChartData } from '../../utils/client/generateChartData';
type KeywordProps = {
keywordData: KeywordType,
@@ -82,16 +83,6 @@ const Keyword = (props: KeywordProps) => {
const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
const renderPosition = (pos:number, type?:string) => {
if (!updating && pos === 0) {
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
}
if (updating && type !== 'sc') {
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
}
return pos;
};
return (
<div
key={keyword}
@@ -123,7 +114,7 @@ const Keyword = (props: KeywordProps) => {
<div
className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-24 lg:grow-0 lg:right-0 text-center font-semibold`}>
{renderPosition(position)}
<KeywordPosition position={position} />
{!updating && positionChange > 0 && <i className=' not-italic ml-1 text-xs text-[#5ed7c3]'> {positionChange}</i>}
{!updating && positionChange < 0 && <i className=' not-italic ml-1 text-xs text-red-300'> {positionChange}</i>}
</div>
@@ -164,7 +155,10 @@ const Keyword = (props: KeywordProps) => {
relative flex justify-between text-center lg:flex-1 lg:text-sm lg:m-0 lg:mt-0 lg:border-t-0 lg:pt-0 lg:top-0'>
<span className='min-w-[40px]'>
<span className='lg:hidden'>SC Position: </span>
{renderPosition(keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0, 'sc')}
<KeywordPosition
position={keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0}
type='sc'
/>
</span>
<span className='min-w-[40px]'>
<span className='lg:hidden'>Impressions: </span>{keywordData?.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0}

View File

@@ -1,10 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import dayjs from 'dayjs';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import Chart from '../common/Chart';
import SelectField from '../common/SelectField';
import { generateTheChartData } from '../common/generateChartData';
import { useFetchSingleKeyword } from '../../services/keywords';
import useOnKey from '../../hooks/useOnKey';
import { generateTheChartData } from '../../utils/client/generateChartData';
type KeywordDetailsProps = {
keyword: KeywordType,
@@ -13,11 +15,12 @@ type KeywordDetailsProps = {
const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
const updatedDate = new Date(keyword.lastUpdated);
const [keywordHistory, setKeywordHistory] = useState<KeywordHistory>(keyword.history);
const [keywordSearchResult, setKeywordSearchResult] = useState<KeywordLastResult[]>([]);
const [chartTime, setChartTime] = useState<string>('30');
const searchResultContainer = useRef<HTMLDivElement>(null);
const searchResultFound = useRef<HTMLDivElement>(null);
const { data: keywordData } = useFetchSingleKeyword(keyword.ID);
const keywordHistory: KeywordHistory = keywordData?.history || keyword.history;
const keywordSearchResult: KeywordLastResult = keywordData?.searchResult || keyword.history;
const dateOptions = [
{ label: 'Last 7 Days', value: '7' },
{ label: 'Last 30 Days', value: '30' },
@@ -26,39 +29,9 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
{ label: 'All Time', value: 'all' },
];
useEffect(() => {
const fetchFullKeyword = async () => {
try {
const fetchURL = `${window.location.origin}/api/keyword?id=${keyword.ID}`;
const res = await fetch(fetchURL, { method: 'GET' }).then((result) => result.json());
if (res.keyword) {
console.log(res.keyword, new Date().getTime());
setKeywordHistory(res.keyword.history || []);
setKeywordSearchResult(res.keyword.lastResult || []);
}
} catch (error) {
console.log(error);
}
};
if (keyword.lastResult.length === 0) {
fetchFullKeyword();
}
}, [keyword]);
useOnKey('Escape', closeDetails);
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
console.log(event.key);
closeDetails();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeDetails]);
useEffect(() => {
useLayoutEffect(() => {
if (keyword.position < 100 && keyword.position > 0 && searchResultFound?.current) {
searchResultFound.current.scrollIntoView({
behavior: 'smooth',

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import countries from '../../utils/countries';
@@ -17,11 +17,6 @@ type KeywordFilterProps = {
SCcountries?: string[];
}
type KeywordCountState = {
desktop: number,
mobile: number
}
const KeywordFilters = (props: KeywordFilterProps) => {
const {
device,
@@ -36,20 +31,14 @@ const KeywordFilters = (props: KeywordFilterProps) => {
integratedConsole = false,
SCcountries = [],
} = props;
const [keywordCounts, setKeywordCounts] = useState<KeywordCountState>({ desktop: 0, mobile: 0 });
const [sortOptions, showSortOptions] = useState(false);
const [filterOptions, showFilterOptions] = useState(false);
useEffect(() => {
const keyWordCount = { desktop: 0, mobile: 0 };
keywords.forEach((k) => {
if (k.device === 'desktop') {
keyWordCount.desktop += 1;
} else {
keyWordCount.mobile += 1;
}
});
setKeywordCounts(keyWordCount);
const keywordCounts = useMemo(() => {
return keywords.reduce((acc, k) => ({
desktop: k.device === 'desktop' ? acc.desktop + 1 : acc.desktop,
mobile: k.device !== 'desktop' ? acc.mobile + 1 : acc.mobile,
}), { desktop: 0, mobile: 0 });
}, [keywords]);
const filterCountry = (cntrs:string[]) => filterKeywords({ ...filterParams, countries: cntrs });

View File

@@ -0,0 +1,19 @@
import Icon from '../common/Icon';
type KeywordPositionProps = {
position: number,
updating?: boolean,
type?: string,
}
const KeywordPosition = ({ position = 0, type = '', updating = false }:KeywordPositionProps) => {
if (!updating && position === 0) {
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
}
if (updating && type !== 'sc') {
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
}
return <>{Math.round(position)}</>;
};
export default KeywordPosition;

View File

@@ -1,9 +1,9 @@
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import { CSSTransition } from 'react-transition-group';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import AddKeywords from './AddKeywords';
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/sortFilter';
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/client/sortFilter';
import Icon from '../common/Icon';
import Keyword from './Keyword';
import KeywordDetails from './KeywordDetails';
@@ -12,6 +12,8 @@ import Modal from '../common/Modal';
import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords';
import KeywordTagManager from './KeywordTagManager';
import AddTags from './AddTags';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
type KeywordsTableProps = {
domain: DomainType | null,
@@ -31,7 +33,6 @@ const KeywordsTable = (props: KeywordsTableProps) => {
const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
const [showTagManager, setShowTagManager] = useState<null|number>(null);
const [showAddTags, setShowAddTags] = useState<boolean>(false);
const [isMobile, setIsMobile] = useState<boolean>(false);
const [SCListHeight, setSCListHeight] = useState(500);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('date_asc');
@@ -40,26 +41,18 @@ const KeywordsTable = (props: KeywordsTableProps) => {
const { mutate: deleteMutate } = useDeleteKeywords(() => {});
const { mutate: favoriteMutate } = useFavKeywords(() => {});
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
const [isMobile] = useIsMobile();
useWindowResize(() => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400)));
const scDataObject:{ [k:string] : string} = {
threeDays: 'Last Three Days',
sevenDays: 'Last Seven Days',
thirtyDays: 'Last Thirty Days',
avgSevenDays: 'Last Three Days Avg',
avgThreeDays: 'Last Seven Days Avg',
avgThreeDays: 'Last Three Days Avg',
avgSevenDays: 'Last Seven Days Avg',
avgThirtyDays: 'Last Thirty Days Avg',
};
useEffect(() => {
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
const resizeList = () => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
resizeList();
window.addEventListener('resize', resizeList);
return () => {
window.removeEventListener('resize', resizeList);
};
}, [isMobile]);
const processedKeywords: {[key:string] : KeywordType[]} = useMemo(() => {
const procKeywords = keywords.filter((x) => x.device === device);
const filteredKeywords = filterKeywords(procKeywords, filterParams);

View File

@@ -1,6 +1,8 @@
import React from 'react';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import KeywordPosition from './KeywordPosition';
import { formattedNum } from '../../utils/client/helpers';
type SCKeywordProps = {
keywordData: SearchAnalyticsItem,
@@ -15,13 +17,6 @@ const SCKeyword = (props: SCKeywordProps) => {
const { keywordData, selected, lastItem, selectKeyword, style, isTracked = false } = props;
const { keyword, uid, position, country, impressions, ctr, clicks } = keywordData;
const renderPosition = () => {
if (position === 0) {
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
}
return Math.round(position);
};
return (
<div
key={keyword}
@@ -45,7 +40,7 @@ const SCKeyword = (props: SCKeywordProps) => {
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-15 p-2 text-base mt-[-20px] rounded right-5 lg:relative
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
{renderPosition()}
<KeywordPosition position={position} />
<span className='block text-xs text-gray-500 lg:hidden'>Position</span>
</div>
@@ -53,14 +48,14 @@ const SCKeyword = (props: SCKeywordProps) => {
<span className='mr-3 lg:hidden'>
<Icon type="eye" size={14} color="#999" />
</span>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(impressions)}
{formattedNum(impressions)}
</div>
<div className={'keyword_visits text-center inline-block mt-4 mr-5 ml-5 lg:flex-1 lg:m-0 max-w-[70px] lg:max-w-none lg:pr-5'}>
<span className='mr-3 lg:hidden'>
<Icon type="cursor" size={14} color="#999" />
</span>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(clicks)}
{formattedNum(clicks)}
</div>
<div className='keyword_ctr text-center inline-block mt-4 relative lg:flex-1 lg:m-0 '>

View File

@@ -1,12 +1,15 @@
import { useRouter } from 'next/router';
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { useAddKeywords, useFetchKeywords } from '../../services/keywords';
import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/SCsortFilter';
import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/client/SCsortFilter';
import Icon from '../common/Icon';
import KeywordFilters from './KeywordFilter';
import SCKeyword from './SCKeyword';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
import { formattedNum } from '../../utils/client/helpers';
type SCKeywordsTableProps = {
domain: DomainType | null,
@@ -27,11 +30,13 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('imp_desc');
const [isMobile, setIsMobile] = useState<boolean>(false);
const [SCListHeight, setSCListHeight] = useState(500);
const { keywordsData } = useFetchKeywords(router);
const addedkeywords: string[] = keywordsData?.keywords?.map((key: KeywordType) => `${key.keyword}:${key.country}:${key.device}`) || [];
const { mutate: addKeywords } = useAddKeywords(() => { if (domain && domain.slug) router.push(`/domain/${domain.slug}`); });
const [isMobile] = useIsMobile();
useWindowResize(() => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400)));
const finalKeywords: {[key:string] : SCKeywordType[] } = useMemo(() => {
const procKeywords = keywords.filter((x) => x.device === device);
const filteredKeywords = SCfilterKeywords(procKeywords, filterParams);
@@ -71,16 +76,6 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
};
}, [finalKeywords, device]);
useEffect(() => {
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
const resizeList = () => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
resizeList();
window.addEventListener('resize', resizeList);
return () => {
window.removeEventListener('resize', resizeList);
};
}, [isMobile]);
const selectKeyword = (keywordID: string) => {
console.log('Select Keyword: ', keywordID);
let updatedSelectd = [...selectedKeywords, keywordID];
@@ -194,10 +189,10 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
</span>
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>{viewSummary.position}</span>
<span className='domKeywords_head_imp flex-1 text-center'>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.impressions)}
{formattedNum(viewSummary.impressions)}
</span>
<span className='domKeywords_head_visits flex-1 text-center'>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.visits)}
{formattedNum(viewSummary.visits)}
</span>
<span className='domKeywords_head_ctr flex-1 text-center'>
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(viewSummary.ctr)}%

View File

@@ -1,5 +1,6 @@
import React from 'react';
import SelectField from '../common/SelectField';
import SecretField from '../common/SecretField';
type NotificationSettingsProps = {
settings: SettingsType,
@@ -76,15 +77,13 @@ const NotificationSettings = ({ settings, settingsError, updateSettings }:Notifi
onChange={(event) => updateSettings('smtp_username', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Password</label>
<input
className={'w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200'}
type="text"
value={settings?.smtp_password || ''}
onChange={(event) => updateSettings('smtp_password', event.target.value)}
/>
</div>
<SecretField
label='SMTP Password'
value={settings?.smtp_password || ''}
onChange={(value:string) => updateSettings('smtp_password', value)}
/>
<div className="settings__section__input mb-5">
<label className={labelStyle}>From Email Address</label>
<input

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useClearFailedQueue } from '../../services/settings';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import SecretField from '../common/SecretField';
type ScraperSettingsProps = {
settings: SettingsType,
@@ -55,17 +56,13 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
/>
</div>
{['scrapingant', 'scrapingrobot', 'serply', 'serpapi', 'spaceSerp', 'searchapi'].includes(settings.scraper_type) && (
<div className="settings__section__input mr-3">
<label className={labelStyle}>Scraper API Key or Token</label>
<input
className={`w-full p-2 border border-gray-200 rounded mt-2 mb-3 focus:outline-none focus:border-blue-200
${settingsError?.type === 'no_api_key' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.scaping_api || ''}
placeholder={'API Key/Token'}
onChange={(event) => updateSettings('scaping_api', event.target.value)}
/>
</div>
<SecretField
label='Scraper API Key or Token'
placeholder={'API Key/Token'}
value={settings?.scaping_api || ''}
hasError={settingsError?.type === 'no_api_key'}
onChange={(value:string) => updateSettings('scaping_api', value)}
/>
)}
{settings.scraper_type === 'proxy' && (
<div className="settings__section__input mb-5">

View File

@@ -4,6 +4,7 @@ import { useFetchSettings, useUpdateSettings } from '../../services/settings';
import Icon from '../common/Icon';
import NotificationSettings from './NotificationSettings';
import ScraperSettings from './ScraperSettings';
import useOnKey from '../../hooks/useOnKey';
type SettingsProps = {
closeSettings: Function,
@@ -34,6 +35,7 @@ const Settings = ({ closeSettings }:SettingsProps) => {
const [settingsError, setSettingsError] = useState<SettingsError|null>(null);
const { mutate: updateMutate, isLoading: isUpdating } = useUpdateSettings(() => console.log(''));
const { data: appSettings, isLoading } = useFetchSettings();
useOnKey('Escape', closeSettings);
useEffect(() => {
if (appSettings && appSettings.settings) {
@@ -41,19 +43,6 @@ const Settings = ({ closeSettings }:SettingsProps) => {
}
}, [appSettings]);
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
console.log(event.key);
closeSettings();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeSettings]);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();

12
hooks/useIsMobile.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
const useIsMobile = () => {
const [isMobile, setIsMobile] = useState<boolean>(false);
useEffect(() => {
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
}, []);
return [isMobile];
};
export default useIsMobile;

17
hooks/useOnKey.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { useEffect } from 'react';
const useOnKey = (key:string, onPress: Function) => {
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === key) {
onPress();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [key, onPress]);
};
export default useOnKey;

13
hooks/useWindowResize.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
const useWindowResize = (onResize: () => void) => {
useEffect(() => {
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [onResize]);
};
export default useWindowResize;

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "serpbear",
"version": "0.3.3",
"version": "0.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "serpbear",
"version": "0.3.3",
"version": "0.3.4",
"dependencies": {
"@googleapis/searchconsole": "^1.0.0",
"@types/react-transition-group": "^4.4.5",

View File

@@ -1,6 +1,6 @@
{
"name": "serpbear",
"version": "0.3.3",
"version": "0.3.4",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -4,6 +4,7 @@ import Domain from '../../database/models/domain';
import Keyword from '../../database/models/keyword';
import getdomainStats from '../../utils/domains';
import verifyUser from '../../utils/verifyUser';
import { removeLocalSCData } from '../../utils/searchConsole';
type DomainsGetRes = {
domains: DomainType[]
@@ -11,13 +12,14 @@ type DomainsGetRes = {
}
type DomainsAddResponse = {
domain: Domain|null,
domains: DomainType[]|null,
error?: string|null,
}
type DomainsDeleteRes = {
domainRemoved: number,
keywordsRemoved: number,
SCDataRemoved: boolean,
error?: string|null,
}
@@ -59,41 +61,45 @@ export const getDomains = async (req: NextApiRequest, res: NextApiResponse<Domai
}
};
export const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => {
if (!req.body.domain) {
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' });
}
const { domain } = req.body || {};
const domainData = {
domain: domain.trim(),
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-'),
lastUpdated: new Date().toJSON(),
added: new Date().toJSON(),
};
const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => {
const { domains } = req.body;
if (domains && Array.isArray(domains) && domains.length > 0) {
const domainsToAdd: any = [];
try {
const addedDomain = await Domain.create(domainData);
return res.status(201).json({ domain: addedDomain });
} catch (error) {
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' });
domains.forEach((domain: string) => {
domainsToAdd.push({
domain: domain.trim(),
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-'),
lastUpdated: new Date().toJSON(),
added: new Date().toJSON(),
});
});
try {
const newDomains:Domain[] = await Domain.bulkCreate(domainsToAdd);
const formattedDomains = newDomains.map((el) => el.get({ plain: true }));
return res.status(201).json({ domains: formattedDomains });
} catch (error) {
console.log('[ERROR] Adding New Domain ', error);
return res.status(400).json({ domains: [], error: 'Error Adding Domain.' });
}
} else {
return res.status(400).json({ domains: [], error: 'Necessary data missing.' });
}
};
export const deleteDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsDeleteRes>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') {
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Domain is Required!' });
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Domain is Required!' });
}
try {
const { domain } = req.query || {};
const removedDomCount: number = await Domain.destroy({ where: { domain } });
const removedKeywordCount: number = await Keyword.destroy({ where: { domain } });
return res.status(200).json({
domainRemoved: removedDomCount,
keywordsRemoved: removedKeywordCount,
});
const SCDataRemoved = await removeLocalSCData(domain as string);
return res.status(200).json({ domainRemoved: removedDomCount, keywordsRemoved: removedKeywordCount, SCDataRemoved });
} catch (error) {
console.log('[ERROR] Deleting Domain: ', req.query.domain, error);
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Error Deleting Domain' });
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Error Deleting Domain' });
}
};

View File

@@ -11,7 +11,7 @@ import DomainHeader from '../../../components/domains/DomainHeader';
import KeywordsTable from '../../../components/keywords/KeywordsTable';
import AddDomain from '../../../components/domains/AddDomain';
import DomainSettings from '../../../components/domains/DomainSettings';
import exportCSV from '../../../utils/exportcsv';
import exportCSV from '../../../utils/client/exportcsv';
import Settings from '../../../components/settings/Settings';
import { useFetchDomains } from '../../../services/domains';
import { useFetchKeywords } from '../../../services/keywords';
@@ -35,7 +35,7 @@ const SingleDomain: NextPage = () => {
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
return active;
}, [router.query.slug, domainsData]);
@@ -86,7 +86,7 @@ const SingleDomain: NextPage = () => {
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>

View File

@@ -10,7 +10,7 @@ import TopBar from '../../../../components/common/TopBar';
import DomainHeader from '../../../../components/domains/DomainHeader';
import AddDomain from '../../../../components/domains/AddDomain';
import DomainSettings from '../../../../components/domains/DomainSettings';
import exportCSV from '../../../../utils/exportcsv';
import exportCSV from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains';
import { useFetchSCKeywords } from '../../../../services/searchConsole';
@@ -34,7 +34,7 @@ const DiscoverPage: NextPage = () => {
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
return active;
}, [router.query.slug, domainsData]);
@@ -71,7 +71,7 @@ const DiscoverPage: NextPage = () => {
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>

View File

@@ -10,7 +10,7 @@ import TopBar from '../../../../components/common/TopBar';
import DomainHeader from '../../../../components/domains/DomainHeader';
import AddDomain from '../../../../components/domains/AddDomain';
import DomainSettings from '../../../../components/domains/DomainSettings';
import exportCSV from '../../../../utils/exportcsv';
import exportCSV from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains';
import { useFetchSCInsight } from '../../../../services/searchConsole';
@@ -34,7 +34,7 @@ const InsightPage: NextPage = () => {
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
return active;
}, [router.query.slug, domainsData]);
@@ -71,7 +71,7 @@ const InsightPage: NextPage = () => {
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>

View File

@@ -119,7 +119,7 @@ const Domains: NextPage = () => {
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} />

View File

@@ -7,7 +7,7 @@ type UpdatePayload = {
domain: DomainType
}
export async function fetchDomains(router: NextRouter, withStats:boolean) {
export async function fetchDomains(router: NextRouter, withStats:boolean): Promise<{domains: DomainType[]}> {
const res = await fetch(`${window.location.origin}/api/domains${withStats ? '?withstats=true' : ''}`, { method: 'GET' });
if (res.status >= 400 && res.status < 600) {
if (res.status === 401) {
@@ -56,9 +56,9 @@ export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
export function useAddDomain(onSuccess:Function) {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation(async (domainName:string) => {
return useMutation(async (domains:string[]) => {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domain: domainName }) };
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domains }) };
const res = await fetch(`${window.location.origin}/api/domains`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
@@ -67,11 +67,12 @@ export function useAddDomain(onSuccess:Function) {
}, {
onSuccess: async (data) => {
console.log('Domain Added!!!', data);
const newDomain:DomainType = data.domain;
toast(`${newDomain.domain} Added Successfully!`, { icon: '✔️' });
const newDomain:DomainType[] = data.domains;
const singleDomain = newDomain.length === 1;
toast(`${singleDomain ? newDomain[0].domain : `${newDomain.length} domains`} Added Successfully!`, { icon: '✔️' });
onSuccess(false);
if (newDomain && newDomain.slug) {
router.push(`/domain/${data.domain.slug}`);
if (singleDomain) {
router.push(`/domain/${newDomain[0].slug}`);
}
queryClient.invalidateQueries(['domains']);
},

View File

@@ -153,3 +153,23 @@ export function useRefreshKeywords(onSuccess:Function) {
},
});
}
export function useFetchSingleKeyword(keywordID:number) {
return useQuery(['keyword', keywordID], async () => {
try {
const fetchURL = `${window.location.origin}/api/keyword?id=${keywordID}`;
const res = await fetch(fetchURL, { method: 'GET' }).then((result) => result.json());
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return { history: res.keyword.history || [], searchResult: res.keyword.lastResult || [] };
} catch (error) {
throw new Error('Error Loading Keyword Details');
}
}, {
onError: () => {
console.log('Error Loading Keyword Data!!!');
toast('Error Loading Keyword Details.', { icon: '⚠️' });
},
});
}

View File

@@ -282,3 +282,8 @@ body {
right: 240px;
}
}
/* Disable LastPass Icon for Secret Field */
[autocomplete="off"] + div[data-lastpass-icon-root="true"], [autocomplete="off"] + div[data-lastpass-infield="true"] {
display: none;
}

View File

@@ -1,4 +1,4 @@
import countries from './countries';
import countries from '../countries';
/**
* Generates CSV File form the given domain & keywords, and automatically downloads it.

2
utils/client/helpers.ts Normal file
View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);

View File

@@ -2,6 +2,12 @@ import Keyword from '../database/models/keyword';
import parseKeywords from './parseKeywords';
import { readLocalSCData } from './searchConsole';
/**
* The function `getdomainStats` takes an array of domain objects, retrieves keyword and stats data for
* each domain, and calculates various statistics for each domain.
* @param {DomainType[]} domains - An array of objects of type DomainType.
* @returns {DomainType[]} - An array of objects of type DomainType.
*/
const getdomainStats = async (domains:DomainType[]): Promise<DomainType[]> => {
const finalDomains: DomainType[] = [];
console.log('domains: ', domains.length);
@@ -15,7 +21,8 @@ const getdomainStats = async (domains:DomainType[]): Promise<DomainType[]> => {
domainWithStat.keywordCount = keywords.length;
const keywordPositions = keywords.reduce((acc, itm) => (acc + itm.position), 0);
const KeywordsUpdateDates: number[] = keywords.reduce((acc: number[], itm) => [...acc, new Date(itm.lastUpdated).getTime()], [0]);
domainWithStat.keywordsUpdated = new Date(Math.max(...KeywordsUpdateDates)).toJSON();
const lastKeywordUpdateDate = Math.max(...KeywordsUpdateDates);
domainWithStat.keywordsUpdated = new Date(lastKeywordUpdateDate || new Date(domain.lastUpdated).getTime()).toJSON();
domainWithStat.avgPosition = Math.round(keywordPositions / keywords.length);
// Then Load the SC File and read the stats and calculate the Last 7 days stats

View File

@@ -1,4 +1,10 @@
export const sortInsightItems = (items:SCInsightItem[], sortBy: string = 'clicks') => {
/**
* The function `sortInsightItems` sorts an array of `SCInsightItem` objects based on a specified property.
* @param {SCInsightItem[]} items - An array of SCInsightItem objects.
* @param {string} [sortBy=clicks] - The `sortBy` parameter determines the property by which the `items` array should be sorted.
* @returns {SCInsightItem[]}
*/
export const sortInsightItems = (items:SCInsightItem[], sortBy: string = 'clicks'): SCInsightItem[] => {
const sortKey = sortBy as keyof SCInsightItem;
let sortedItems = [];
switch (sortKey) {
@@ -18,6 +24,13 @@ export const sortInsightItems = (items:SCInsightItem[], sortBy: string = 'clicks
return sortedItems;
};
/**
* The `getCountryInsight` function takes search analytics data and returns insights about countries based on clicks, impressions, CTR, and position.
* @param {SCDomainDataType} SCData - The SCData parameter is an object that contains search analytics data for different dates.
* @param {string} [sortBy=clicks] - The "sortBy" parameter is used to specify the sorting criteria for the country insights.
* @param {string} [queryDate=thirtyDays] - The `queryDate` parameter is a string that represents the date range for which the search analytics data is retrieved.
* @returns {SCInsightItem[]}
*/
export const getCountryInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
const keywordsCounts: { [key:string]: string[] } = {};
const countryItems: { [key:string]: SCInsightItem } = {};
@@ -57,6 +70,13 @@ export const getCountryInsight = (SCData:SCDomainDataType, sortBy:string = 'clic
return sortBy ? sortInsightItems(countryInsight, sortBy) : countryInsight;
};
/**
* The `getKeywordsInsight` function takes search analytics data, sorts it based on specified criteria, and returns insights on keywords.
* @param {SCDomainDataType} SCData - The SCData parameter is of type SCDomainDataType, which is an object containing search analytics data for a specific domain.
* @param {string} [sortBy=clicks] - The "sortBy" parameter is used to specify the sorting criteria for the keyword insights.
* @param {string} [queryDate=thirtyDays] - The `queryDate` parameter is a string that represents the date range for which the search analytics data is retrieved.
* @returns {SCInsightItem[]}
*/
export const getKeywordsInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
const keywordItems: { [key:string]: SCInsightItem } = {};
const keywordCounts: { [key:string]: number } = {};
@@ -99,6 +119,13 @@ export const getKeywordsInsight = (SCData:SCDomainDataType, sortBy:string = 'cli
return sortBy ? sortInsightItems(keywordInsight, sortBy) : keywordInsight;
};
/**
* The `getPagesInsight` function takes a domain's search analytics data, sorts it based on specified criteria and returns insights about the pages.
* @param {SCDomainDataType} SCData - SCData is an object that contains search analytics data for a specific domain.
* @param {string} [sortBy=clicks] - The `sortBy` parameter is used to specify the sorting criteria for the pages insight.
* @param {string} [queryDate=thirtyDays] - The `queryDate` parameter is a string that represents the date range for which you want to retrieve the data.
* @returns {SCInsightItem[]}
*/
export const getPagesInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
const pagesItems: { [key:string]: SCInsightItem } = {};
const keywordCounts: { [key:string]: number } = {};

View File

@@ -1,12 +1,21 @@
import { auth, searchconsole_v1 } from '@googleapis/searchconsole';
import { readFile, writeFile } from 'fs/promises';
import { readFile, writeFile, unlink } from 'fs/promises';
import { getCountryCodeFromAlphaThree } from './countries';
export type SCDomainFetchError = {
error: boolean,
errorMsg: string,
}
type fetchConsoleDataResponse = SearchAnalyticsItem[] | SearchAnalyticsStat[] | SCDomainFetchError;
/**
* function that retrieves data from the Google Search Console API based on the provided domain name, number of days, and optional type.
* @param {string} domainName - The domain name for which you want to fetch search console data.
* @param {number} days - The `days` parameter is the number of days of data you want to fetch from the Search Console.
* @param {string} [type] - The `type` parameter is an optional parameter that specifies the type of data to fetch from the Search Console API.
* @returns {Promise<fetchConsoleDataResponse>}
*/
const fetchSearchConsoleData = async (domainName:string, days:number, type?:string): Promise<fetchConsoleDataResponse> => {
if (!domainName) return { error: true, errorMsg: 'Domain Not Provided!' };
try {
@@ -68,6 +77,13 @@ const fetchSearchConsoleData = async (domainName:string, days:number, type?:stri
}
};
/**
* The function fetches search console data for a given domain and returns it in a structured format.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which we
* want to fetch search console data.
* @returns The function `fetchDomainSCData` is returning a Promise that resolves to an object of type
* `SCDomainDataType`.
*/
export const fetchDomainSCData = async (domain:string): Promise<SCDomainDataType> => {
const days = [3, 7, 30];
const scDomainData:SCDomainDataType = { threeDays: [], sevenDays: [], thirtyDays: [], lastFetched: '', lastFetchError: '', stats: [] };
@@ -93,6 +109,12 @@ export const fetchDomainSCData = async (domain:string): Promise<SCDomainDataType
return scDomainData;
};
/**
* The function takes a raw search console item and a domain name as input and returns a parsed search analytics item.
* @param {SearchAnalyticsRawItem} SCItem - The SCItem parameter is an object that represents a raw item from the Search Console API.
* @param {string} domainName - The `domainName` parameter is a string that represents the domain name of the website.
* @returns {SearchAnalyticsItem}.
*/
export const parseSearchConsoleItem = (SCItem: SearchAnalyticsRawItem, domainName: string): SearchAnalyticsItem => {
const { clicks = 0, impressions = 0, ctr = 0, position = 0 } = SCItem;
const keyword = SCItem.keys[0];
@@ -104,6 +126,12 @@ export const parseSearchConsoleItem = (SCItem: SearchAnalyticsRawItem, domainNam
return { keyword, uid, device, country, clicks, impressions, ctr: ctr * 100, position, page };
};
/**
* The function integrates search console data with a keyword object and returns the updated keyword object with the search console data.
* @param {KeywordType} keyword - The `keyword` parameter is of type `KeywordType`, which is a custom type representing a keyword.
* @param {SCDomainDataType} SCData - SCData is an object that contains search analytics data for different time periods
* @returns {KeywordType}
*/
export const integrateKeywordSCData = (keyword: KeywordType, SCData:SCDomainDataType) : KeywordType => {
const kuid = `${keyword.country.toLowerCase()}:${keyword.device}:${keyword.keyword.replaceAll(' ', '_')}`;
const impressions:any = { yesterday: 0, threeDays: 0, sevenDays: 0, thirtyDays: 0, avgSevenDays: 0, avgThreeDays: 0, avgThirtyDays: 0 };
@@ -136,6 +164,11 @@ export const integrateKeywordSCData = (keyword: KeywordType, SCData:SCDomainData
return { ...keyword, scData: finalSCData };
};
/**
* The function reads and returns the domain-specific data stored in a local JSON file.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which the SC data is being read.
* @returns {Promise<SCDomainDataType>}
*/
export const readLocalSCData = async (domain:string): Promise<SCDomainDataType> => {
const filePath = `${process.cwd()}/data/SC_${domain}.json`;
const currentQueueRaw = await readFile(filePath, { encoding: 'utf-8' }).catch(async () => { await updateLocalSCData(domain); return '{}'; });
@@ -143,6 +176,12 @@ export const readLocalSCData = async (domain:string): Promise<SCDomainDataType>
return domainSCData;
};
/**
* The function reads and returns the domain-specific data stored in a local JSON file.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which the SC data will be written.
* @param {SCDomainDataType} scDomainData - an object that contains search analytics data for different time periods.
* @returns {Promise<SCDomainDataType|false>}
*/
export const updateLocalSCData = async (domain:string, scDomainData?:SCDomainDataType): Promise<SCDomainDataType|false> => {
const filePath = `${process.cwd()}/data/SC_${domain}.json`;
const emptyData:SCDomainDataType = { threeDays: [], sevenDays: [], thirtyDays: [], lastFetched: '', lastFetchError: '' };
@@ -150,4 +189,19 @@ export const updateLocalSCData = async (domain:string, scDomainData?:SCDomainDat
return scDomainData || emptyData;
};
/**
* The function removes the domain-specific Seach Console data stored in a local JSON file.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which the SC data file will be removed.
* @returns {Promise<boolean>} - Returns true if file was removed, else returns false.
*/
export const removeLocalSCData = async (domain:string): Promise<boolean> => {
const filePath = `${process.cwd()}/data/SC_${domain}.json`;
try {
await unlink(filePath);
return true;
} catch (error) {
return false;
}
};
export default fetchSearchConsoleData;