mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c6c7fc3d1 | ||
|
|
cca9f95358 | ||
|
|
faa88c9254 | ||
|
|
8b0ee562cf | ||
|
|
2f08bb3f62 | ||
|
|
897aa0b7d7 | ||
|
|
e166b588aa | ||
|
|
c897a52550 | ||
|
|
df3a738788 | ||
|
|
4a47cedad8 | ||
|
|
2783de5c65 | ||
|
|
08f44911d7 | ||
|
|
9dce1d5b48 | ||
|
|
fbd23ede25 | ||
|
|
60c68bd339 | ||
|
|
2339e31af9 | ||
|
|
4a60271cac | ||
|
|
c870250fbd | ||
|
|
da92f11afa | ||
|
|
9b9b74af4c | ||
|
|
291aa60bbb | ||
|
|
8a35e358e6 | ||
|
|
f164b287be | ||
|
|
97dd0b131b | ||
|
|
454454a422 | ||
|
|
4620f11c4b |
@@ -12,6 +12,7 @@
|
||||
"no-await-in-loop": "off",
|
||||
"arrow-body-style":"off",
|
||||
"max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}],
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -2,6 +2,50 @@
|
||||
|
||||
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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Adds ability to visit pages from Insight tab ([60c68bd](https://github.com/towfiqi/serpbear/commit/60c68bd339db7aeed35aea035dd21691702ffee3))
|
||||
* Domains now show their favicon. ([2339e31](https://github.com/towfiqi/serpbear/commit/2339e31af9e90bf918f5bcd4f23114f38cef0313)), closes [#130](https://github.com/towfiqi/serpbear/issues/130)
|
||||
* Shows total keywords count in domains page ([fbd23ed](https://github.com/towfiqi/serpbear/commit/fbd23ede256062c72ec2f7e3983a0a02f0240725))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Resolves Website Thumbnail missing issue ([4a60271](https://github.com/towfiqi/serpbear/commit/4a60271cac1209dc02748c4d31943bb21c9ecaf2)), closes [#131](https://github.com/towfiqi/serpbear/issues/131)
|
||||
|
||||
### [0.3.2](https://github.com/towfiqi/serpbear/compare/v0.3.1...v0.3.2) (2023-11-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Resolves issue with adding long tld emails ([9b9b74a](https://github.com/towfiqi/serpbear/commit/9b9b74af4c249e27458d29ba052e96ab2db8b640)), closes [#127](https://github.com/towfiqi/serpbear/issues/127)
|
||||
|
||||
### [0.3.1](https://github.com/towfiqi/serpbear/compare/v0.3.0...v0.3.1) (2023-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Removes dev files from docker volumes ([454454a](https://github.com/towfiqi/serpbear/commit/454454a422bab4d37a2d43ad95868e293a97b88e))
|
||||
* Updates vulnerable dependencies ([97dd0b1](https://github.com/towfiqi/serpbear/commit/97dd0b131be4cec73d07f35062334dd1881f0013))
|
||||
|
||||
## [0.3.0](https://github.com/towfiqi/serpbear/compare/v0.2.6...v0.3.0) (2023-11-03)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ COPY . .
|
||||
FROM node:lts-alpine AS builder
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ SerpBear is an Open Source Search Engine Position Tracking App. It allows you to
|
||||
- **Zero Cost to RUN:** Run the App on mogenius.com or Fly.io for free.
|
||||
|
||||
#### How it Works
|
||||
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SerpApi or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword. Also, When you connect your Googel Search Console account, the app shows actual search visits for each tracked keywords. You can also discover new keywords, and find the most performing keywords, countries, pages.
|
||||
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SearchApi, SerpApi or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword. Also, When you connect your Googel Search Console account, the app shows actual search visits for each tracked keywords. You can also discover new keywords, and find the most performing keywords, countries, pages.
|
||||
|
||||
#### Getting Started
|
||||
- **Step 1:** Deploy & Run the App.
|
||||
@@ -41,6 +41,7 @@ The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SerpA
|
||||
| serply.io | $49/mo | 5000/mo | Yes |
|
||||
| serpapi.com | From $50/mo** | From 5,000/mo** | Yes |
|
||||
| spaceserp.com | $59/lifetime | 15,000/mo | Yes |
|
||||
| SearchApi.io | From $40/mo | From 10,000/mo | Yes |
|
||||
|
||||
(*) Free upto a limit. If you are using ScrapingAnt you can lookup 10,000 times per month for free.
|
||||
(**) Free up to 100 per month. Paid from 5,000 to 10,000,000+ per month.
|
||||
|
||||
@@ -2,10 +2,11 @@ export const dummyDomain = {
|
||||
ID: 1,
|
||||
domain: 'compressimage.io',
|
||||
slug: 'compressimage-io',
|
||||
keywordCount: 0,
|
||||
keywordCount: 10,
|
||||
avgPosition: 24,
|
||||
lastUpdated: '2022-11-11T10:00:32.243',
|
||||
added: '2022-11-11T10:00:32.244',
|
||||
tags: [],
|
||||
tags: '',
|
||||
notification: true,
|
||||
notification_interval: 'daily',
|
||||
notification_emails: '',
|
||||
@@ -33,7 +34,7 @@ export const dummyKeywords = [
|
||||
lastResult: [],
|
||||
sticky: false,
|
||||
updating: false,
|
||||
lastUpdateError: 'false',
|
||||
lastUpdateError: false as false,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
@@ -56,6 +57,23 @@ export const dummyKeywords = [
|
||||
lastResult: [],
|
||||
sticky: false,
|
||||
updating: false,
|
||||
lastUpdateError: 'false',
|
||||
lastUpdateError: false as false,
|
||||
},
|
||||
];
|
||||
|
||||
export const dummySettings = {
|
||||
scaping_api: '',
|
||||
scraper_type: 'none',
|
||||
notification_interval: 'never',
|
||||
notification_email: '',
|
||||
notification_email_from: '',
|
||||
smtp_server: '',
|
||||
smtp_port: '',
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
scrape_retry: false,
|
||||
search_console_integrated: false,
|
||||
screenshot_key: '',
|
||||
available_scapers: [],
|
||||
failed_queue: [],
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import Modal from '../../components/common/Modal';
|
||||
|
||||
// jest.mock('React', () => ({
|
||||
// ...jest.requireActual('React'),
|
||||
// useEffect: jest.fn(),
|
||||
// }));
|
||||
// const mockedUseEffect = useEffect as jest.Mock<any>;
|
||||
|
||||
// jest.mock('../../components/common/Icon', () => () => <div data-testid="icon"/>);
|
||||
|
||||
describe('Modal Component', () => {
|
||||
it('Renders without crashing', async () => {
|
||||
render(<Modal closeModal={() => console.log() }><div></div></Modal>);
|
||||
// mockedUseEffect.mock.calls[0]();
|
||||
expect(document.querySelector('.modal')).toBeInTheDocument();
|
||||
});
|
||||
// it('Sets up the escapae key shortcut', async () => {
|
||||
// render(<Modal closeModal={() => console.log() }><div></div></Modal>);
|
||||
// expect(mockedUseEffect).toBeCalled();
|
||||
// });
|
||||
it('Displays the Given Content', async () => {
|
||||
render(<Modal closeModal={() => console.log() }>
|
||||
<div>
|
||||
<h1>Hello Modal!!</h1>
|
||||
</div>
|
||||
</Modal>);
|
||||
expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument();
|
||||
});
|
||||
it('Renders Modal Title', async () => {
|
||||
render(<Modal closeModal={() => console.log() } title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
|
||||
expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Sidebar from '../../components/common/Sidebar';
|
||||
|
||||
describe('Sidebar Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<Sidebar domains={[]} showAddModal={() => console.log() } />);
|
||||
expect(
|
||||
await screen.findByText('SerpBear'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
// import { useFetchDomains } from '../../services/domains';
|
||||
// import { createWrapper } from '../utils';
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
query: { slug: 'compressimage-io' },
|
||||
push: (link:string) => { console.log('Pushed', link); },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('DomainHooks', () => {
|
||||
it('useFetchDomains should fetch the Domains', async () => {
|
||||
// const { result } = renderHook(() => useFetchDomains(), { wrapper: createWrapper() });
|
||||
const result = { current: { isSuccess: false, data: '' } };
|
||||
await waitFor(() => {
|
||||
console.log('result.current: ', result.current.data);
|
||||
return expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
__tests__/components/DomainItem.test.tsx
Normal file
35
__tests__/components/DomainItem.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import DomainItem from '../../components/domains/DomainItem';
|
||||
import { dummyDomain } from '../../__mocks__/data';
|
||||
|
||||
const updateThumbMock = jest.fn();
|
||||
const domainItemProps = {
|
||||
domain: dummyDomain,
|
||||
selected: false,
|
||||
isConsoleIntegrated: false,
|
||||
thumb: '',
|
||||
updateThumb: updateThumbMock,
|
||||
};
|
||||
|
||||
describe('DomainItem Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
const { container } = render(<DomainItem {...domainItemProps} />);
|
||||
expect(container.querySelector('.domItem')).toBeInTheDocument();
|
||||
});
|
||||
it('renders keywords count', async () => {
|
||||
const { container } = render(<DomainItem {...domainItemProps} />);
|
||||
const domStatskeywords = container.querySelector('.dom_stats div:nth-child(1)');
|
||||
expect(domStatskeywords?.textContent).toBe('Keywords10');
|
||||
});
|
||||
it('renders avg position', async () => {
|
||||
const { container } = render(<DomainItem {...domainItemProps} />);
|
||||
const domStatsAvg = container.querySelector('.dom_stats div:nth-child(2)');
|
||||
expect(domStatsAvg?.textContent).toBe('Avg position24');
|
||||
});
|
||||
it('updates domain thumbnail on relevant button click', async () => {
|
||||
const { container } = render(<DomainItem {...domainItemProps} />);
|
||||
const reloadThumbbBtn = container.querySelector('.domain_thumb button');
|
||||
if (reloadThumbbBtn) fireEvent.click(reloadThumbbBtn);
|
||||
expect(updateThumbMock).toHaveBeenCalledWith(dummyDomain.domain);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import Keyword from '../../components/keywords/Keyword';
|
||||
import { dummyKeywords } from '../data';
|
||||
import { dummyKeywords } from '../../__mocks__/data';
|
||||
|
||||
const keywordFunctions = {
|
||||
const keywordProps = {
|
||||
keywordData: dummyKeywords[0],
|
||||
selected: false,
|
||||
index: 0,
|
||||
showSCData: false,
|
||||
scDataType: '',
|
||||
style: {},
|
||||
refreshkeyword: jest.fn(),
|
||||
favoriteKeyword: jest.fn(),
|
||||
removeKeyword: jest.fn(),
|
||||
@@ -10,35 +16,37 @@ const keywordFunctions = {
|
||||
manageTags: jest.fn(),
|
||||
showKeywordDetails: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('react-chartjs-2', () => ({
|
||||
Line: () => null,
|
||||
}));
|
||||
describe('Keyword Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
render(<Keyword {...keywordProps} />);
|
||||
expect(await screen.findByText('compress image')).toBeInTheDocument();
|
||||
});
|
||||
it('Should Render Position Correctly', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
render(<Keyword {...keywordProps} />);
|
||||
const positionElement = document.querySelector('.keyword_position');
|
||||
expect(positionElement?.childNodes[0].nodeValue).toBe('19');
|
||||
});
|
||||
it('Should Display Position Change arrow', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
render(<Keyword {...keywordProps} />);
|
||||
const positionElement = document.querySelector('.keyword_position i');
|
||||
expect(positionElement?.textContent).toBe('▲ 1');
|
||||
});
|
||||
it('Should Display the SERP Page URL', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
render(<Keyword {...keywordProps} />);
|
||||
const positionElement = document.querySelector('.keyword_url');
|
||||
expect(positionElement?.textContent).toBe('/');
|
||||
});
|
||||
it('Should Display the Keyword Options on dots Click', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
const button = document.querySelector('.keyword .keyword_dots');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
const { container } = render(<Keyword {...keywordProps} />);
|
||||
const button = container.querySelector('.keyword_dots');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(document.querySelector('.keyword_options')).toBeVisible();
|
||||
});
|
||||
// it('Should favorite Keywords', async () => {
|
||||
// render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
// render(<Keyword {...keywordProps} />);
|
||||
// const button = document.querySelector('.keyword .keyword_dots');
|
||||
// if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
// const option = document.querySelector('.keyword .keyword_options li:nth-child(1) a');
|
||||
28
__tests__/components/Modal.test.tsx
Normal file
28
__tests__/components/Modal.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import Modal from '../../components/common/Modal';
|
||||
|
||||
const closeModalMock = jest.fn();
|
||||
describe('Modal Component', () => {
|
||||
it('Renders without crashing', async () => {
|
||||
render(<Modal closeModal={closeModalMock }><div></div></Modal>);
|
||||
expect(document.querySelector('.modal')).toBeInTheDocument();
|
||||
});
|
||||
it('Displays the Given Content', async () => {
|
||||
render(<Modal closeModal={closeModalMock}>
|
||||
<div>
|
||||
<h1>Hello Modal!!</h1>
|
||||
</div>
|
||||
</Modal>);
|
||||
expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument();
|
||||
});
|
||||
it('Renders Modal Title', async () => {
|
||||
render(<Modal closeModal={closeModalMock} title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
|
||||
expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument();
|
||||
});
|
||||
it('Closes the modal on close button click', async () => {
|
||||
const { container } = render(<Modal closeModal={closeModalMock} title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
|
||||
const closeBtn = container.querySelector('.modal-close');
|
||||
if (closeBtn) fireEvent.click(closeBtn);
|
||||
expect(closeModalMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
23
__tests__/components/Sidebar.test.tsx
Normal file
23
__tests__/components/Sidebar.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import Sidebar from '../../components/common/Sidebar';
|
||||
import { dummyDomain } from '../../__mocks__/data';
|
||||
|
||||
const addDomainMock = jest.fn();
|
||||
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
|
||||
|
||||
describe('Sidebar Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
|
||||
expect(screen.getByText('SerpBear')).toBeInTheDocument();
|
||||
});
|
||||
it('renders domain list', async () => {
|
||||
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
|
||||
expect(screen.getByText('compressimage.io')).toBeInTheDocument();
|
||||
});
|
||||
it('calls showAddModal on Add Domain button click', async () => {
|
||||
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
|
||||
const addDomainBtn = screen.getByTestId('add_domain');
|
||||
fireEvent.click(addDomainBtn);
|
||||
expect(addDomainMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import TopBar from '../../components/common/TopBar';
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
pathname: '/',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('TopBar Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<TopBar showSettings={() => console.log() } />);
|
||||
render(<TopBar showSettings={jest.fn} showAddModal={jest.fn} />);
|
||||
expect(
|
||||
await screen.findByText('SerpBear'),
|
||||
).toBeInTheDocument();
|
||||
27
__tests__/hooks/domains.test.tsx
Normal file
27
__tests__/hooks/domains.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import mockRouter from 'next-router-mock';
|
||||
|
||||
import { useFetchDomains } from '../../services/domains';
|
||||
import { createWrapper } from '../../__mocks__/utils';
|
||||
import { dummyDomain } from '../../__mocks__/data';
|
||||
|
||||
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
|
||||
|
||||
fetchMock.mockIf(`${window.location.origin}/api/domains`, async () => {
|
||||
return new Promise((resolve) => {
|
||||
resolve({
|
||||
body: JSON.stringify({ domains: [dummyDomain] }),
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DomainHooks', () => {
|
||||
it('useFetchDomains should fetch the Domains', async () => {
|
||||
const { result } = renderHook(() => useFetchDomains(mockRouter), { wrapper: createWrapper() });
|
||||
// const result = { current: { isSuccess: false, data: '' } };
|
||||
await waitFor(() => {
|
||||
return expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,26 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import SingleDomain from '../../pages/domain/[slug]';
|
||||
import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains';
|
||||
import { useAddKeywords, useDeleteKeywords, useFavKeywords, useFetchKeywords, useRefreshKeywords } from '../../services/keywords';
|
||||
import { dummyDomain, dummyKeywords } from '../data';
|
||||
import { useAddKeywords, useDeleteKeywords,
|
||||
useFavKeywords, useFetchKeywords, useRefreshKeywords, useFetchSingleKeyword } from '../../services/keywords';
|
||||
import { dummyDomain, dummyKeywords, dummySettings } from '../../__mocks__/data';
|
||||
import { useFetchSettings } from '../../services/settings';
|
||||
|
||||
jest.mock('../../services/domains');
|
||||
jest.mock('../../services/keywords');
|
||||
jest.mock('../../services/settings');
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
query: { slug: dummyDomain.slug },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-chartjs-2', () => ({
|
||||
Line: () => null,
|
||||
}));
|
||||
|
||||
const useFetchDomainsFunc = useFetchDomains as jest.Mock<any>;
|
||||
const useFetchKeywordsFunc = useFetchKeywords as jest.Mock<any>;
|
||||
const useDeleteKeywordsFunc = useDeleteKeywords as jest.Mock<any>;
|
||||
@@ -21,11 +30,17 @@ const useAddDomainFunc = useAddDomain as jest.Mock<any>;
|
||||
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();
|
||||
beforeEach(() => {
|
||||
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: () => { } }));
|
||||
@@ -38,158 +53,154 @@ describe('SingleDomain Page', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('Render without crashing.', async () => {
|
||||
const { getByTestId } = render(<SingleDomain />);
|
||||
// screen.debug(undefined, Infinity);
|
||||
expect(getByTestId('domain-header')).toBeInTheDocument();
|
||||
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
expect(screen.getByTestId('domain-header')).toBeInTheDocument();
|
||||
});
|
||||
it('Should Call the useFetchDomains hook on render.', async () => {
|
||||
render(<SingleDomain />);
|
||||
// screen.debug(undefined, Infinity);
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
expect(useFetchDomains).toHaveBeenCalled();
|
||||
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
|
||||
});
|
||||
it('Should Render the Keywords', async () => {
|
||||
render(<SingleDomain />);
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const keywordsCount = document.querySelectorAll('.keyword').length;
|
||||
expect(keywordsCount).toBe(2);
|
||||
});
|
||||
it('Should Display the Keywords Details Sidebar on Keyword Click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const keywords = document.querySelectorAll('.keyword');
|
||||
const firstKeyword = keywords && keywords[0].querySelector('a');
|
||||
if (firstKeyword) fireEvent(firstKeyword, new MouseEvent('click', { bubbles: true }));
|
||||
if (firstKeyword) fireEvent.click(firstKeyword);
|
||||
expect(useFetchSingleKeyword).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('keywordDetails')).toBeVisible();
|
||||
});
|
||||
it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=add_domain]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('add_domain');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(screen.getByTestId('adddomain_modal')).toBeVisible();
|
||||
});
|
||||
it('Should Display the AddKeywords Modal on Add Keyword Button Click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=add_keyword]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('add_keyword');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(screen.getByTestId('addkeywords_modal')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Should display the Domain Settings on Settings Button click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=show_domain_settings]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('show_domain_settings');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(screen.getByTestId('domain_settings')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Device Tab change should be functioning.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=mobile_tab]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('mobile_tab');
|
||||
if (button) fireEvent.click(button);
|
||||
const keywordsCount = document.querySelectorAll('.keyword').length;
|
||||
expect(keywordsCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Search Filter should function properly', async () => {
|
||||
render(<SingleDomain />);
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const inputNode = screen.getByTestId('filter_input');
|
||||
fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event
|
||||
if (inputNode) fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event
|
||||
expect(inputNode.getAttribute('value')).toBe('compressor');
|
||||
const keywordsCount = document.querySelectorAll('.keyword').length;
|
||||
expect(keywordsCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Country Filter should function properly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=filter_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('filter_button');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(document.querySelector('.country_filter')).toBeVisible();
|
||||
|
||||
const countrySelect = document.querySelector('.country_filter .selected');
|
||||
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
|
||||
if (countrySelect) fireEvent.click(countrySelect);
|
||||
expect(document.querySelector('.country_filter .select_list')).toBeVisible();
|
||||
const firstCountry = document.querySelector('.country_filter .select_list ul li:nth-child(1)');
|
||||
if (firstCountry) fireEvent(firstCountry, new MouseEvent('click', { bubbles: true }));
|
||||
if (firstCountry) fireEvent.click(firstCountry);
|
||||
const keywordsCount = document.querySelectorAll('.keyword').length;
|
||||
expect(keywordsCount).toBe(0);
|
||||
});
|
||||
|
||||
// Tags Filter should function properly
|
||||
it('Tags Filter should Render & Function properly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=filter_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('filter_button');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(document.querySelector('.tags_filter')).toBeVisible();
|
||||
|
||||
const countrySelect = document.querySelector('.tags_filter .selected');
|
||||
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
|
||||
if (countrySelect) fireEvent.click(countrySelect);
|
||||
expect(document.querySelector('.tags_filter .select_list')).toBeVisible();
|
||||
expect(document.querySelectorAll('.tags_filter .select_list ul li').length).toBe(1);
|
||||
|
||||
const firstTag = document.querySelector('.tags_filter .select_list ul li:nth-child(1)');
|
||||
if (firstTag) fireEvent(firstTag, new MouseEvent('click', { bubbles: true }));
|
||||
if (firstTag) fireEvent.click(firstTag);
|
||||
expect(document.querySelectorAll('.keyword').length).toBe(1);
|
||||
});
|
||||
|
||||
it('Sort Options Should be visible Sort Button on Click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=sort_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('sort_button');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(document.querySelector('.sort_options')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Sort: Position should sort keywords accordingly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=sort_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('sort_button');
|
||||
if (button) fireEvent.click(button);
|
||||
// Test Top Position Sort
|
||||
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(1)');
|
||||
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (topPosSortOption) fireEvent.click(topPosSortOption);
|
||||
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(firstKeywordTitle).toBe('compress image');
|
||||
|
||||
// Test Lowest Position Sort
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
if (button) fireEvent.click(button);
|
||||
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(2)');
|
||||
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
|
||||
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(secondKeywordTitle).toBe('image compressor');
|
||||
});
|
||||
|
||||
it('Sort: Date Added should sort keywords accordingly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=sort_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('sort_button');
|
||||
if (button) fireEvent.click(button);
|
||||
|
||||
// Test Top Position Sort
|
||||
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(3)');
|
||||
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (topPosSortOption) fireEvent.click(topPosSortOption);
|
||||
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(firstKeywordTitle).toBe('compress image');
|
||||
|
||||
// Test Lowest Position Sort
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
if (button) fireEvent.click(button);
|
||||
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(4)');
|
||||
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
|
||||
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(secondKeywordTitle).toBe('image compressor');
|
||||
});
|
||||
|
||||
it('Sort: Alphabetical should sort keywords accordingly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=sort_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('sort_button');
|
||||
if (button) fireEvent.click(button);
|
||||
|
||||
// Test Top Position Sort
|
||||
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(5)');
|
||||
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (topPosSortOption) fireEvent.click(topPosSortOption);
|
||||
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(firstKeywordTitle).toBe('compress image');
|
||||
|
||||
// Test Lowest Position Sort
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
if (button) fireEvent.click(button);
|
||||
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(6)');
|
||||
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
|
||||
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(secondKeywordTitle).toBe('image compressor');
|
||||
});
|
||||
49
__tests__/pages/domains.test.tsx
Normal file
49
__tests__/pages/domains.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import * as ReactQuery from 'react-query';
|
||||
import { dummyDomain } from '../../__mocks__/data';
|
||||
import Domains from '../../pages/domains';
|
||||
|
||||
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
|
||||
jest.spyOn(ReactQuery, 'useQuery').mockImplementation(jest.fn().mockReturnValue(
|
||||
{ data: { domains: [dummyDomain] }, isLoading: false, isSuccess: true },
|
||||
));
|
||||
|
||||
fetchMock.mockIf(`${window.location.origin}/api/domains`, async () => {
|
||||
return new Promise((resolve) => {
|
||||
resolve({
|
||||
body: JSON.stringify({ domains: [dummyDomain] }),
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Domains Page', () => {
|
||||
const queryClient = new QueryClient();
|
||||
it('Renders without crashing', async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Domains />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('domains')).toBeInTheDocument();
|
||||
});
|
||||
it('Renders the Domain Component', async () => {
|
||||
const { container } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Domains />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(container.querySelector('.domItem')).toBeInTheDocument();
|
||||
});
|
||||
it('Should Display Add Domain Modal on relveant Button Click.', async () => {
|
||||
render(<QueryClientProvider client={queryClient}><Domains /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('addDomainButton');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(screen.getByTestId('adddomain_modal')).toBeVisible();
|
||||
});
|
||||
it('Should Display the version number in Footer.', async () => {
|
||||
render(<QueryClientProvider client={queryClient}><Domains /></QueryClientProvider>);
|
||||
expect(screen.getByText('SerpBear v0.0.0')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -2,21 +2,16 @@ import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import Home from '../../pages/index';
|
||||
|
||||
const routerPush = jest.fn();
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
push: routerPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Home Page', () => {
|
||||
const queryClient = new QueryClient();
|
||||
it('Renders without crashing', async () => {
|
||||
// const dummyDomain = {
|
||||
// ID: 1,
|
||||
// domain: 'compressimage.io',
|
||||
// slug: 'compressimage-io',
|
||||
// keywordCount: 0,
|
||||
// lastUpdated: '2022-11-11T10:00:32.243',
|
||||
// added: '2022-11-11T10:00:32.244',
|
||||
// tags: [],
|
||||
// notification: true,
|
||||
// notification_interval: 'daily',
|
||||
// notification_emails: '',
|
||||
// };
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Home />
|
||||
@@ -26,12 +21,12 @@ describe('Home Page', () => {
|
||||
expect(await screen.findByRole('main')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Add Domain')).not.toBeInTheDocument();
|
||||
});
|
||||
it('Should Display the Add Domain Modal when there are no Domains.', async () => {
|
||||
it('Should redirect to /domains route.', async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Home />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText('Add Domain')).toBeInTheDocument();
|
||||
expect(routerPush).toHaveBeenCalledWith('/domains');
|
||||
});
|
||||
});
|
||||
@@ -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,3 +1,4 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -26,11 +27,11 @@ const Sidebar = ({ domains, showAddModal } : SidebarProps) => {
|
||||
rounded-r-none ${((`/domain/${d.slug}` === router.asPath || `/domain/console/${d.slug}` === router.asPath
|
||||
|| `/domain/insight/${d.slug}` === router.asPath)
|
||||
? 'bg-white text-zinc-800 border border-r-0' : 'text-zinc-500')}`}>
|
||||
<i className={'text-center leading-4 mr-2 inline-block rounded-full w-5 h-5 bg-orange-200 not-italic'}>
|
||||
{d.domain.charAt(0)}
|
||||
</i>
|
||||
<img
|
||||
className={' inline-block mr-1'}
|
||||
src={`https://www.google.com/s2/favicons?domain=${d.domain}&sz=16`} alt={d.domain}
|
||||
/>
|
||||
{d.domain}
|
||||
{/* <span>0</span> */}
|
||||
</a>
|
||||
</Link>
|
||||
</li>)
|
||||
|
||||
@@ -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() }>
|
||||
|
||||
@@ -11,9 +11,10 @@ type DomainItemProps = {
|
||||
selected: boolean,
|
||||
isConsoleIntegrated: boolean,
|
||||
thumb: string,
|
||||
updateThumb: Function,
|
||||
}
|
||||
|
||||
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: DomainItemProps) => {
|
||||
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb, updateThumb }: DomainItemProps) => {
|
||||
const { keywordsUpdated, slug, keywordCount = 0, avgPosition = 0, scVisits = 0, scImpressions = 0, scPosition = 0 } = domain;
|
||||
// const router = useRouter();
|
||||
return (
|
||||
@@ -21,8 +22,20 @@ const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: Do
|
||||
<Link href={`/domain/${slug}`} passHref={true}>
|
||||
<a className='flex flex-col lg:flex-row'>
|
||||
<div className={`flex-1 p-6 flex ${!isConsoleIntegrated ? 'basis-1/3' : ''}`}>
|
||||
<div className="domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded border border-gray-200 overflow-hidden">
|
||||
{thumb && <img src={thumb} alt={domain.domain} />}
|
||||
<div className="group domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded
|
||||
border border-gray-200 overflow-hidden flex justify-center relative">
|
||||
<button
|
||||
className=' absolute right-1 top-0 text-gray-400 p-1 transition-all
|
||||
invisible opacity-0 group-hover:visible group-hover:opacity-100 hover:text-gray-600 z-10'
|
||||
title='Reload Website Screenshot'
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); updateThumb(domain.domain); }}
|
||||
>
|
||||
<Icon type="reload" size={12} />
|
||||
</button>
|
||||
<img
|
||||
className={`self-center ${!thumb ? 'max-w-[50px]' : ''}`}
|
||||
src={thumb || `https://www.google.com/s2/favicons?domain=${domain.domain}&sz=128`} alt={domain.domain}
|
||||
/>
|
||||
</div>
|
||||
<div className="domain_details flex-1">
|
||||
<h3 className='font-semibold text-base mb-2'>{domain.domain}</h3>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -41,7 +38,7 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
let error: DomainSettingsError | null = null;
|
||||
if (domainSettings.notification_emails) {
|
||||
const notification_emails = domainSettings.notification_emails.split(',');
|
||||
const invalidEmails = notification_emails.find((x) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(x) === false);
|
||||
const invalidEmails = notification_emails.find((x) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,15})+$/.test(x) === false);
|
||||
console.log('invalidEmails: ', invalidEmails);
|
||||
if (invalidEmails) {
|
||||
error = { type: 'email', msg: 'Invalid Email' };
|
||||
|
||||
@@ -12,7 +12,7 @@ type SCInsightProps = {
|
||||
isConsoleIntegrated: boolean,
|
||||
}
|
||||
|
||||
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SCInsightProps) => {
|
||||
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true, domain }: SCInsightProps) => {
|
||||
const [activeTab, setActiveTab] = useState<string>('stats');
|
||||
|
||||
const insightItems = insight[activeTab as keyof InsightDataType];
|
||||
@@ -108,7 +108,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SC
|
||||
(item:SCInsightItem, index: number) => {
|
||||
const insightItemCount = insight ? insightItems : [];
|
||||
const lastItem = !!(insightItemCount && (index === insightItemCount.length));
|
||||
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} />;
|
||||
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} domain={domain?.domain || ''} />;
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React from 'react';
|
||||
import countries from '../../utils/countries';
|
||||
import Icon from '../common/Icon';
|
||||
import { formattedNum } from '../../utils/client/helpers';
|
||||
|
||||
type InsightItemProps = {
|
||||
item: SCInsightItem,
|
||||
lastItem: boolean,
|
||||
type: string
|
||||
type: string,
|
||||
domain: string
|
||||
}
|
||||
|
||||
const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
|
||||
const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
|
||||
const { clicks, impressions, ctr, position, country = 'zzz', keyword, page, keywords = 0, countries: cntrs = 0, date } = item;
|
||||
let firstItem = keyword;
|
||||
if (type === 'pages') { firstItem = page; } if (type === 'stats') {
|
||||
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
|
||||
@@ -24,7 +25,7 @@ const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
|
||||
|
||||
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold'>
|
||||
{type === 'countries' && <span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} />}
|
||||
{firstItem}
|
||||
{type === 'pages' && domain ? <a href={`https://${domain}${page}`} target='_blank' rel="noreferrer">{firstItem}</a> : firstItem}
|
||||
</div>
|
||||
|
||||
<div className='keyword_pos text-center inline-block mr-3 lg:mr-0 lg:flex-1'>
|
||||
@@ -34,7 +35,6 @@ const InsightItem = ({ item, lastItem, type }: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,
|
||||
@@ -54,18 +55,14 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
minWidth={270}
|
||||
/>
|
||||
</div>
|
||||
{['scrapingant', 'scrapingrobot', 'serply', 'serpapi', 'spaceSerp'].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>
|
||||
{['scrapingant', 'scrapingrobot', 'serply', 'serpapi', 'spaceSerp', 'searchapi'].includes(settings.scraper_type) && (
|
||||
<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">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import useUpdateSettings, { useFetchSettings } from '../../services/settings';
|
||||
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;
|
||||
@@ -1,11 +1,26 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import 'isomorphic-fetch';
|
||||
import './styles/globals.css';
|
||||
import '@testing-library/jest-dom';
|
||||
import { enableFetchMocks } from 'jest-fetch-mock';
|
||||
// Optional: configure or set up a testing framework before each test.
|
||||
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
|
||||
|
||||
// Used for __tests__/testing-library.js
|
||||
// Learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
window.matchMedia = (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
});
|
||||
|
||||
global.ResizeObserver = require('resize-observer-polyfill');
|
||||
|
||||
// Enable Fetch Mocking
|
||||
enableFetchMocks();
|
||||
|
||||
15378
package-lock.json
generated
15378
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "serpbear",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -17,7 +17,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@googleapis/searchconsole": "^1.0.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"axios": "^1.1.3",
|
||||
"axios-retry": "^3.3.1",
|
||||
@@ -31,10 +30,9 @@
|
||||
"dotenv": "^16.0.3",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"msw": "^0.49.0",
|
||||
"next": "12.3.1",
|
||||
"node-cron": "^3.0.2",
|
||||
"next": "^12.3.4",
|
||||
"nodemailer": "^6.8.0",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^4.3.1",
|
||||
@@ -45,15 +43,17 @@
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-window": "^1.8.8",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sequelize": "^6.25.2",
|
||||
"sequelize": "^6.34.0",
|
||||
"sequelize-typescript": "^2.1.5",
|
||||
"sqlite3": "^5.1.2"
|
||||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/jest-dom": "^6.1.4",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/cookies": "^0.7.7",
|
||||
"@types/cryptr": "^4.0.1",
|
||||
"@types/isomorphic-fetch": "^0.0.36",
|
||||
"@types/jest": "^29.5.8",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/node": "18.11.0",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
@@ -65,9 +65,11 @@
|
||||
"eslint": "8.25.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-next": "12.3.1",
|
||||
"jest": "^29.3.1",
|
||||
"jest-environment-jsdom": "^29.3.1",
|
||||
"postcss": "^8.4.18",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"next-router-mock": "^0.9.10",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.7.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.55.0",
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
|
||||
};
|
||||
|
||||
export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
const screenshotAPIKey = process.env.SCREENSHOT_API || '69408-serpbear';
|
||||
try {
|
||||
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
|
||||
const failedQueueRaw = await readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' });
|
||||
@@ -73,6 +74,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL),
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
|
||||
failed_queue: failedQueue,
|
||||
screenshot_key: screenshotAPIKey,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('Error Decrypting Settings API Keys!');
|
||||
@@ -81,7 +83,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
return decryptedSettings;
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Getting App Settings. ', error);
|
||||
const settings = {
|
||||
const settings: SettingsType = {
|
||||
scraper_type: 'none',
|
||||
notification_interval: 'never',
|
||||
notification_email: '',
|
||||
@@ -91,8 +93,14 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
scrape_retry: false,
|
||||
screenshot_key: screenshotAPIKey,
|
||||
};
|
||||
const otherSettings = {
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
|
||||
failed_queue: [],
|
||||
};
|
||||
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(settings), { encoding: 'utf-8' });
|
||||
return settings;
|
||||
await writeFile(`${process.cwd()}/data/failed_queue.json`, JSON.stringify([]), { encoding: 'utf-8' });
|
||||
return { ...settings, ...otherSettings };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import TopBar from '../../components/common/TopBar';
|
||||
import AddDomain from '../../components/domains/AddDomain';
|
||||
import Settings from '../../components/settings/Settings';
|
||||
import { useFetchSettings } from '../../services/settings';
|
||||
import { useFetchDomains } from '../../services/domains';
|
||||
import { fetchDomainScreenshot, useFetchDomains } from '../../services/domains';
|
||||
import DomainItem from '../../components/domains/DomainItem';
|
||||
import Icon from '../../components/common/Icon';
|
||||
|
||||
type thumbImages = { [domain:string] : string }
|
||||
|
||||
const SingleDomain: NextPage = () => {
|
||||
const Domains: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const [noScrapprtError, setNoScrapprtError] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@@ -22,33 +23,28 @@ const SingleDomain: NextPage = () => {
|
||||
const { data: appSettings } = useFetchSettings();
|
||||
const { data: domainsData, isLoading } = useFetchDomains(router, true);
|
||||
|
||||
const totalKeywords = useMemo(() => {
|
||||
let keywords = 0;
|
||||
if (domainsData?.domains) {
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
keywords += domain?.keywordCount || 0;
|
||||
});
|
||||
}
|
||||
return keywords;
|
||||
}, [domainsData]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log('Domains Data: ', domainsData);
|
||||
if (domainsData?.domains && domainsData.domains.length > 0) {
|
||||
const domainThumbsRaw = localStorage.getItem('domainThumbs');
|
||||
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
|
||||
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings?.settings?.screenshot_key) {
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
if (domain.domain) {
|
||||
if (!domThumbs[domain.domain]) {
|
||||
const domainImageBlob = await fetch(`https://image.thum.io/get/auth/66909-serpbear/maxAge/96/width/200/https://${domain.domain}`).then((res) => res.blob());
|
||||
if (domainImageBlob) {
|
||||
const reader = new FileReader();
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = resolve;
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(domainImageBlob);
|
||||
});
|
||||
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
|
||||
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain.domain]: imageBase }));
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: imageBase }));
|
||||
}
|
||||
} else {
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domThumbs[domain.domain] }));
|
||||
const domainThumb = await fetchDomainScreenshot(domain.domain, appSettings.settings.screenshot_key);
|
||||
if (domainThumb) {
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domainThumb }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [domainsData]);
|
||||
}, [domainsData, appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log('appSettings.settings: ', appSettings && appSettings.settings);
|
||||
@@ -57,8 +53,20 @@ const SingleDomain: NextPage = () => {
|
||||
}
|
||||
}, [appSettings]);
|
||||
|
||||
const manuallyUpdateThumb = async (domain: string) => {
|
||||
if (domain && appSettings?.settings?.screenshot_key) {
|
||||
const domainThumb = await fetchDomainScreenshot(domain, appSettings.settings.screenshot_key, true);
|
||||
if (domainThumb) {
|
||||
toast(`${domain} Screenshot Updated Successfully!`, { icon: '✔️' });
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain]: domainThumb }));
|
||||
} else {
|
||||
toast(`Failed to Fetch ${domain} Screenshot!`, { icon: '⚠️' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="Domain flex flex-col min-h-screen">
|
||||
<div data-testid="domains" className="Domain flex flex-col min-h-screen">
|
||||
{noScrapprtError && (
|
||||
<div className=' p-3 bg-red-600 text-white text-sm text-center'>
|
||||
A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app.
|
||||
@@ -71,9 +79,12 @@ const SingleDomain: NextPage = () => {
|
||||
|
||||
<div className="flex flex-col w-full max-w-5xl mx-auto p-6 lg:mt-24 lg:p-0">
|
||||
<div className='flex justify-between mb-2 items-center'>
|
||||
<div className=' text-sm'>{domainsData?.domains?.length || 0} Domains</div>
|
||||
<div className=' text-sm text-gray-600'>
|
||||
{domainsData?.domains?.length || 0} Domains <span className=' text-gray-300 ml-1 mr-1'>|</span> {totalKeywords} keywords
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
data-testid="addDomainButton"
|
||||
className={'ml-2 inline-block py-2 text-blue-700 font-bold text-sm'}
|
||||
onClick={() => setShowAddDomain(true)}>
|
||||
<span
|
||||
@@ -90,6 +101,7 @@ const SingleDomain: NextPage = () => {
|
||||
selected={false}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) }
|
||||
thumb={domainThumbs[domain.domain]}
|
||||
updateThumb={manuallyUpdateThumb}
|
||||
// isConsoleIntegrated={false}
|
||||
/>;
|
||||
})}
|
||||
@@ -107,7 +119,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={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
|
||||
<Settings closeSettings={() => setShowSettings(false)} />
|
||||
@@ -115,8 +127,9 @@ const SingleDomain: NextPage = () => {
|
||||
<footer className='text-center flex flex-1 justify-center pb-5 items-end'>
|
||||
<span className='text-gray-500 text-xs'><a href='https://github.com/towfiqi/serpbear' target="_blank" rel='noreferrer'>SerpBear v{appSettings?.settings?.version || '0.0.0'}</a></span>
|
||||
</footer>
|
||||
<Toaster position='bottom-center' containerClassName="react_toaster" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleDomain;
|
||||
export default Domains;
|
||||
|
||||
@@ -8,7 +8,7 @@ import Icon from '../components/common/Icon';
|
||||
const Home: NextPage = () => {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.push('/domains');
|
||||
if (router) router.push('/domains');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import serpapi from './services/serpapi';
|
||||
import serply from './services/serply';
|
||||
import spaceserp from './services/spaceserp';
|
||||
import proxy from './services/proxy';
|
||||
import searchapi from './services/searchapi';
|
||||
|
||||
export default [
|
||||
scrapingRobot,
|
||||
@@ -12,4 +13,5 @@ export default [
|
||||
serply,
|
||||
spaceserp,
|
||||
proxy,
|
||||
searchapi,
|
||||
];
|
||||
|
||||
38
scrapers/services/searchapi.ts
Normal file
38
scrapers/services/searchapi.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const searchapi:ScraperSettings = {
|
||||
id: 'searchapi',
|
||||
name: 'SearchApi.io',
|
||||
website: 'searchapi.io',
|
||||
headers: (keyword, settings) => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${settings.scaping_api}`,
|
||||
};
|
||||
},
|
||||
scrapeURL: (keyword) => {
|
||||
return `https://www.searchapi.io/api/v1/search?engine=google&q=${encodeURI(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
const extractedResult = [];
|
||||
const results: SearchApiResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SearchApiResult[];
|
||||
|
||||
for (const { link, title, position } of results) {
|
||||
if (title && link) {
|
||||
extractedResult.push({
|
||||
title,
|
||||
url: link,
|
||||
position,
|
||||
});
|
||||
}
|
||||
}
|
||||
return extractedResult;
|
||||
},
|
||||
};
|
||||
|
||||
interface SearchApiResult {
|
||||
title: string,
|
||||
link: string,
|
||||
position: number,
|
||||
}
|
||||
|
||||
export default searchapi;
|
||||
@@ -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) {
|
||||
@@ -19,6 +19,36 @@ export async function fetchDomains(router: NextRouter, withStats:boolean) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchDomainScreenshot(domain: string, screenshot_key:string, forceFetch = false): Promise<string | false> {
|
||||
const domainThumbsRaw = localStorage.getItem('domainThumbs');
|
||||
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
|
||||
if (!domThumbs[domain] || forceFetch) {
|
||||
try {
|
||||
const screenshotURL = `https://image.thum.io/get/auth/${screenshot_key}/maxAge/96/width/200/https://${domain}`;
|
||||
const domainImageRes = await fetch(screenshotURL);
|
||||
const domainImageBlob = domainImageRes.status === 200 ? await domainImageRes.blob() : false;
|
||||
if (domainImageBlob) {
|
||||
const reader = new FileReader();
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = resolve;
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(domainImageBlob);
|
||||
});
|
||||
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
|
||||
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain]: imageBase }));
|
||||
return imageBase;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
} else if (domThumbs[domain]) {
|
||||
return domThumbs[domain];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
|
||||
return useQuery('domains', () => fetchDomains(router, withStats));
|
||||
}
|
||||
@@ -26,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');
|
||||
@@ -37,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: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useFetchSettings() {
|
||||
return useQuery('settings', () => fetchSettings());
|
||||
}
|
||||
|
||||
const useUpdateSettings = (onSuccess:Function|undefined) => {
|
||||
export const useUpdateSettings = (onSuccess:Function|undefined) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(async (settings: SettingsType) => {
|
||||
@@ -60,5 +60,3 @@ export function useClearFailedQueue(onSuccess:Function) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default useUpdateSettings;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"types.d.ts"
|
||||
"types.d.ts",
|
||||
"./jest.setup.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
3
types.d.ts
vendored
3
types.d.ts
vendored
@@ -83,7 +83,8 @@ type SettingsType = {
|
||||
scrape_delay?: string,
|
||||
scrape_retry?: boolean,
|
||||
failed_queue?: string[]
|
||||
version?: string
|
||||
version?: string,
|
||||
screenshot_key?: string,
|
||||
}
|
||||
|
||||
type KeywordSCDataChild = {
|
||||
|
||||
@@ -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 } = {};
|
||||
|
||||
@@ -17,11 +17,11 @@ const refreshAndUpdateKeywords = async (rawkeyword:Keyword[], settings:SettingsT
|
||||
const start = performance.now();
|
||||
const updatedKeywords: KeywordType[] = [];
|
||||
|
||||
if (['scrapingant', 'serpapi'].includes(settings.scraper_type)) {
|
||||
if (['scrapingant', 'serpapi', 'searchapi'].includes(settings.scraper_type)) {
|
||||
const refreshedResults = await refreshParallel(keywords, settings);
|
||||
if (refreshedResults.length > 0) {
|
||||
for (const keyword of rawkeyword) {
|
||||
const refreshedkeywordData = refreshedResults.find((k) => k && k.ID === keyword.id);
|
||||
const refreshedkeywordData = refreshedResults.find((k) => k && k.ID === keyword.ID);
|
||||
if (refreshedkeywordData) {
|
||||
const updatedkeyword = await updateKeywordPosition(keyword, refreshedkeywordData, settings);
|
||||
updatedKeywords.push(updatedkeyword);
|
||||
|
||||
@@ -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