mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c6c7fc3d1 | ||
|
|
cca9f95358 | ||
|
|
faa88c9254 | ||
|
|
8b0ee562cf | ||
|
|
2f08bb3f62 | ||
|
|
897aa0b7d7 | ||
|
|
e166b588aa | ||
|
|
c897a52550 | ||
|
|
df3a738788 | ||
|
|
4a47cedad8 | ||
|
|
2783de5c65 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
37
components/common/SecretField.tsx
Normal file
37
components/common/SecretField.tsx
Normal 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;
|
||||
@@ -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() }>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
19
components/keywords/KeywordPosition.tsx
Normal file
19
components/keywords/KeywordPosition.tsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 '>
|
||||
|
||||
@@ -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)}%
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
12
hooks/useIsMobile.tsx
Normal 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
17
hooks/useOnKey.tsx
Normal 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
13
hooks/useWindowResize.tsx
Normal 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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "serpbear",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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']);
|
||||
},
|
||||
|
||||
@@ -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: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
2
utils/client/helpers.ts
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 } = {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user