first commit

This commit is contained in:
Towfiq
2022-11-24 20:13:54 +06:00
parent e84f43d9f6
commit 51ebe3e326
84 changed files with 18529 additions and 302 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.vscode
TODO
data/database.sqlite
data/settings.json
data/failed_queue.json

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
USER=admin
PASSWORD=0123456789
SECRET=4715aed3216f7b0a38e6b534a958362654e96d10fbc04700770d572af3dce43625dd
APIKEY=5saedXklbslhnapihe2pihp3pih4fdnakhjwq5
SESSION_DURATION=24
NEXT_PUBLIC_APP_URL=http://localhost:3000

View File

@@ -1,3 +1,27 @@
{
"extends": "next/core-web-vitals"
"extends": ["next/core-web-vitals", "airbnb-base"],
"rules":{
"linebreak-style": 0,
"indent":"off",
"no-undef":"off",
"no-console": "off",
"camelcase":"off",
"object-curly-newline":"off",
"no-use-before-define": "off",
"no-restricted-syntax": "off",
"no-await-in-loop": "off",
"arrow-body-style":"off",
"max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}],
"import/extensions": [
"error",
"ignorePackages",
{
"": "never",
"js": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
]
}
}

11
.gitignore vendored
View File

@@ -1,5 +1,3 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
@@ -7,6 +5,7 @@
# testing
/coverage
/.swc/
# next.js
/.next/
@@ -18,6 +17,7 @@
# misc
.DS_Store
*.pem
.vscode
# debug
npm-debug.log*
@@ -34,3 +34,10 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
#todo
TODO
#database
data/

40
Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
FROM node:lts-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
FROM node:lts-alpine AS builder
WORKDIR /app
COPY --from=deps /app ./
RUN npm run build
FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# COPY --from=builder --chown=nextjs:nodejs /app/package*.json ./
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/data ./data
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# setup the cron
COPY --from=builder --chown=nextjs:nodejs /app/cron.js ./
RUN npm i cryptr dotenv
RUN npm i -g concurrently
USER nextjs
EXPOSE 3000
# CMD ["node", "server.js"]
# CMD ["npm", "start"]
CMD ["concurrently","node server.js", "node cron.js"]

View File

@@ -1,34 +1,41 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
![SerpBear](https://i.imgur.com/0S2zIH3.png)
# SerpBear
## Getting Started
SerpBear is an Open Source Search Engine Position Tracking App. It allows you to track your website's keyword positions in Google and get notified of their positions.
First, run the development server:
![Easy to Use Search Engine Rank Tracker](https://i.imgur.com/bRzpmCK.gif)
```bash
npm run dev
# or
yarn dev
```
**Features**
- **Unlimited Keywords:** Add unlimited domains and unlimited keywords to track their SERP.
- **Email Notification:** Get notified of your keyword position changes daily/weekly/monthly through email.
- **SERP API:** SerpBear comes with built-in API that you can use for your marketing & data reporting tools.
- **Mobile App:** Add the PWA app to your mobile for a better mobile experience.
- **Zero Cost to RUN:** Run the App on mogenius.com or Fly.io for free.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
**How it Works**
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
**Getting Started**
- **Step 1:** Deploy & Run the App.
- **Step 2:** Access your App and Login.
- **Step 3:** Add your First domain.
- **Step 4:** Get an free API key from either ScrapingAnt or ScrapingRobot. Skip if you want to use Proxy ips.
- **Step 5:** Setup the Scraping API/Proxy from the App's Settings interface.
- **Step 6:** Add your keywords and start tracking.
- **Step 7:** Optional. From the Settings panel, setup SMTP details to get notified of your keywords positions through email. You can use ElasticEmail and Sendpulse SMTP services that are free.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
**Compare SerpBear with other SERP tracking services:**
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|Service | Cost | SERP Lookup | API |
|--|--|--|--|
| SerpBear | Free* | Unlimited* | Yes |
| ranktracker.com | $18/mo| 3,000/mo| No |
| SerpWatch.io | $29/mo | 7500/mo | Yes |
| Serpwatcher.com | $49/mo| 3000/mo | No |
| whatsmyserp.com | $49/mo| 30,000/mo| No |
## Learn More
(*) Free upto a limit. If you are using ScrapingAnt you can lookup 10,000 times per month for free.
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
**Stack**
- Next.js for Frontend & Backend.
- Sqlite for Database.

View File

@@ -0,0 +1,9 @@
import { render } from '@testing-library/react';
import Icon from '../../components/common/Icon';
describe('Icon Component', () => {
it('renders without crashing', async () => {
render(<Icon type='logo' size={24} />);
expect(document.querySelector('svg')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react';
import Keyword from '../../components/keywords/Keyword';
import { dummyKeywords } from '../data';
const keywordFunctions = {
refreshkeyword: jest.fn(),
favoriteKeyword: jest.fn(),
removeKeyword: jest.fn(),
selectKeyword: jest.fn(),
manageTags: jest.fn(),
showKeywordDetails: jest.fn(),
};
describe('Keyword Component', () => {
it('renders without crashing', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
expect(await screen.findByText('compress image')).toBeInTheDocument();
});
it('Should Render Position Correctly', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
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} />);
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} />);
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 }));
expect(document.querySelector('.keyword_options')).toBeVisible();
});
// it('Should favorite Keywords', 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 option = document.querySelector('.keyword .keyword_options li:nth-child(1) a');
// if (option) fireEvent(option, new MouseEvent('click', { bubbles: true }));
// const { favoriteKeyword } = keywordFunctions;
// expect(favoriteKeyword).toHaveBeenCalled();
// });
});

View File

@@ -0,0 +1,35 @@
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();
});
});

View File

@@ -0,0 +1,11 @@
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();
});
});

View File

@@ -0,0 +1,11 @@
import { render, screen } from '@testing-library/react';
import TopBar from '../../components/common/TopBar';
describe('TopBar Component', () => {
it('renders without crashing', async () => {
render(<TopBar showSettings={() => console.log() } />);
expect(
await screen.findByText('SerpBear'),
).toBeInTheDocument();
});
});

61
__test__/data.ts Normal file
View File

@@ -0,0 +1,61 @@
export 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: '',
};
export const dummyKeywords = [
{
ID: 1,
keyword: 'compress image',
device: 'desktop',
country: 'US',
domain: 'compressimage.io',
lastUpdated: '2022-11-15T10:49:53.113',
added: '2022-11-11T10:01:06.951',
position: 19,
history: {
'2022-11-11': 21,
'2022-11-12': 24,
'2022-11-13': 24,
'2022-11-14': 20,
'2022-11-15': 19,
},
url: 'https://compressimage.io/',
tags: [],
lastResult: [],
sticky: false,
updating: false,
lastUpdateError: 'false',
},
{
ID: 2,
keyword: 'image compressor',
device: 'desktop',
country: 'US',
domain: 'compressimage.io',
lastUpdated: '2022-11-15T10:49:53.119',
added: '2022-11-15T10:01:06.951',
position: 29,
history: {
'2022-11-11': 33,
'2022-11-12': 34,
'2022-11-13': 17,
'2022-11-14': 30,
'2022-11-15': 29,
},
url: 'https://compressimage.io/',
tags: ['compressor'],
lastResult: [],
sticky: false,
updating: false,
lastUpdateError: 'false',
},
];

View File

@@ -0,0 +1,21 @@
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);
});
});
});

View File

@@ -0,0 +1,196 @@
import { fireEvent, render, screen } from '@testing-library/react';
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';
jest.mock('../../services/domains');
jest.mock('../../services/keywords');
jest.mock('next/router', () => ({
useRouter: () => ({
query: { slug: dummyDomain.slug },
}),
}));
const useFetchDomainsFunc = useFetchDomains as jest.Mock<any>;
const useFetchKeywordsFunc = useFetchKeywords as jest.Mock<any>;
const useDeleteKeywordsFunc = useDeleteKeywords as jest.Mock<any>;
const useFavKeywordsFunc = useFavKeywords as jest.Mock<any>;
const useRefreshKeywordsFunc = useRefreshKeywords as jest.Mock<any>;
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>;
describe('SingleDomain Page', () => {
beforeEach(() => {
useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false }));
useFetchKeywordsFunc.mockImplementation(() => ({ keywordsData: { keywords: dummyKeywords }, keywordsLoading: false }));
useDeleteKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useFavKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useRefreshKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useAddDomainFunc.mockImplementation(() => ({ mutate: () => { } }));
useAddKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useUpdateDomainFunc.mockImplementation(() => ({ mutate: () => { } }));
useDeleteDomainFunc.mockImplementation(() => ({ mutate: () => { } }));
});
afterEach(() => {
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();
});
it('Should Call the useFetchDomains hook on render.', async () => {
render(<SingleDomain />);
// screen.debug(undefined, Infinity);
expect(useFetchDomains).toHaveBeenCalled();
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
});
it('Should Render the Keywords', async () => {
render(<SingleDomain />);
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(2);
});
it('Should Display the Keywords Details Sidebar on Keyword Click.', async () => {
render(<SingleDomain />);
const keywords = document.querySelectorAll('.keyword');
const firstKeyword = keywords && keywords[0].querySelector('a');
if (firstKeyword) fireEvent(firstKeyword, new MouseEvent('click', { bubbles: true }));
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 }));
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 }));
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 }));
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 }));
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(0);
});
it('Search Filter should function properly', async () => {
render(<SingleDomain />);
const inputNode = screen.getByTestId('filter_input');
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 }));
expect(document.querySelector('.country_filter')).toBeVisible();
const countrySelect = document.querySelector('.country_filter .selected');
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
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 }));
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 }));
expect(document.querySelector('.tags_filter')).toBeVisible();
const countrySelect = document.querySelector('.tags_filter .selected');
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
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 }));
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 }));
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 }));
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(1)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
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 }));
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(2)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
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 }));
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(3)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
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 }));
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(4)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
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 }));
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(5)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
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 }));
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(6)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor');
});
});

View File

@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import Home from '../../pages/index';
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 />
</QueryClientProvider>,
);
// console.log(prettyDOM(renderer.container.firstChild));
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 () => {
render(
<QueryClientProvider client={queryClient}>
<Home />
</QueryClientProvider>,
);
expect(await screen.findByText('Add Domain')).toBeInTheDocument();
});
});

47
__test__/utils.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/react';
import { rest } from 'msw';
import * as React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
export const handlers = [
rest.get(
'*/react-query',
(req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
name: 'mocked-react-query',
}),
);
},
),
];
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
export function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient();
const { rerender, ...result } = render(
<QueryClientProvider client={testQueryClient}>{ui}</QueryClientProvider>,
);
return {
...result,
rerender: (rerenderUi: React.ReactElement) => rerender(
<QueryClientProvider client={testQueryClient}>{rerenderUi}</QueryClientProvider>,
),
};
}
export function createWrapper() {
const testQueryClient = createTestQueryClient();
// eslint-disable-next-line react/display-name
return ({ children }: {children: React.ReactNode}) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
type ChartProps ={
labels: string[],
sreies: number[]
}
const Chart = ({ labels, sreies }:ChartProps) => {
const options = {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
scales: {
y: {
reverse: true,
min: 1,
max: 100,
},
},
plugins: {
legend: {
display: false,
},
},
};
return <Line
datasetIdKey='XXX'
options={options}
data={{
labels,
datasets: [
{
fill: 'start',
data: sreies,
borderColor: 'rgb(31, 205, 176)',
backgroundColor: 'rgba(31, 205, 176, 0.5)',
},
],
}}
/>;
};
export default Chart;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler } from 'chart.js';
import { Line } from 'react-chartjs-2';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Title, Tooltip, Legend);
type ChartProps ={
labels: string[],
sreies: number[]
}
const ChartSlim = ({ labels, sreies }:ChartProps) => {
const options = {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
scales: {
y: {
display: false,
reverse: true,
min: 1,
max: 100,
},
x: {
display: false,
},
},
plugins: {
tooltip: {
enabled: false,
},
legend: {
display: false,
},
},
};
return <div className='w-[120px] h-[30px] rounded border border-gray-200'>
<Line
datasetIdKey='XXX'
options={options}
data={{
labels,
datasets: [
{
fill: 'start',
showLine: false,
data: sreies,
pointRadius: 0,
borderColor: 'rgb(31, 205, 176)',
backgroundColor: 'rgba(31, 205, 176, 0.5)',
},
],
}}
/>
</div>;
};
export default ChartSlim;

193
components/common/Icon.tsx Normal file
View File

@@ -0,0 +1,193 @@
/* eslint-disable max-len */
import React from 'react';
type IconProps = {
type: string;
size?: number;
color?: string;
title?: string;
classes?: string;
}
const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '' }: IconProps) => {
const xmlnsProps = { xmlns: 'http://www.w3.org/2000/svg', xmlnsXlink: 'http://www.w3.org/1999/xlink', preserveAspectRatio: 'xMidYMid meet' };
return (
<span className={`icon inline-block relative top-[2px] ${classes}`} title={title}>
{type === 'logo'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 1484.32 1348.5">
<path fill={color} d="M1406.23,604.17s-44-158.18,40.43-192.67,195,97.52,195,97.52,314-65.41,534,0c0,0,122.16-105.61,214.68-80.28,99.9,27.36,32.7,181.38,32.7,181.38s228.36,384.15,239.06,737.38c0,0-346.1,346.09-746.9,406.75,0,0-527.47-106.44-737.38-449.57C1177.88,1304.68,1169.55,1008.54,1406.23,604.17Z" transform="translate(-1177.84 -405.75)"/>
<path fill='white' d="M1920.79,873S1659,855,1635,1275c0,0-19,182,304.82,178.35,244-2.75,260.55-118.61,266.41-182C2212,1209,2131,874,1920.79,873Z" transform="translate(-1177.84 -405.75)"/>
<path fill={color} d="M1930.07,1194.67s143.91,5.95,116.55,94-118.93,83.25-118.93,83.25-96.34,0-134.4-95.15C1764.45,1204.62,1930.07,1194.67,1930.07,1194.67Z" transform="translate(-1177.84 -405.75)"/>
</svg>
}
{type === 'loading'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8a8 8 0 0 1-8 8z" opacity=".5" fill={color}/>
<path d="M20 12h2A10 10 0 0 0 12 2v2a8 8 0 0 1 8 8z" fill={color}>
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</path>
</svg>
}
{type === 'menu'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M4 6h16M4 12h16M4 18h16" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
}
{type === 'close'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill={color} d="M5.293 5.293a1 1 0 0 1 1.414 0L12 10.586l5.293-5.293a1 1 0 1 1 1.414 1.414L13.414 12l5.293 5.293a1 1 0 0 1-1.414 1.414L12 13.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L10.586 12L5.293 6.707a1 1 0 0 1 0-1.414z"/>
</svg>
}
{type === 'download'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill={color} d="M12 16l4-5h-3V4h-2v7H8z" />
<path fill={color} d="M20 18H4v-7H2v7c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2v-7h-2v7z" />
</svg>
}
{type === 'trash'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M5 20a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8h2V6h-4V4a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v2H3v2h2zM9 4h6v2H9zM8 8h9v12H7V8z" fill={color} />
<path d="M9 10h2v8H9zm4 0h2v8h-2z" fill={color} />
</svg>
}
{type === 'edit'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 1024 1024">
<path fill={color} d="M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 0 0 0-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 0 0 9.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3l-362.7 362.6l-88.9 15.7l15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z" />
</svg>
}
{type === 'check'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<g fill="none"><path d="M8.818 19.779l-6.364-6.364l2.83-2.83l3.534 3.544l9.898-9.908l2.83 2.83L8.818 19.779z" fill={color} /></g>
</svg>
}
{type === 'error'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 36 36">
<path fill={color} d="M18 6a12 12 0 1 0 12 12A12 12 0 0 0 18 6zm-1.49 6a1.49 1.49 0 0 1 3 0v6.89a1.49 1.49 0 1 1-3 0zM18 25.5a1.72 1.72 0 1 1 1.72-1.72A1.72 1.72 0 0 1 18 25.5z" />
</svg>
}
{type === 'question'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<g fill="none"><path d="M12 22c-5.52-.006-9.994-4.48-10-10v-.2C2.11 6.305 6.635 1.928 12.13 2c5.497.074 9.904 4.569 9.868 10.065C21.962 17.562 17.497 22 12 22zm-.016-2H12a8 8 0 1 0-.016 0zM13 18h-2v-2h2v2zm0-3h-2a3.583 3.583 0 0 1 1.77-3.178C13.43 11.316 14 10.88 14 10a2 2 0 1 0-4 0H8v-.09a4 4 0 1 1 8 .09a3.413 3.413 0 0 1-1.56 2.645A3.1 3.1 0 0 0 13 15z" fill={color} /></g>
</svg>
}
{type === 'caret-left'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 256 256">
<path fill={color} d="M160 216a7.975 7.975 0 0 1-5.657-2.343l-80-80a8 8 0 0 1 0-11.314l80-80a8 8 0 0 1 11.314 11.314L91.314 128l74.343 74.343A8 8 0 0 1 160 216z" />
</svg>
}
{type === 'caret-right'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 256 256">
<path fill={color} d="M96 216a8 8 0 0 1-5.657-13.657L164.686 128L90.343 53.657a8 8 0 0 1 11.314-11.314l80 80a8 8 0 0 1 0 11.314l-80 80A7.975 7.975 0 0 1 96 216z" />
</svg>
}
{type === 'caret-down'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 256 256">
<path fill={color} d="M128 188a11.962 11.962 0 0 1-8.485-3.515l-80-80a12 12 0 0 1 16.97-16.97L128 159.029l71.515-71.514a12 12 0 0 1 16.97 16.97l-80 80A11.962 11.962 0 0 1 128 188z" />
</svg>
}
{type === 'caret-up'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 256 256">
<path fill={color} d="M208 172a11.962 11.962 0 0 1-8.485-3.515L128 96.971l-71.515 71.514a12 12 0 0 1-16.97-16.97l80-80a12 12 0 0 1 16.97 0l80 80A12 12 0 0 1 208 172z" />
</svg>
}
{type === 'search'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 1024 1024">
<path fill={color} d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1c-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0 0 11.6 0l43.6-43.5a8.2 8.2 0 0 0 0-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z"/>
</svg>
}
{type === 'settings'
&& <svg width={size} viewBox="0 0 16 16" {...xmlnsProps}>
<path fill={color} fillRule="evenodd" clipRule="evenodd" d="M3.5 2h-1v5h1V2zm6.1 5H6.4L6 6.45v-1L6.4 5h3.2l.4.5v1l-.4.5zm-5 3H1.4L1 9.5v-1l.4-.5h3.2l.4.5v1l-.4.5zm3.9-8h-1v2h1V2zm-1 6h1v6h-1V8zm-4 3h-1v3h1v-3zm7.9 0h3.19l.4-.5v-.95l-.4-.5H11.4l-.4.5v.95l.4.5zm2.1-9h-1v6h1V2zm-1 10h1v2h-1v-2z" />
</svg>
}
{type === 'settings-alt'
&& <svg width={size} viewBox="0 0 32 32" {...xmlnsProps}>
<path d="M27 16.76V16v-.77l1.92-1.68A2 2 0 0 0 29.3 11l-2.36-4a2 2 0 0 0-1.73-1a2 2 0 0 0-.64.1l-2.43.82a11.35 11.35 0 0 0-1.31-.75l-.51-2.52a2 2 0 0 0-2-1.61h-4.68a2 2 0 0 0-2 1.61l-.51 2.52a11.48 11.48 0 0 0-1.32.75l-2.38-.86A2 2 0 0 0 6.79 6a2 2 0 0 0-1.73 1L2.7 11a2 2 0 0 0 .41 2.51L5 15.24v1.53l-1.89 1.68A2 2 0 0 0 2.7 21l2.36 4a2 2 0 0 0 1.73 1a2 2 0 0 0 .64-.1l2.43-.82a11.35 11.35 0 0 0 1.31.75l.51 2.52a2 2 0 0 0 2 1.61h4.72a2 2 0 0 0 2-1.61l.51-2.52a11.48 11.48 0 0 0 1.32-.75l2.42.82a2 2 0 0 0 .64.1a2 2 0 0 0 1.73-1l2.28-4a2 2 0 0 0-.41-2.51zM25.21 24l-3.43-1.16a8.86 8.86 0 0 1-2.71 1.57L18.36 28h-4.72l-.71-3.55a9.36 9.36 0 0 1-2.7-1.57L6.79 24l-2.36-4l2.72-2.4a8.9 8.9 0 0 1 0-3.13L4.43 12l2.36-4l3.43 1.16a8.86 8.86 0 0 1 2.71-1.57L13.64 4h4.72l.71 3.55a9.36 9.36 0 0 1 2.7 1.57L25.21 8l2.36 4l-2.72 2.4a8.9 8.9 0 0 1 0 3.13L27.57 20z" fill={color} />
<path d="M16 22a6 6 0 1 1 6-6a5.94 5.94 0 0 1-6 6zm0-10a3.91 3.91 0 0 0-4 4a3.91 3.91 0 0 0 4 4a3.91 3.91 0 0 0 4-4a3.91 3.91 0 0 0-4-4z" fill={color} />
</svg>
}
{type === 'logout'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<path d="M3 5c0-1.1.9-2 2-2h8v2H5v14h8v2H5c-1.1 0-2-.9-2-2V5zm14.176 6L14.64 8.464l1.414-1.414l4.95 4.95l-4.95 4.95l-1.414-1.414L17.176 13H10.59v-2h6.586z" fill={color}fillRule="evenodd"/>
</svg>
}
{type === 'reload'
&& <svg width={size} viewBox="0 0 344 480" {...xmlnsProps}>
<path d="M171 69q70 0 120 50t50 121q0 49-26 91l-31-31q15-28 15-60q0-53-37.5-90.5T171 112v64L85 91l86-86v64zm0 299v-64l85 85l-85 86v-64q-71 0-121-50T0 240q0-49 26-91l32 31q-15 28-15 60q0 53 37.5 90.5T171 368z" fill={color}/>
</svg>
}
{type === 'dots'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<path d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 1 1-2 0a1 1 0 0 1 2 0zm7 0a1 1 0 1 1-2 0a1 1 0 0 1 2 0zm7 0a1 1 0 1 1-2 0a1 1 0 0 1 2 0z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
}
{type === 'hamburger'
&& <svg width={size} viewBox="0 0 15 15" {...xmlnsProps}>
<g fill="none">
<path fillRule="evenodd" clipRule="evenodd" d="M1.5 3a.5.5 0 0 0 0 1h12a.5.5 0 0 0 0-1h-12zM1 7.5a.5.5 0 0 1 .5-.5h12a.5.5 0 0 1 0 1h-12a.5.5 0 0 1-.5-.5zm0 4a.5.5 0 0 1 .5-.5h12a.5.5 0 0 1 0 1h-12a.5.5 0 0 1-.5-.5z" fill={color} />
</g>
</svg>
}
{type === 'star'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<path fill={color} d="M10 1L7 7l-6 .75l4.13 4.62L4 19l6-3l6 3l-1.12-6.63L19 7.75L13 7zm0 2.24l2.34 4.69l4.65.58l-3.18 3.56l.87 5.15L10 14.88l-4.68 2.34l.87-5.15l-3.18-3.56l4.65-.58z"/>
</svg>
}
{type === 'star-filled'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<path fill={color} d="M10 1l3 6l6 .75l-4.12 4.62L16 19l-6-3l-6 3l1.13-6.63L1 7.75L7 7z"/>
</svg>
}
{type === 'link'
&& <svg width={size} viewBox="0 0 20 20" {...xmlnsProps}>
<path d="M11 3a1 1 0 1 0 0 2h2.586l-6.293 6.293a1 1 0 1 0 1.414 1.414L15 6.414V9a1 1 0 1 0 2 0V4a1 1 0 0 0-1-1h-5z" fill={color} />
<path d="M5 5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3a1 1 0 1 0-2 0v3H5V7h3a1 1 0 0 0 0-2H5z" fill={color} />
</svg>
}
{type === 'link-alt'
&& <svg width={size} viewBox="0 0 20 20" {...xmlnsProps}>
<g fill="none">
<path d="M14.828 12l1.415 1.414l2.828-2.828a4 4 0 0 0-5.657-5.657l-2.828 2.828L12 9.172l2.828-2.829a2 2 0 1 1 2.829 2.829L14.828 12z" fill={color} />
<path d="M12 14.829l1.414 1.414l-2.828 2.828a4 4 0 0 1-5.657-5.657l2.828-2.828L9.172 12l-2.829 2.829a2 2 0 1 0 2.829 2.828L12 14.828z" fill={color} />
<path d="M14.829 10.586a1 1 0 0 0-1.415-1.415l-4.242 4.243a1 1 0 1 0 1.414 1.414l4.242-4.242z" fill={color} />
</g>
</svg>
}
{type === 'clock'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<g fill="none"><path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0a9 9 0 0 1 18 0z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></g>
</svg>
}
{type === 'sort'
&& <svg width={size} viewBox="0 0 48 48" {...xmlnsProps}>
<g fill="none" stroke={color} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round"><path d="M25 14l-9-9l-9 9"/><path d="M15.992 31V5"/><path d="M42 34l-9 9l-9-9"/><path d="M32.992 17v26"/></g>
</svg>
}
{type === 'desktop'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<path d="M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H9c-.55 0-1 .45-1 1s.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1h-1v-2h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 14H4c-.55 0-1-.45-1-1V5c0-.55.45-1 1-1h16c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1z" fill="currentColor"/>
</svg>
}
{type === 'mobile'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<g fill="none">
<path d="M12 18h.01M8 21h8a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</g>
</svg>
}
{type === 'tags'
&& <svg width={size} viewBox="0 0 1920 1536" {...xmlnsProps}>
<path d="M448 320q0-53-37.5-90.5T320 192t-90.5 37.5T192 320t37.5 90.5T320 448t90.5-37.5T448 320zm1067 576q0 53-37 90l-491 492q-39 37-91 37q-53 0-90-37L91 762q-38-37-64.5-101T0 544V128q0-52 38-90t90-38h416q53 0 117 26.5T763 91l715 714q37 39 37 91zm384 0q0 53-37 90l-491 492q-39 37-91 37q-36 0-59-14t-53-45l470-470q37-37 37-90q0-52-37-91L923 91q-38-38-102-64.5T704 0h224q53 0 117 26.5T1147 91l715 714q37 39 37 91z" fill={color} />
</svg>
}
{type === 'filter'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M5.5 5h13a1 1 0 0 1 .5 1.5L14 12v7l-4-3v-4L5 6.5A1 1 0 0 1 5.5 5" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
}
</span>
);
};
export default Icon;

View File

@@ -0,0 +1,47 @@
import React, { useEffect } from 'react';
import Icon from './Icon';
type ModalProps = {
children: React.ReactNode,
width?: string,
title?: string,
closeModal: Function,
}
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]);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (e.target === e.currentTarget) { closeModal(); }
};
return (
<div className='modal fixed top-0 left-0 bg-white/[.7] w-full h-screen z-50' onClick={closeOnBGClick}>
<div
className={` max-w-[340px] absolute top-1/4 left-0 right-0 ml-auto mr-auto w-${width}
lg:max-w-md bg-white shadow-md rounded-md p-5 border-t-[1px] border-gray-100 text-base`}>
{title && <h3 className=' font-semibold mb-3'>{title}</h3>}
<button
className='modal-close absolute right-2 top-2 p-2 cursor-pointer text-gray-400 transition-all
hover:text-gray-700 hover:rotate-90' onClick={() => closeModal()}>
<Icon type="close" size={18} />
</button>
<div>{children}</div>
</div>
</div>
);
};
export default Modal;

View File

@@ -0,0 +1,101 @@
import React, { useMemo, useState } from 'react';
import Icon from './Icon';
export type SelectionOption = {
label:string,
value: string
}
type SelectFieldProps = {
defaultLabel: string,
options: SelectionOption[],
selected: string[],
multiple?: boolean,
updateField: Function,
minWidth?: number,
maxHeight?: number|string,
rounded?: string,
flags?: boolean,
emptyMsg?: string
}
const SelectField = (props: SelectFieldProps) => {
const {
options,
selected,
defaultLabel = 'Select an Option',
multiple = true,
updateField,
minWidth = 180,
maxHeight = 96,
rounded = 'rounded-3xl',
flags = false,
emptyMsg = '' } = props;
const [showOptions, setShowOptions] = useState(false);
const selectedLabels = useMemo(() => {
return options.reduce((acc:string[], item:SelectionOption) :string[] => {
return selected.includes(item.value) ? [...acc, item.label] : [...acc];
}, []);
}, [selected, options]);
const selectItem = (option:SelectionOption) => {
let updatedSelect = [option.value];
if (multiple && Array.isArray(selected)) {
if (selected.includes(option.value)) {
updatedSelect = selected.filter((x) => x !== option.value);
} else {
updatedSelect = [...selected, option.value];
}
}
updateField(updatedSelect);
if (!multiple) { setShowOptions(false); }
};
return (
<div className="select font-semibold text-gray-500">
<div
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[180px] min-w-[${minWidth}px]
${showOptions ? 'border-indigo-200' : ''}`}
onClick={() => setShowOptions(!showOptions)}>
<span className={`w-[${minWidth - 30}px] inline-block truncate mr-2`}>
{selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel}
</span>
{multiple && selected.length > 2
&& <span className={`px-2 py-0 ${rounded} bg-[#eaecff] text-[0.7rem] font-bold`}>{(selected.length)}</span>}
<span className='ml-2'><Icon type={showOptions ? 'caret-up' : 'caret-down'} size={9} /></span>
</div>
{showOptions && (
<div
className={`select_list mt-1 border absolute min-w-[${minWidth}px]
${rounded === 'rounded-3xl' ? 'rounded-lg' : rounded} max-h-${maxHeight} bg-white z-50 overflow-y-auto styled-scrollbar`}>
<ul>
{options.map((opt) => {
const itemActive = selected.includes(opt.value);
return (
<li
key={opt.value}
className={`select-none cursor-pointer px-3 py-2 hover:bg-[#FCFCFF]
${itemActive ? ' bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''} `}
onClick={() => selectItem(opt)}
>
{multiple && (
<span
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border
${itemActive ? ' bg-indigo-600 border-indigo-600 text-white' : 'text-transparent'}`} >
<Icon type="check" size={10} />
</span>
)}
{flags && <span className={`fflag fflag-${opt.value} w-[15px] h-[10px] mr-1`} />}
{opt.label}
</li>
);
})}
</ul>
{emptyMsg && options.length === 0 && <p className='p-4'>{emptyMsg}</p>}
</div>
)}
</div>
);
};
export default SelectField;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Icon from './Icon';
type SidebarProps = {
domains: Domain[],
showAddModal: Function
}
const Sidebar = ({ domains, showAddModal } : SidebarProps) => {
const router = useRouter();
return (
<div className="sidebar pt-44 w-1/5 hidden lg:block" data-testid="sidebar">
<h3 className="py-7 text-base font-bold text-blue-700">
<span className=' relative top-[3px] mr-1'><Icon type="logo" size={24} color="#364AFF" /></span> SerpBear
</h3>
<div className="sidebar_menu max-h-96 overflow-auto styled-scrollbar">
<ul className=' font-medium text-sm'>
{domains.map((d) => <li
key={d.domain}
className={'my-2.5 leading-10'}>
<Link href={`/domain/${d.slug}`} passHref={true}>
<a className={`block cursor-pointer px-4 text-ellipsis max-w-[215px] overflow-hidden whitespace-nowrap rounded
rounded-r-none
${(`/domain/${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>
{d.domain}
{/* <span>0</span> */}
</a>
</Link>
</li>)
}
</ul>
</div>
<div className='sidebar_add border-t font-semibold text-sm text-center mt-6 w-[80%] ml-3 text-zinc-500'>
<button data-testid="add_domain" onClick={() => showAddModal(true)} className='p-4 hover:text-blue-600'>+ Add Domain</button>
</div>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,63 @@
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
import Icon from './Icon';
type TopbarProps = {
showSettings: Function
}
const TopBar = ({ showSettings }:TopbarProps) => {
const [showMobileMenu, setShowMobileMenu] = useState<boolean>(false);
const router = useRouter();
const logoutUser = async () => {
try {
const fetchOpts = { method: 'POST', headers: new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }) };
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/logout`, fetchOpts).then((result) => result.json());
console.log(res);
if (!res.success) {
toast(res.error, { icon: '⚠️' });
} else {
router.push('/login');
}
} catch (fetchError) {
toast('Could not login, Ther Server is not responsive.', { icon: '⚠️' });
}
};
return (
<div className="topbar flex w-full max-w-7xl mx-auto justify-between lg:justify-end bg-white lg:bg-transparent">
<h3 className="p-4 text-base font-bold text-blue-700 lg:hidden">
<span className=' relative top-[3px] mr-1'><Icon type="logo" size={24} color="#364AFF" /></span> SerpBear
</h3>
<div className="topbar__right">
<button className={' lg:hidden p-3'} onClick={() => setShowMobileMenu(!showMobileMenu)}>
<Icon type="hamburger" size={24} />
</button>
<ul
className={`text-sm font-semibold text-gray-500 absolute mt-[-10px] right-3 bg-white
border border-gray-200 lg:mt-2 lg:relative lg:block lg:border-0 lg:bg-transparent ${showMobileMenu ? 'block' : 'hidden'}`}>
<li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' href='https://serpbear.com/documentation' target="_blank" rel='noreferrer'>
<Icon type="question" color={'#888'} size={14} /> Help
</a>
</li>
<li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' onClick={() => showSettings()}>
<Icon type="settings-alt" color={'#888'} size={14} /> Settings
</a>
</li>
<li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' onClick={() => logoutUser()}>
<Icon type="logout" color={'#888'} size={14} /> Logout
</a>
</li>
</ul>
</div>
</div>
);
};
export default TopBar;

View File

@@ -0,0 +1,56 @@
type ChartData = {
labels: string[],
sreies: number[]
}
export const generateChartData = (history: KeywordHistory): ChartData => {
const currentDate = new Date();
const priorDates = [];
const seriesDates: any = {};
let lastFoundSerp = 0;
// First Generate Labels. The labels should be the last 30 days dates. Format: Oct 26
for (let index = 30; index >= 0; index -= 1) {
const pastDate = new Date(new Date().setDate(currentDate.getDate() - index));
priorDates.push(`${pastDate.getDate()}/${pastDate.getMonth() + 1}`);
// Then Generate Series. if past date's serp does not exist, use 0.
// If have a missing serp in between dates, use the previous date's serp to fill the gap.
const pastDateKey = `${pastDate.getFullYear()}-${pastDate.getMonth() + 1}-${pastDate.getDate()}`;
const serpOftheDate = history[pastDateKey];
const lastLargestSerp = lastFoundSerp > 0 ? lastFoundSerp : 0;
seriesDates[pastDateKey] = history[pastDateKey] ? history[pastDateKey] : lastLargestSerp;
if (lastFoundSerp < serpOftheDate) { lastFoundSerp = serpOftheDate; }
}
return { labels: priorDates, sreies: Object.values(seriesDates) };
};
export const generateTheChartData = (history: KeywordHistory, time:string = '30'): ChartData => {
const currentDate = new Date(); let lastFoundSerp = 0;
const chartData: ChartData = { labels: [], sreies: [] };
if (time === 'all') {
Object.keys(history).forEach((dateKey) => {
const serpVal = history[dateKey] ? history[dateKey] : 111;
chartData.labels.push(dateKey);
chartData.sreies.push(serpVal);
});
} else {
// First Generate Labels. The labels should be the last 30 days dates. Format: Oct 26
for (let index = parseInt(time, 10); index >= 0; index -= 1) {
const pastDate = new Date(new Date().setDate(currentDate.getDate() - index));
// Then Generate Series. if past date's serp does not exist, use 0.
// If have a missing serp in between dates, use the previous date's serp to fill the gap.
const pastDateKey = `${pastDate.getFullYear()}-${pastDate.getMonth() + 1}-${pastDate.getDate()}`;
const prevSerp = history[pastDateKey];
const serpVal = prevSerp || (lastFoundSerp > 0 ? lastFoundSerp : 111);
if (serpVal !== 0) { lastFoundSerp = prevSerp; }
chartData.labels.push(pastDateKey);
chartData.sreies.push(serpVal);
}
}
// console.log(chartData);
return chartData;
};

View File

@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import Modal from '../common/Modal';
import { useAddDomain } from '../../services/domains';
type AddDomainProps = {
closeModal: Function
}
const AddDomain = ({ closeModal }: AddDomainProps) => {
const [newDomain, setNewDomain] = useState<string>('');
const [newDomainError, setNewDomainError] = useState<boolean>(false);
const { mutate: addMutate, isLoading: isAdding } = useAddDomain(() => closeModal());
const addDomain = () => {
// console.log('ADD NEW DOMAIN', newDomain);
if (/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/.test(newDomain)) {
setNewDomainError(false);
// TODO: Domain Action
addMutate(newDomain);
} else {
setNewDomainError(true);
}
};
const handleDomainInput = (e:React.FormEvent<HTMLInputElement>) => {
if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(false); }
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"
value={newDomain}
placeholder={'example.com'}
onChange={handleDomainInput}
autoFocus={true}
onKeyDown={(e) => {
if (e.code === 'Enter') {
e.preventDefault();
addDomain();
}
}}
/>
<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() }>
{isAdding ? 'Adding....' : 'Add Domain'}
</button>
</div>
</div>
</Modal>
);
};
export default AddDomain;

View File

@@ -0,0 +1,82 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useRefreshKeywords } from '../../services/keywords';
import Icon from '../common/Icon';
import SelectField from '../common/SelectField';
type DomainHeaderProps = {
domain: Domain,
domains: Domain[],
showAddModal: Function,
showSettingsModal: Function,
exportCsv:Function
}
const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, domains }: DomainHeaderProps) => {
const router = useRouter();
const [showOptions, setShowOptions] = useState<boolean>(false);
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
const buttonStyle = 'leading-6 inline-block px-2 py-2 text-gray-500 hover:text-gray-700';
return (
<div className='domain_kewywords_head flex w-full justify-between'>
<div>
<h1 className="hidden lg:block text-xl font-bold my-3" data-testid="domain-header">
{domain && domain.domain && <><i className=' capitalize font-bold not-italic'>{domain.domain.charAt(0)}</i>{domain.domain.slice(1)}</>}
</h1>
<div className='bg-white mt-2 lg:hidden'>
<SelectField
options={domains && domains.length > 0 ? domains.map((d) => { return { label: d.domain, value: d.slug }; }) : []}
selected={[domain.slug]}
defaultLabel="Select Domain"
updateField={(updateSlug:[string]) => updateSlug && updateSlug[0] && router.push(`${updateSlug[0]}`)}
multiple={false}
rounded={'rounded'}
/>
</div>
</div>
<div className='flex my-3'>
<button className={`${buttonStyle} lg:hidden`} onClick={() => setShowOptions(!showOptions)}>
<Icon type='dots' size={20} />
</button>
<div
className={`hidden w-40 ml-[-70px] lg:block absolute mt-10 bg-white border border-gray-100 z-40 rounded
lg:z-auto lg:relative lg:mt-0 lg:border-0 lg:w-auto lg:bg-transparent`}
style={{ display: showOptions ? 'block' : undefined }}>
<button
className={`${buttonStyle}`}
aria-pressed="false"
title='Export as CSV'
onClick={() => exportCsv()}>
<Icon type='download' size={20} /><i className='ml-2 text-sm not-italic lg:hidden'>Export as csv</i>
</button>
<button
className={`${buttonStyle} lg:ml-3`}
aria-pressed="false"
title='Refresh All Keyword Positions'
onClick={() => refreshMutate({ ids: [], domain: domain.domain })}>
<Icon type='reload' size={14} /><i className='ml-2 text-sm not-italic lg:hidden'>Reload All Serps</i>
</button>
<button
data-testid="show_domain_settings"
className={`${buttonStyle} lg:ml-3`}
aria-pressed="false"
title='Domain Settings'
onClick={() => showSettingsModal(true)}><Icon type='settings' size={20} />
<i className='ml-2 text-sm not-italic lg:hidden'>Domain Settings</i></button>
</div>
<button
data-testid="add_keyword"
className={'ml-2 inline-block px-4 py-2 text-blue-700 font-bold text-sm'}
onClick={() => showAddModal(true)}>
<span
className='text-center leading-4 mr-2 inline-block rounded-full w-7 h-7 pt-1 bg-blue-700 text-white font-bold text-lg'>+</span>
<i className=' not-italic hidden lg:inline-block'>Add Keyword</i>
</button>
</div>
</div>
);
};
export default DomainHeader;

View File

@@ -0,0 +1,118 @@
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
import { useDeleteDomain, useUpdateDomain } from '../../services/domains';
type DomainSettingsProps = {
domain:Domain,
domains: Domain[],
closeModal: Function
}
type DomainSettingsError = {
type: string,
msg: string,
}
const DomainSettings = ({ domain, domains, 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 { mutate: updateMutate } = useUpdateDomain(() => closeModal(false));
const { mutate: deleteMutate } = useDeleteDomain(() => {
closeModal(false);
const fitleredDomains = domains.filter((d:Domain) => d.domain !== domain.domain);
if (fitleredDomains[0] && fitleredDomains[0].slug) {
router.push(`/domain/${fitleredDomains[0].slug}`);
}
});
useEffect(() => {
setDomainSettings({ notification_interval: domain.notification_interval, notification_emails: domain.notification_emails });
}, [domain.notification_interval, domain.notification_emails]);
const updateNotiEmails = (event:React.FormEvent<HTMLInputElement>) => {
setDomainSettings({ ...domainSettings, notification_emails: event.currentTarget.value });
};
const updateDomain = () => {
console.log('Domain: ');
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);
console.log('invalidEmails: ', invalidEmails);
if (invalidEmails) {
error = { type: 'email', msg: 'Invalid Email' };
}
}
if (error && error.type) {
console.log('Error!!!!!');
setSettingsError(error);
setTimeout(() => {
setSettingsError({ type: '', msg: '' });
}, 3000);
} else {
updateMutate({ domainSettings, domain });
}
};
return (
<div>
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]">
<div data-testid="domain_settings" className=" text-sm">
<div className="mb-6 flex justify-between items-center">
<h4>Notification Emails
{settingsError.type === 'email' && <span className="text-red-500 font-semibold ml-2">{settingsError.msg}</span>}
</h4>
<input
className={`border w-46 text-sm transition-all rounded p-1.5 px-4 outline-none ring-0
${settingsError.type === 'email' ? ' border-red-300' : ''}`}
type="text"
placeholder='Your Emails'
onChange={updateNotiEmails}
value={domainSettings.notification_emails || ''}
/>
</div>
</div>
<div className="flex justify-between border-t-[1px] border-gray-100 mt-8 pt-4 pb-0">
<button
className="text-sm font-semibold text-red-500"
onClick={() => setShowRemoveDomain(true)}>
<Icon type="trash" /> Remove Domain
</button>
<button
className='text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white'
onClick={() => updateDomain()}>
Update Settings
</button>
</div>
</Modal>
{showRemoveDomain && (
<Modal closeModal={() => setShowRemoveDomain(false) } title={`Remove Domain ${domain.domain}`}>
<div className='text-sm'>
<p>Are you sure you want to remove this Domain? Removing this domain will remove all its keywords.</p>
<div className='mt-6 text-right font-semibold'>
<button
className=' py-1 px-5 rounded cursor-pointer bg-indigo-50 text-slate-500 mr-3'
onClick={() => setShowRemoveDomain(false)}>
Cancel
</button>
<button
className=' py-1 px-5 rounded cursor-pointer bg-red-400 text-white'
onClick={() => deleteMutate(domain)}>
Remove
</button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default DomainSettings;

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
import SelectField from '../common/SelectField';
import countries from '../../utils/countries';
import { useAddKeywords } from '../../services/keywords';
type AddKeywordsProps = {
keywords: KeywordType[],
closeModal: Function,
domain: string
}
type KeywordsInput = {
keywords: string,
device: string,
country: string,
domain: string,
tags: string,
}
const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
const [error, setError] = useState<string>('');
const [newKeywordsData, setNewKeywordsData] = useState<KeywordsInput>({ keywords: '', device: 'desktop', country: 'US', domain, tags: '' });
const { mutate: addMutate, isLoading: isAdding } = useAddKeywords(() => closeModal(false));
const deviceTabStyle = 'cursor-pointer px-3 py-2 rounded mr-2';
const addKeywords = () => {
if (newKeywordsData.keywords) {
const keywordsArray = newKeywordsData.keywords.replaceAll('\n', ',').split(',').map((item:string) => item.trim());
const currentKeywords = keywords.map((k) => `${k.keyword}-${k.device}-${k.country}`);
const keywordExist = keywordsArray.filter((k) => currentKeywords.includes(`${k}-${newKeywordsData.device}-${newKeywordsData.country}`));
if (keywordExist.length > 0) {
setError(`Keywords ${keywordExist.join(',')} already Exist`);
setTimeout(() => { setError(''); }, 3000);
} else {
addMutate(newKeywordsData);
}
} else {
setError('Please Insert a Keyword');
setTimeout(() => { setError(''); }, 3000);
}
};
return (
<Modal closeModal={() => { closeModal(false); }} title={'Add New Keywords'} width="[420px]">
<div data-testid="addkeywords_modal">
<div>
<div>
<textarea
className='w-full h-40 border rounded border-gray-200 p-4 outline-none focus:border-indigo-300'
placeholder='Type or Paste Keywords here...'
value={newKeywordsData.keywords}
onChange={(e) => setNewKeywordsData({ ...newKeywordsData, keywords: e.target.value })}>
</textarea>
</div>
<div className=' my-3 flex justify-between text-sm'>
<div>
<SelectField
multiple={false}
selected={[newKeywordsData.country]}
options={Object.keys(countries).map((countryISO:string) => { return { label: countries[countryISO][0], value: countryISO }; })}
defaultLabel='All Countries'
updateField={(updated:string[]) => setNewKeywordsData({ ...newKeywordsData, country: updated[0] })}
rounded='rounded'
maxHeight={48}
flags={true}
/>
</div>
<ul className='flex text-xs font-semibold text-gray-500'>
<li
className={`${deviceTabStyle} ${newKeywordsData.device === 'desktop' ? ' bg-indigo-50 text-gray-700' : ''}`}
onClick={() => setNewKeywordsData({ ...newKeywordsData, device: 'desktop' })}
><Icon type='desktop' classes={'top-[3px]'} size={15} /> <i className='not-italic hidden lg:inline-block'>Desktop</i></li>
<li
className={`${deviceTabStyle} ${newKeywordsData.device === 'mobile' ? ' bg-indigo-50 text-gray-700' : ''}`}
onClick={() => setNewKeywordsData({ ...newKeywordsData, device: 'mobile' })}
><Icon type='mobile' /> <i className='not-italic hidden lg:inline-block'>Mobile</i></li>
</ul>
</div>
<div className='relative'>
{/* TODO: Insert Existing Tags as Suggestions */}
<input
className='w-full border rounded border-gray-200 py-2 px-4 pl-8 outline-none focus:border-indigo-300'
placeholder='Insert Tags'
value={newKeywordsData.tags}
onChange={(e) => setNewKeywordsData({ ...newKeywordsData, tags: e.target.value })}
/>
<span className='absolute text-gray-400 top-2 left-2'><Icon type="tags" size={16} /></span>
</div>
</div>
{error && <div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{error}</div>}
<div className='mt-6 text-right text-sm font-semibold flex justify-between'>
<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 && addKeywords()}>
{isAdding ? 'Adding....' : 'Add Keywords'}
</button>
</div>
</div>
</Modal>
);
};
export default AddKeywords;

View File

@@ -0,0 +1,150 @@
import React, { useState, useMemo } from 'react';
import TimeAgo from 'react-timeago';
import dayjs from 'dayjs';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import ChartSlim from '../common/ChartSlim';
import { generateTheChartData } from '../common/generateChartData';
type KeywordProps = {
keywordData: KeywordType,
selected: boolean,
refreshkeyword: Function,
favoriteKeyword: Function,
removeKeyword: Function,
selectKeyword: Function,
manageTags: Function,
showKeywordDetails: Function,
lastItem?:boolean
}
const Keyword = (props: KeywordProps) => {
const { keywordData, refreshkeyword, favoriteKeyword, removeKeyword, selectKeyword, selected, showKeywordDetails, manageTags, lastItem } = props;
const {
keyword, domain, ID, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = 'false',
} = keywordData;
const [showOptions, setShowOptions] = useState(false);
const [showPositionError, setPositionError] = useState(false);
const turncatedURL = useMemo(() => {
return url.replace(`https://${domain}`, '').replace(`https://www.${domain}`, '').replace(`http://${domain}`, '');
}, [url, domain]);
const chartData = useMemo(() => {
return generateTheChartData(history, '7');
}, [history]);
const positionChange = useMemo(() => {
let status = 0;
if (Object.keys(history).length >= 2) {
const historyArray = Object.keys(history).map((dateKey:string) => {
return { date: new Date(dateKey).getTime(), dateRaw: dateKey, position: history[dateKey] };
});
const historySorted = historyArray.sort((a, b) => a.date - b.date);
const previousPos = historySorted[historySorted.length - 2].position;
status = previousPos === 0 ? position : previousPos - position;
}
return status;
}, [history, position]);
const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
const renderPosition = () => {
if (position === 0) {
return <span title='Not in Top 100'>{'-'}</span>;
}
if (updating) {
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
}
return position;
};
return (
<div
key={keyword}
className={`keyword relative py-5 px-4 text-gray-600 border-b-[1px] border-gray-200 lg:py-4 lg:px-6 lg:border-0
lg:flex lg:justify-between lg:items-center ${selected ? ' bg-indigo-50 keyword--selected' : ''} ${lastItem ? 'border-b-0' : ''}`}>
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold cursor-pointer'>
<button
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border
${selected ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}`}
onClick={() => selectKeyword(ID)}
>
<Icon type="check" size={10} />
</button>
<a
className='py-2 hover:text-blue-600'
onClick={() => showKeywordDetails()}>
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />{keyword}
</a>
{sticky && <button className='ml-2 relative top-[2px]' title='Favorite'><Icon type="star-filled" size={16} color="#fbd346" /></button>}
{lastUpdateError !== 'false'
&& <button className='ml-2 relative top-[2px]' onClick={() => setPositionError(true)}>
<Icon type="error" size={18} color="#FF3672" />
</button>
}
</div>
<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-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
{renderPosition()}
{!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>
{chartData.labels.length > 0 && (
<div className='lg:flex-1 hidden lg:block'>
<ChartSlim labels={chartData.labels} sreies={chartData.sreies} />
</div>
)}
<div
className={`keyword_url inline-block mt-4 mr-5 ml-5 lg:flex-1 text-gray-400 lg:m-0 max-w-[70px]
overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-none lg:pr-5`}>
<span className='mr-3 lg:hidden'><Icon type="link-alt" size={14} color="#999" /></span>{turncatedURL || '-'}</div>
<div
className='inline-block mt-[4] top-[-5px] relative lg:flex-1 lg:m-0'>
<span className='mr-2 lg:hidden'><Icon type="clock" size={14} color="#999" /></span>
<TimeAgo title={dayjs(lastUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={lastUpdated} />
</div>
<div className='absolute right-7 mt-[-10px] lg:flex-1 lg:basis-5 lg:grow-0 lg:shrink-0 lg:relative lg:mt-0 lg:right-auto'>
<button
className={`keyword_dots rounded px-1 text-indigo-300 hover:bg-indigo-50 ${showOptions ? 'bg-indigo-50 text-indigo-600 ' : ''}`}
onClick={() => setShowOptions(!showOptions)}>
<Icon type="dots" size={20} />
</button>
{showOptions && (
<ul className='keyword_options customShadow absolute w-[180px] right-0 bg-white rounded border z-20'>
<li>
<a className={optionsButtonStyle} onClick={() => { refreshkeyword([ID]); setShowOptions(false); }}>
<span className=' bg-indigo-100 text-blue-700 px-1 rounded'><Icon type="reload" size={11} /></span> Refresh Keyword</a>
</li>
<li>
<a className={optionsButtonStyle}
onClick={() => { favoriteKeyword({ keywordID: ID, sticky: !sticky }); setShowOptions(false); }}>
<span className=' bg-yellow-300/30 text-yellow-500 px-1 rounded'>
<Icon type="star" size={14} />
</span> { sticky ? 'Unfavorite Keyword' : 'Favorite Keyword'}
</a>
</li>
<li><a className={optionsButtonStyle} onClick={() => { manageTags(); setShowOptions(false); }}>
<span className=' bg-green-100 text-green-500 px-1 rounded'><Icon type="tags" size={14} /></span> Add/Edit Tags</a>
</li>
<li><a className={optionsButtonStyle} onClick={() => { removeKeyword([ID]); setShowOptions(false); }}>
<span className=' bg-red-100 text-red-600 px-1 rounded'><Icon type="trash" size={14} /></span> Remove Keyword</a>
</li>
</ul>
)}
</div>
{lastUpdateError !== 'false' && showPositionError
&& <div className=' absolute mt-[-70px] p-2 bg-white z-30 border border-red-200 rounded w-[220px] left-4 shadow-sm text-xs'>
Error Updating Keyword position (Tried <TimeAgo
title={dayjs(parseInt(lastUpdateError, 10)).format('DD-MMM-YYYY, hh:mm:ss A')}
date={parseInt(lastUpdateError, 10)} />)
<i className='absolute top-0 right-0 ml-2 p-2 font-semibold not-italic cursor-pointer' onClick={() => setPositionError(false)}>
<Icon type="close" size={16} color="#999" />
</i>
</div>
}
</div>
);
};
export default Keyword;

View File

@@ -0,0 +1,156 @@
import React, { useEffect, 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';
type KeywordDetailsProps = {
keyword: KeywordType,
closeDetails: Function
}
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 dateOptions = [
{ label: 'Last 7 Days', value: '7' },
{ label: 'Last 30 Days', value: '30' },
{ label: 'Last 90 Days', value: '90' },
{ label: '1 Year', value: '360' },
{ label: 'All Time', value: 'all' },
];
useEffect(() => {
const fetchFullKeyword = async () => {
try {
const fetchURL = `${process.env.NEXT_PUBLIC_APP_URL}/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]);
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(() => {
if (keyword.position < 100 && keyword.position > 0 && searchResultFound?.current) {
searchResultFound.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'start',
});
}
}, [keywordSearchResult, keyword.position]);
const chartData = useMemo(() => {
return generateTheChartData(keywordHistory, chartTime);
}, [keywordHistory, chartTime]);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (e.target === e.currentTarget) { closeDetails(); }
};
return (
<div className="keywordDetails fixed w-full h-screen top-0 left-0 z-30" onClick={closeOnBGClick} data-testid="keywordDetails">
<div className="keywordDetails absolute w-full lg:w-5/12 bg-white customShadow top-0 right-0 h-screen" >
<div className='keywordDetails__header p-6 border-b border-b-slate-200 text-slate-500'>
<h3 className=' text-lg font-bold'>
<span title={countries[keyword.country][0]}
className={`fflag fflag-${keyword.country} w-[18px] h-[12px] mr-2`} /> {keyword.keyword}
<span className='py-1 px-2 rounded bg-blue-50 text-blue-700 text-xs font-bold'>{keyword.position}</span>
</h3>
<button
className='absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
onClick={() => closeDetails()}>
<Icon type='close' size={24} />
</button>
</div>
<div className='keywordDetails__content p-6'>
<div className='keywordDetails__section'>
<div className="keywordDetails__section__head flex justify-between mb-5">
<h3 className=' font-bold text-gray-700 text-lg'>SERP History</h3>
<div className="keywordDetails__section__chart_select mr-3">
<SelectField
options={dateOptions}
selected={[chartTime]}
defaultLabel="Select Date"
updateField={(updatedTime:[string]) => setChartTime(updatedTime[0])}
multiple={false}
rounded={'rounded'}
/>
</div>
</div>
<div className='keywordDetails__section__chart h-64'>
<Chart labels={chartData.labels} sreies={chartData.sreies} />
</div>
</div>
<div className='keywordDetails__section mt-10'>
<div className="keywordDetails__section__head flex justify-between items-center pb-4 mb-4 border-b border-b-slate-200">
<h3 className=' font-bold text-gray-700 lg:text-lg'>Google Search Result
<a className='text-gray-400 hover:text-indigo-600 inline-block ml-1 px-2 py-1'
href={`https://www.google.com/search?q=${encodeURI(keyword.keyword)}`}
target="_blank"
rel='noreferrer'>
<Icon type='link' size={14} />
</a>
</h3>
<span className=' text-xs text-gray-500'>{dayjs(updatedDate).format('MMMM D, YYYY')}</span>
</div>
<div className='keywordDetails__section__results styled-scrollbar overflow-y-auto' ref={searchResultContainer}>
{keywordSearchResult && Array.isArray(keywordSearchResult) && keywordSearchResult.length > 0 && (
keywordSearchResult.map((item, index) => {
const { position } = keyword;
const domainExist = position < 100 && index === (position - 1);
return (
<div
ref={domainExist ? searchResultFound : null}
className={`leading-6 mb-4 mr-3 p-3 text-sm break-all pr-3 rounded
${domainExist ? ' bg-amber-50 border border-amber-200' : ''}`}
key={item.url + item.position}>
<h4 className='font-semibold text-blue-700'>
<a href={item.url} target="_blank" rel='noreferrer'>{`${index + 1}. ${item.title}`}</a>
</h4>
{/* <p>{item.description}</p> */}
<a className=' text-green-900' href={item.url} target="_blank" rel='noreferrer'>{item.url}</a>
</div>
);
})
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default KeywordDetails;

View File

@@ -0,0 +1,170 @@
import React, { useState, useEffect, useMemo } from 'react';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import countries from '../../utils/countries';
type KeywordFilterProps = {
device: string,
allTags: string[],
setDevice: Function,
filterParams: KeywordFilters,
filterKeywords: Function,
keywords: KeywordType[],
updateSort: Function,
sortBy: string
}
type KeywordCountState = {
desktop: number,
mobile: number
}
const KeywordFilters = (props: KeywordFilterProps) => {
const {
device,
setDevice,
filterKeywords,
allTags = [],
keywords,
updateSort,
sortBy,
filterParams } = 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);
}, [keywords]);
const filterCountry = (cntrs:string[]) => filterKeywords({ ...filterParams, countries: cntrs });
const filterTags = (tags:string[]) => filterKeywords({ ...filterParams, tags });
const searchKeywords = (event:React.FormEvent<HTMLInputElement>) => {
const filtered = filterKeywords({ ...filterParams, search: event.currentTarget.value });
return filtered;
};
const countryOptions = useMemo(() => {
const optionObject = Object.keys(countries).map((countryISO:string) => ({
label: countries[countryISO][0],
value: countryISO,
}));
return optionObject;
}, []);
const sortOptionChoices: SelectionOption[] = [
{ value: 'pos_asc', label: 'Top Position' },
{ value: 'pos_desc', label: 'Lowest Position' },
{ value: 'date_asc', label: 'Most Recent (Default)' },
{ value: 'date_desc', label: 'Oldest' },
{ value: 'alpha_asc', label: 'Alphabetically(A-Z)' },
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
];
const sortItemStyle = (sortType:string) => {
return `cursor-pointer py-2 px-3 hover:bg-[#FCFCFF] ${sortBy === sortType ? 'bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''}`;
};
const deviceTabStyle = 'select-none cursor-pointer px-3 py-2 rounded-3xl mr-2';
const deviceTabCountStyle = 'px-2 py-0 rounded-3xl bg-[#DEE1FC] text-[0.7rem] font-bold ml-1';
const mobileFilterOptionsStyle = 'visible mt-8 border absolute min-w-[0] rounded-lg max-h-96 bg-white z-50 w-52 right-2 p-4';
return (
<div className='domKeywords_filters py-4 px-6 flex justify-between text-sm text-gray-500 font-semibold border-b-[1px] lg:border-0'>
<div>
<ul className='flex text-xs'>
<li
data-testid="desktop_tab"
className={`${deviceTabStyle} ${device === 'desktop' ? ' bg-[#F8F9FF] text-gray-700' : ''}`}
onClick={() => setDevice('desktop')}>
<Icon type='desktop' classes='top-[3px]' size={15} />
<i className='hidden not-italic lg:inline-block ml-1'>Desktop</i>
<span className={`${deviceTabCountStyle}`}>{keywordCounts.desktop}</span>
</li>
<li
data-testid="mobile_tab"
className={`${deviceTabStyle} ${device === 'mobile' ? ' bg-[#F8F9FF] text-gray-700' : ''}`}
onClick={() => setDevice('mobile')}>
<Icon type='mobile' />
<i className='hidden not-italic lg:inline-block ml-1'>Mobile</i>
<span className={`${deviceTabCountStyle}`}>{keywordCounts.mobile}</span>
</li>
</ul>
</div>
<div className='flex gap-5'>
<div className=' lg:hidden'>
<button
data-testid="filter_button"
className={`px-2 py-1 rounded ${filterOptions ? ' bg-indigo-100 text-blue-700' : ''}`}
title='Filter'
onClick={() => showFilterOptions(!filterOptions)}>
<Icon type="filter" size={18} />
</button>
</div>
<div className={`lg:flex gap-5 lg:visible ${filterOptions ? mobileFilterOptionsStyle : 'hidden'}`}>
<div className={'country_filter mb-2 lg:mb-0'}>
<SelectField
selected={filterParams.countries}
options={countryOptions}
defaultLabel='All Countries'
updateField={(updated:string[]) => filterCountry(updated)}
flags={true}
/>
</div>
<div className={'tags_filter mb-2 lg:mb-0'}>
<SelectField
selected={filterParams.tags}
options={allTags.map((tag:string) => ({ label: tag, value: tag }))}
defaultLabel='All Tags'
updateField={(updated:string[]) => filterTags(updated)}
emptyMsg="No Tags Found for this Domain"
/>
</div>
<div className={'mb-2 lg:mb-0'}>
<input
data-testid="filter_input"
className={'border w-44 lg:w-36 focus:w-44 transition-all rounded-3xl p-1.5 px-4 outline-none ring-0 focus:border-indigo-200'}
type="text"
placeholder='Filter Keywords...'
onChange={searchKeywords}
value={filterParams.search}
/>
</div>
</div>
<div className='relative'>
<button
data-testid="sort_button"
className={`px-2 py-1 rounded ${sortOptions ? ' bg-indigo-100 text-blue-700' : ''}`}
title='Sort'
onClick={() => showSortOptions(!sortOptions)}>
<Icon type="sort" size={18} />
</button>
{sortOptions && (
<ul
data-testid="sort_options"
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg max-h-96 bg-white z-50 w-44'>
{sortOptionChoices.map((sortOption) => {
return <li
key={sortOption.value}
className={sortItemStyle(sortOption.value)}
onClick={() => { updateSort(sortOption.value); showSortOptions(false); }}>
{sortOption.label}
</li>;
})}
</ul>
)}
</div>
</div>
</div>
);
};
export default KeywordFilters;

View File

@@ -0,0 +1,84 @@
import { useState } from 'react';
import { useUpdateKeywordTags } from '../../services/keywords';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
type keywordTagManagerProps = {
keyword: KeywordType|undefined,
closeModal: Function,
allTags: string[]
}
const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
const [tagInput, setTagInput] = useState('');
const [inputError, setInputError] = useState('');
const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); });
const removeTag = (tag:String) => {
if (!keyword) { return; }
const newTags = keyword.tags.filter((t) => t !== tag.trim());
updateMutate({ tags: { [keyword.ID]: newTags } });
};
const addTag = () => {
if (!keyword) { return; }
if (!tagInput) {
setInputError('Please Insert a Tag!');
setTimeout(() => { setInputError(''); }, 3000);
return;
}
if (keyword.tags.includes(tagInput)) {
setInputError('Tag Exist!');
setTimeout(() => { setInputError(''); }, 3000);
return;
}
console.log('New Tag: ', tagInput);
const newTags = [...keyword.tags, tagInput.trim()];
updateMutate({ tags: { [keyword.ID]: newTags } });
};
return (
<Modal closeModal={() => { closeModal(false); }} title={`Tags for Keyword "${keyword && keyword.keyword}"`}>
<div className="text-sm my-8 ">
{keyword && keyword.tags.length > 0 && (
<ul>
{keyword.tags.map((tag:string) => {
return <li key={tag} className={'inline-block bg-slate-50 py-1 px-3 border rounded mr-4 mb-3 text-slate-500'}>
<Icon type="tags" size={14} classes="mr-2" />{tag}
<button
className="ml-1 cursor-pointer rounded px-1 hover:bg-red-200 hover:text-red-700"
onClick={() => removeTag(tag)}>
<Icon type="close" size={14} />
</button>
</li>;
})}
</ul>
)}
{keyword && keyword.tags.length === 0 && (
<div className="text-center w-full text-gray-500">No Tags Added to this Keyword.</div>
)}
</div>
<div className="relative">
{inputError && <span className="absolute top-[-24px] text-red-400 text-sm font-semibold">{inputError}</span>}
<span className='absolute text-gray-400 top-3 left-2'><Icon type="tags" size={16} /></span>
<input
className='w-full border rounded border-gray-200 py-3 px-4 pl-8 outline-none focus:border-indigo-300'
placeholder='Insert Tags'
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.code === 'Enter') {
e.preventDefault();
addTag();
}
}}
/>
<button className=" absolute right-2 top-2 cursor-pointer rounded p-1 px-4 bg-blue-600 text-white font-bold" onClick={addTag}>+</button>
</div>
</Modal>
);
};
export default KeywordTagManager;

View File

@@ -0,0 +1,178 @@
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import AddKeywords from './AddKeywords';
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/sortFilter';
import Icon from '../common/Icon';
import Keyword from './Keyword';
import KeywordDetails from './KeywordDetails';
import KeywordFilters from './KeywordFilter';
import Modal from '../common/Modal';
import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords';
import KeywordTagManager from './KeywordTagManager';
type KeywordsTableProps = {
domain: Domain | null,
keywords: KeywordType[],
isLoading: boolean,
showAddModal: boolean,
setShowAddModal: Function
}
const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal = false, setShowAddModal }: KeywordsTableProps) => {
const [device, setDevice] = useState<string>('desktop');
const [selectedKeywords, setSelectedKeywords] = useState<number[]>([]);
const [showKeyDetails, setShowKeyDetails] = useState<KeywordType|null>(null);
const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
const [showTagManager, setShowTagManager] = useState<null|number>(null);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('date_asc');
const { mutate: deleteMutate } = useDeleteKeywords(() => {});
const { mutate: favoriteMutate } = useFavKeywords(() => {});
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
const processedKeywords: {[key:string] : KeywordType[]} = useMemo(() => {
const procKeywords = keywords.filter((x) => x.device === device);
const filteredKeywords = filterKeywords(procKeywords, filterParams);
const sortedKeywords = sortKeywords(filteredKeywords, sortBy);
return keywordsByDevice(sortedKeywords, device);
}, [keywords, device, sortBy, filterParams]);
const allDomainTags: string[] = useMemo(() => {
const allTags = keywords.reduce((acc: string[], keyword) => [...acc, ...keyword.tags], []);
return [...new Set(allTags)];
}, [keywords]);
const selectKeyword = (keywordID: number) => {
console.log('Select Keyword: ', keywordID);
let updatedSelectd = [...selectedKeywords, keywordID];
if (selectedKeywords.includes(keywordID)) {
updatedSelectd = selectedKeywords.filter((keyID) => keyID !== keywordID);
}
setSelectedKeywords(updatedSelectd);
};
const selectedAllItems = selectedKeywords.length === processedKeywords[device].length;
return (
<div>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border'>
{selectedKeywords.length > 0 && (
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
<ul className=''>
<li className='inline-block mr-4'>
<a
className='block px-2 py-2 cursor-pointer hover:text-indigo-600'
onClick={() => { refreshMutate({ ids: selectedKeywords }); setSelectedKeywords([]); }}
>
<span className=' bg-indigo-100 text-blue-700 px-1 rounded'><Icon type="reload" size={11} /></span> Refresh Keyword
</a>
</li>
<li className='inline-block mr-4'>
<a
className='block px-2 py-2 cursor-pointer hover:text-indigo-600'
onClick={() => setShowRemoveModal(true)}
>
<span className=' bg-red-100 text-red-600 px-1 rounded'><Icon type="trash" size={14} /></span> Remove Keyword</a>
</li>
</ul>
</div>
)}
{selectedKeywords.length === 0 && (
<KeywordFilters
allTags={allDomainTags}
filterParams={filterParams}
filterKeywords={(params:KeywordFilters) => setFilterParams(params)}
updateSort={(sorted:string) => setSortBy(sorted)}
sortBy={sortBy}
keywords={keywords}
device={device}
setDevice={setDevice}
/>
)}
<div className='styled-scrollbar w-full overflow-auto min-h-[60vh] '>
<div className=' lg:min-w-[800px]'>
<div className={`domKeywords_head domKeywords_head--${sortBy} hidden lg:flex p-3 px-6 bg-[#FCFCFF]
text-gray-600 justify-between items-center font-semibold border-y`}>
<span className='domKeywords_head_keyword flex-1 basis-20 w-auto '>
{processedKeywords[device].length > 0 && (
<button
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border border-slate-300
${selectedAllItems ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}`}
onClick={() => setSelectedKeywords(selectedAllItems ? [] : processedKeywords[device].map((k: KeywordType) => k.ID))}
>
<Icon type="check" size={10} />
</button>
)}
Keyword
</span>
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>Position</span>
<span className='domKeywords_head_history flex-1'>History (7d)</span>
<span className='domKeywords_head_url flex-1'>URL</span>
<span className='domKeywords_head_updated flex-1'>Updated</span>
</div>
<div className='domKeywords_keywords border-gray-200'>
{processedKeywords[device] && processedKeywords[device].length > 0
&& processedKeywords[device].map((keyword, index) => <Keyword
key={keyword.ID}
selected={selectedKeywords.includes(keyword.ID)}
selectKeyword={selectKeyword}
keywordData={keyword}
refreshkeyword={() => refreshMutate({ ids: [keyword.ID] })}
favoriteKeyword={favoriteMutate}
manageTags={() => setShowTagManager(keyword.ID)}
removeKeyword={() => { setSelectedKeywords([keyword.ID]); setShowRemoveModal(true); }}
showKeywordDetails={() => setShowKeyDetails(keyword)}
lastItem={index === (processedKeywords[device].length - 1)}
/>)}
{!isLoading && processedKeywords[device].length === 0 && (
<p className=' p-9 mt-[10%] text-center text-gray-500'>No Keywords Added for this Device Type.</p>
)}
{isLoading && (
<p className=' p-9 mt-[10%] text-center text-gray-500'>Loading Keywords...</p>
)}
</div>
</div>
</div>
</div>
{showKeyDetails && showKeyDetails.ID && (
<KeywordDetails keyword={showKeyDetails} closeDetails={() => setShowKeyDetails(null)} />
)}
{showRemoveModal && selectedKeywords.length > 0 && (
<Modal closeModal={() => { setSelectedKeywords([]); setShowRemoveModal(false); }} title={'Remove Keywords'}>
<div className='text-sm'>
<p>Are you sure you want to remove {selectedKeywords.length > 1 ? 'these' : 'this'} Keyword?</p>
<div className='mt-6 text-right font-semibold'>
<button
className=' py-1 px-5 rounded cursor-pointer bg-indigo-50 text-slate-500 mr-3'
onClick={() => { setSelectedKeywords([]); setShowRemoveModal(false); }}>
Cancel
</button>
<button
className=' py-1 px-5 rounded cursor-pointer bg-red-400 text-white'
onClick={() => { deleteMutate(selectedKeywords); setShowRemoveModal(false); setSelectedKeywords([]); }}>
Remove
</button>
</div>
</div>
</Modal>
)}
{showAddModal && domain && (
<AddKeywords
domain={domain.domain}
keywords={keywords}
closeModal={() => setShowAddModal(false)}
/>
)}
{showTagManager && (
<KeywordTagManager
allTags={allDomainTags}
keyword={keywords.find((k) => k.ID === showTagManager)}
closeModal={() => setShowTagManager(null)}
/>
)}
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};
export default KeywordsTable;

View File

@@ -0,0 +1,286 @@
import React, { useEffect, useState } from 'react';
// import { useQuery } from 'react-query';
import useUpdateSettings, { useFetchSettings } from '../../services/settings';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
type SettingsProps = {
closeSettings: Function,
settings?: SettingsType
}
type SettingsError = {
type: string,
msg: string
}
const defaultSettings = {
scraper_type: 'none',
notification_interval: 'daily',
notification_email: '',
smtp_server: '',
smtp_port: '',
smtp_username: '',
smtp_password: '',
notification_email_from: '',
};
const Settings = ({ closeSettings }:SettingsProps) => {
const [currentTab, setCurrentTab] = useState<string>('scraper');
const [settings, setSettings] = useState<SettingsType>(defaultSettings);
const [settingsError, setSettingsError] = useState<SettingsError|null>(null);
const { mutate: updateMutate, isLoading: isUpdating } = useUpdateSettings(() => console.log(''));
const { data: appSettings, isLoading } = useFetchSettings();
useEffect(() => {
if (appSettings && appSettings.settings) {
setSettings(appSettings.settings);
}
}, [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();
if (e.target === e.currentTarget) { closeSettings(); }
};
const updateSettings = (key: string, value:string|number) => {
setSettings({ ...settings, [key]: value });
};
const performUpdate = () => {
let error: null|SettingsError = null;
if (settings.notification_interval !== 'never') {
if (!settings.notification_email) {
error = { type: 'no_email', msg: 'Insert a Valid Email address' };
}
if (settings.notification_email
&& (!settings.smtp_username || !settings.smtp_password || !settings.smtp_port || !settings.smtp_server
|| !settings.notification_email_from)) {
let type = 'no_smtp_from';
if (!settings.smtp_password) { type = 'no_smtp_pass'; }
if (!settings.smtp_username) { type = 'no_smtp_user'; }
if (!settings.smtp_port) { type = 'no_smtp_port'; }
if (!settings.smtp_server) { type = 'no_smtp_server'; }
error = { type, msg: 'Insert SMTP Server details that will be used to send the emails.' };
}
}
if (['scrapingant', 'scrapingrobot'].includes(settings.scraper_type) && !settings.scaping_api) {
error = { type: 'no_api_key', msg: 'Insert a Valid API Key or Token for the Scraper Service.' };
}
if (error) {
setSettingsError(error);
setTimeout(() => { setSettingsError(null); }, 3000);
} else {
// Perform Update
updateMutate(settings);
}
};
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
const notificationOptions: SelectionOption[] = [
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Never', value: 'never' },
];
const scraperOptions: SelectionOption[] = [
{ label: 'None', value: 'none' },
{ label: 'Proxy', value: 'proxy' },
{ label: 'ScrapingAnt.com', value: 'scrapingant' },
{ label: 'ScrapingRobot.com', value: 'scrapingrobot' },
];
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm';
return (
<div className="settings fixed w-full h-screen top-0 left-0 z-50" onClick={closeOnBGClick}>
<div className="absolute w-full max-w-xs bg-white customShadow top-0 right-0 h-screen" data-loading={isLoading} >
{isLoading && <div className='absolute flex content-center items-center h-full'><Icon type="loading" size={24} /></div>}
<div className='settings__header p-6 border-b border-b-slate-200 text-slate-500'>
<h3 className=' text-black text-lg font-bold'>Settings</h3>
<button
className=' absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
onClick={() => closeSettings()}>
<Icon type='close' size={24} />
</button>
</div>
<div className=' px-4 mt-4 '>
<ul>
<li
className={`${tabStyle} ${currentTab === 'scraper' ? ' bg-blue-50 text-blue-600' : ''}`}
onClick={() => setCurrentTab('scraper')}>
Scraper
</li>
<li
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-blue-50 text-blue-600' : ''}`}
onClick={() => setCurrentTab('notification')}>
Notification
</li>
</ul>
</div>
{currentTab === 'scraper' && (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__select mb-5">
<label className={labelStyle}>Scraping Method</label>
<SelectField
options={scraperOptions}
selected={[settings.scraper_type || 'none']}
defaultLabel="Select Scraper"
updateField={(updatedTime:[string]) => updateSettings('scraper_type', updatedTime[0])}
multiple={false}
rounded={'rounded'}
minWidth={270}
/>
</div>
{['scrapingant', 'scrapingrobot'].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 && 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>
)}
{settings.scraper_type === 'proxy' && (
<div className="settings__section__input mb-5">
<label className={labelStyle}>Proxy List</label>
<textarea
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
focus:outline-none min-h-[160px] focus:border-blue-200
${settingsError && settingsError.type === 'no_email' ? ' border-red-400 focus:border-red-400' : ''} `}
value={settings?.proxy}
placeholder={'http://122.123.22.45:5049\nhttps://user:password@122.123.22.45:5049'}
onChange={(event) => updateSettings('proxy', event.target.value)}
/>
</div>
)}
</div>
</div>
)}
{currentTab === 'notification' && (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Notification Frequency</label>
<SelectField
multiple={false}
selected={[settings.notification_interval]}
options={notificationOptions}
defaultLabel={'Notification Settings'}
updateField={(updated:string[]) => updated[0] && updateSettings('notification_interval', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={270}
/>
</div>
{settings.notification_interval !== 'never' && (
<>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Notification Emails</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_email' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.notification_email}
placeholder={'test@gmail.com'}
onChange={(event) => updateSettings('notification_email', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Server</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_server' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_server || ''}
onChange={(event) => updateSettings('smtp_server', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Port</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_port' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_port || ''}
onChange={(event) => updateSettings('smtp_port', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Username</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_user' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_username || ''}
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
${settingsError && settingsError.type === 'no_smtp_pass' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_password || ''}
onChange={(event) => updateSettings('smtp_password', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>From Email Address</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_from' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.notification_email_from || ''}
placeholder="no-reply@mydomain.com"
onChange={(event) => updateSettings('notification_email_from', event.target.value)}
/>
</div>
</>
)}
</div>
{settingsError && (
<div className='absolute w-full bottom-16 text-center p-3 bg-red-100 text-red-600 text-sm font-semibold'>
{settingsError.msg}
</div>
)}
</div>
)}
<div className=' border-t-[1px] border-gray-200 p-2 px-3'>
<button
onClick={() => performUpdate()}
className=' py-3 px-5 w-full rounded cursor-pointer bg-blue-700 text-white font-semibold text-sm'>
{isUpdating && <Icon type="loading" size={14} />} Update Settings
</button>
</div>
</div>
</div>
);
};
export default Settings;

124
cron.js Normal file
View File

@@ -0,0 +1,124 @@
const Cryptr = require('cryptr');
const { promises } = require('fs');
const { readFile } = require('fs');
const cron = require('node-cron');
require('dotenv').config({ path: './.env.local' });
const getAppSettings = async () => {
const defaultSettings = {
scraper_type: 'none',
notification_interval: 'never',
notification_email: '',
smtp_server: '',
smtp_port: '',
smtp_username: '',
smtp_password: '',
};
// console.log('process.env.SECRET: ', process.env.SECRET);
try {
let decryptedSettings = {};
const exists = await promises.stat(`${process.cwd()}/data/settings.json`).then(() => true).catch(() => false);
if (exists) {
const settingsRaw = await promises.readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
try {
const cryptr = new Cryptr(process.env.SECRET);
const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : '';
const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : '';
decryptedSettings = { ...settings, scaping_api, smtp_password };
} catch (error) {
console.log('Error Decrypting Settings API Keys!');
}
} else {
throw Error('Settings file dont exist.');
}
return decryptedSettings;
} catch (error) {
console.log(error);
await promises.writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(defaultSettings), { encoding: 'utf-8' });
return defaultSettings;
}
};
const generateCronTime = (interval) => {
let cronTime = false;
if (interval === 'hourly') {
cronTime = '0 0 */1 * * *';
}
if (interval === 'daily') {
cronTime = '0 0 0 * * *';
}
if (interval === 'weekly') {
cronTime = '0 0 0 */7 * *';
}
if (interval === 'monthly') {
cronTime = '0 0 1 * *'; // Run every first day of the month at 00:00(midnight)
}
return cronTime;
};
const runAppCronJobs = () => {
// RUN SERP Scraping CRON (EveryDay at Midnight) 0 0 0 * *
const scrapeCronTime = generateCronTime('daily');
cron.schedule(scrapeCronTime, () => {
// console.log('### Running Keyword Position Cron Job!');
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/cron`, fetchOpts)
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => {
console.log('ERROR Making Cron Request..');
console.log(err);
});
}, { scheduled: true });
// Run Failed scraping CRON (Every Hour)
const failedCronTime = generateCronTime('hourly');
cron.schedule(failedCronTime, () => {
// console.log('### Retrying Failed Scrapes...');
readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => {
if (data) {
const keywordsToRetry = data ? JSON.parse(data) : [];
if (keywordsToRetry.length > 0) {
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/refresh?id=${keywordsToRetry.join(',')}`, fetchOpts)
.then((res) => res.json())
.then((refreshedData) => console.log(refreshedData))
.catch((fetchErr) => {
console.log('ERROR Making Cron Request..');
console.log(fetchErr);
});
}
} else {
console.log('ERROR Reading Failed Scrapes Queue File..', err);
}
});
}, { scheduled: true });
// RUN Email Notification CRON
getAppSettings().then((settings) => {
const notif_interval = (!settings.notification_interval || settings.notification_interval === 'never') ? false : settings.notification_interval;
if (notif_interval) {
const cronTime = generateCronTime(notif_interval);
if (cronTime) {
cron.schedule(cronTime, () => {
// console.log('### Sending Notification Email...');
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/notify`, fetchOpts)
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => {
console.log('ERROR Making Cron Request..');
console.log(err);
});
}, { scheduled: true });
}
}
});
};
runAppCronJobs();

23
database/database.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Sequelize } from 'sequelize-typescript';
import sqlite3 from 'sqlite3';
import Domain from './models/domain';
import Keyword from './models/keyword';
const connection = new Sequelize({
dialect: 'sqlite',
host: '0.0.0.0',
username: process.env.USER,
password: process.env.PASSWORD,
database: 'sequelize',
dialectModule: sqlite3,
pool: {
max: 5,
min: 0,
idle: 10000,
},
logging: false,
models: [Domain, Keyword],
storage: './data/database.sqlite',
});
export default connection;

43
database/models/domain.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Table, Model, Column, DataType, PrimaryKey, Unique } from 'sequelize-typescript';
@Table({
timestamps: false,
tableName: 'domain',
})
class Domain extends Model {
@PrimaryKey
@Column({ type: DataType.INTEGER, allowNull: false, primaryKey: true, autoIncrement: true })
ID!: number;
@Unique
@Column({ type: DataType.STRING, allowNull: false, defaultValue: true, unique: true })
domain!: string;
@Unique
@Column({ type: DataType.STRING, allowNull: false, defaultValue: true, unique: true })
slug!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0 })
keywordCount!: number;
@Column({ type: DataType.STRING, allowNull: true })
lastUpdated!: string;
@Column({ type: DataType.STRING, allowNull: true })
added!: string;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
tags!: string;
@Column({ type: DataType.BOOLEAN, allowNull: true, defaultValue: true })
notification!: boolean;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'daily' })
notification_interval!: string;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: '' })
notification_emails!: string;
}
export default Domain;

View File

@@ -0,0 +1,63 @@
import { Table, Model, Column, DataType, PrimaryKey } from 'sequelize-typescript';
@Table({
timestamps: false,
tableName: 'keyword',
})
class Keyword extends Model {
@PrimaryKey
@Column({ type: DataType.INTEGER, allowNull: false, primaryKey: true, autoIncrement: true })
ID!: number;
@Column({ type: DataType.STRING, allowNull: false })
keyword!: string;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'desktop' })
device!: string;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'US' })
country!: string;
@Column({ type: DataType.STRING, allowNull: false })
domain!: string;
// @ForeignKey(() => Domain)
// @Column({ allowNull: false })
// domainID!: number;
// @BelongsTo(() => Domain)
// domain!: Domain;
@Column({ type: DataType.STRING, allowNull: true })
lastUpdated!: string;
@Column({ type: DataType.STRING, allowNull: true })
added!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0 })
position!: number;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
history!: string;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
url!: string;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
tags!: string;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
lastResult!: string;
@Column({ type: DataType.BOOLEAN, allowNull: true, defaultValue: true })
sticky!: boolean;
@Column({ type: DataType.BOOLEAN, allowNull: true, defaultValue: false })
updating!: boolean;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'false' })
lastUpdateError!: string;
}
export default Keyword;

25
docker-compose.yaml Normal file
View File

@@ -0,0 +1,25 @@
version: "3.8"
services:
app:
build: .
container_name: serpbear_app
# restart: always
ports:
- 3000:3000
environment:
- SESSION_DURATION=48
- USER=admin
- PASSWORD=0123456789
- SECRET=4715aed3216f7b0a38e6b534a958362654e96d10fbc04700770d572af3dce43625dd
- APIKEY=5saedXklbslhnapihe2pihp3pih4fdnakhjwq5
- NEXT_PUBLIC_APP_URL=http://localhost:3000
volumes:
# - ./:/app
# - /node_modules
- appdata:/app/data
networks:
my-network:
driver: bridge
volumes:
appdata:

460
email/email.html Normal file
View File

@@ -0,0 +1,460 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Simple Transactional Email</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
/*All the styling goes here*/
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
background-color: #f8f9ff;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
width: 100%; }
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #f8f9ff;
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 10px;
width: 580px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 580px;
padding: 10px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #ffffff;
border-radius: 3px;
width: 100%;
border: 1px solid #e7e9f5;
}
.wrapper {
box-sizing: border-box;
padding: 20px;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
font-size: 12px;
text-align: center;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #000000;
font-family: sans-serif;
font-weight: 400;
line-height: 1.4;
margin: 0;
margin-bottom: 30px;
}
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize;
}
p,
ul,
ol {
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
}
a {
color: #3498db;
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%; }
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
color: #3498db;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #3498db;
}
.btn-primary a {
background-color: #3498db;
border-color: #3498db;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
hr {
border: 0;
border-bottom: 1px solid #f8f9ff;
margin: 20px 0;
}
.topbar{
margin: 15px 10px;
}
.logo{
font-weight: bold;
color: #1d4ed8;
}
.logo img{
max-width: 24px;
vertical-align: bottom;
}
.mainhead {
background: #3f51b5;
color: #fff;
border-radius: 4px 4px 0 0;
}
.mainhead td {
padding: 15px;
}
.mainhead a{
color: white;
text-decoration: none;
}
.keyword_table td {
padding: 10px 0;
}
.keyword_table th{
font-weight: normal;
color: #888;
padding-bottom: 10px;
}
.keyword td:nth-child(1){
font-weight: bold;
}
.keyword_table th:nth-child(2), .keyword_table th:nth-child(3), .keyword td:nth-child(2), .keyword td:nth-child(3){
text-align: center;
}
.keyword td:nth-child(3){
font-size: 12px;
color: #888;
}
.keyword svg {
width: 15px;
}
.keyword .pos_change{
font-size: 12px;
}
.keyword .pos_change span{
padding-left: 5px;
}
.keyword .flag {
max-width: 20px;
margin-right: 2px;
}
.device {
width: 18px;
margin-right: 2px;
position: relative;
vertical-align: middle;
opacity: 0.6;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table.body h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table.body .wrapper,
table.body .article {
padding: 10px !important;
}
table.body .content {
padding: 0 !important;
}
table.body .container {
padding: 0 !important;
width: 100% !important;
}
table.body .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table.body .btn table {
width: 100% !important;
}
table.body .btn a {
width: 100% !important;
}
table.body .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
.stat{
display: none;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body>
<span class="preheader">{{preheader}}</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="topbar">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="logo">{{logo}} SerpBear</td>
<td align="right" style="vertical-align: bottom ;" >{{currentDate}}</td>
</tr>
</table>
</div>
<div class="content">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="mainhead">
<tr>
<td style="font-weight:bold;">{{domainName}}<span style="display:inline-block; margin-left:4px; font-size: 12px; padding: 1px 7px; border-radius:4px; background: #5768c7;">{{keywordsCount}}</span></h3></td>
<td class="stat" align="right">{{stat}}</td>
</tr>
</table>
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="keyword_table">
<tbody>
<tr align="left">
<th>Keyword</th>
<th>Position</th>
<th>Updated</th>
</tr>
{{keywordsTable}}
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block powered-by">
Powered by <a href="https://serpbear.com">SerpBear</a> | <a href="{{appURL}}">Visit Dashboard</a>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>

21
jest.config.js Normal file
View File

@@ -0,0 +1,21 @@
const nextJest = require('next/jest');
require('dotenv').config({ path: './.env.local' });
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const customJestConfig = {
// Add more setup options before each test is run
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom',
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

11
jest.setup.js Normal file
View File

@@ -0,0 +1,11 @@
// eslint-disable-next-line no-unused-vars
import 'isomorphic-fetch';
import './styles/globals.css';
// 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';
global.ResizeObserver = require('resize-observer-polyfill');

View File

@@ -1,7 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
}
swcMinify: false,
output: 'standalone',
};
module.exports = nextConfig
module.exports = nextConfig;

12597
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,19 +6,64 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"cron": "node cron.js",
"start:all": "concurrently npm:start npm:cron",
"lint": "next lint",
"test": "jest --watch --verbose",
"test:ci": "jest --ci",
"test:cv": "jest --coverage --coverageDirectory='coverage'"
},
"dependencies": {
"@testing-library/react": "^13.4.0",
"axios": "^1.1.3",
"axios-retry": "^3.3.1",
"chart.js": "^3.9.1",
"cheerio": "^1.0.0-rc.12",
"concurrently": "^7.6.0",
"cookies": "^0.8.0",
"cryptr": "^6.0.3",
"dayjs": "^1.11.5",
"dotenv": "^16.0.3",
"https-proxy-agent": "^5.0.1",
"isomorphic-fetch": "^3.0.0",
"jsonwebtoken": "^8.5.1",
"msw": "^0.49.0",
"next": "12.3.1",
"node-cron": "^3.0.2",
"nodemailer": "^6.8.0",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-chartjs-2": "^4.3.1",
"react-dom": "18.2.0",
"react-hot-toast": "^2.4.0",
"react-query": "^3.39.2",
"react-timeago": "^7.1.0",
"reflect-metadata": "^0.1.13",
"sequelize": "^6.25.2",
"sequelize-typescript": "^2.1.5",
"sqlite3": "^5.1.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@types/cookies": "^0.7.7",
"@types/cryptr": "^4.0.1",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "18.11.0",
"@types/nodemailer": "^6.4.6",
"@types/react": "18.0.21",
"@types/react-dom": "18.0.6",
"@types/react-timeago": "^4.1.3",
"autoprefixer": "^10.4.12",
"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",
"prettier": "^2.7.1",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.55.0",
"tailwindcss": "^3.1.8",
"typescript": "4.8.4"
}
}

View File

@@ -1,8 +1,15 @@
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import '../styles/globals.css';
import React from 'react';
import type { AppProps } from 'next/app';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
const [queryClient] = React.useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>
<Component {...pageProps} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>;
}
export default MyApp
export default MyApp;

22
pages/_document.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
class MyDocument extends Document {
// eslint-disable-next-line class-methods-use-this
render() {
return (
<Html>
<Head>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon.png"></link>
<meta name="theme-color" content="#fff" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;

41
pages/api/cron.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser';
import { refreshAndUpdateKeywords } from './refresh';
type CRONRefreshRes = {
started: boolean
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await db.sync();
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'POST') {
return cronRefreshkeywords(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const cronRefreshkeywords = async (req: NextApiRequest, res: NextApiResponse<CRONRefreshRes>) => {
try {
const settings = await getAppSettings();
if (!settings || (settings && settings.scraper_type === 'never')) {
return res.status(400).json({ started: false, error: 'Scraper has not been set up yet.' });
}
await Keyword.update({ updating: true }, { where: {} });
const keywordQueries: Keyword[] = await Keyword.findAll();
refreshAndUpdateKeywords(keywordQueries, settings);
return res.status(200).json({ started: true });
} catch (error) {
console.log('ERROR cronRefreshkeywords: ', error);
return res.status(400).json({ started: false, error: 'CRON Error refreshing keywords!' });
}
};

110
pages/api/domains.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import Domain from '../../database/models/domain';
import Keyword from '../../database/models/keyword';
import verifyUser from '../../utils/verifyUser';
type DomainsGetRes = {
domains: Domain[]
error?: string|null,
}
type DomainsAddResponse = {
domain: Domain|null,
error?: string|null,
}
type DomainsDeleteRes = {
domainRemoved: number,
keywordsRemoved: number,
error?: string|null,
}
type DomainsUpdateRes = {
domain: Domain|null,
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await db.sync();
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'GET') {
return getDomains(req, res);
}
if (req.method === 'POST') {
return addDomain(req, res);
}
if (req.method === 'DELETE') {
return deleteDomain(req, res);
}
if (req.method === 'PUT') {
return updateDomain(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
export const getDomains = async (req: NextApiRequest, res: NextApiResponse<DomainsGetRes>) => {
try {
const allDomains: Domain[] = await Domain.findAll();
return res.status(200).json({ domains: allDomains });
} catch (error) {
return res.status(400).json({ domains: [], error: 'Error Getting Domains.' });
}
};
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,
slug: domain.replaceAll('.', '-'),
lastUpdated: new Date().toString(),
added: new Date().toString(),
};
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.' });
}
};
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!' });
}
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,
});
} catch (error) {
console.log('##### Delete Domain Error: ', error);
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Error Deleting Domain' });
}
};
export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsUpdateRes>) => {
if (!req.query.domain) {
return res.status(400).json({ domain: null, error: 'Domain is Required!' });
}
const { domain } = req.query || {};
const { notification_interval, notification_emails } = req.body;
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } });
if (domainToUpdate) {
domainToUpdate.set({ notification_interval, notification_emails });
await domainToUpdate.save();
}
return res.status(200).json({ domain: domainToUpdate });
};

View File

@@ -1,13 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
type Data = {
name: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
res.status(200).json({ name: 'John Doe' })
}

37
pages/api/keyword.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import parseKeywords from '../../utils/parseKeywords';
import verifyUser from '../../utils/verifyUser';
type KeywordGetResponse = {
keyword?: KeywordType | null
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const authorized = verifyUser(req, res);
if (authorized === 'authorized' && req.method === 'GET') {
await db.sync();
return getKeyword(req, res);
}
return res.status(401).json({ error: authorized });
}
const getKeyword = async (req: NextApiRequest, res: NextApiResponse<KeywordGetResponse>) => {
if (!req.query.id && typeof req.query.id !== 'string') {
return res.status(400).json({ error: 'Keyword ID is Required!' });
}
console.log('KEYWORD: ', req.query.id);
try {
const query = { ID: parseInt((req.query.id as string), 10) };
const foundKeyword:Keyword| null = await Keyword.findOne({ where: query });
const pareseKeyword = foundKeyword && parseKeywords([foundKeyword.get({ plain: true })]);
const keywords = pareseKeyword && pareseKeyword[0] ? pareseKeyword[0] : null;
return res.status(200).json({ keyword: keywords });
} catch (error) {
console.log(error);
return res.status(400).json({ error: 'Error Loading Keyword' });
}
};

161
pages/api/keywords.ts Normal file
View File

@@ -0,0 +1,161 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Op } from 'sequelize';
import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import { refreshAndUpdateKeywords } from './refresh';
import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser';
import parseKeywords from '../../utils/parseKeywords';
type KeywordsGetResponse = {
keywords?: KeywordType[],
error?: string|null,
}
type KeywordsDeleteRes = {
domainRemoved?: number,
keywordsRemoved?: number,
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await db.sync();
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'GET') {
return getKeywords(req, res);
}
if (req.method === 'POST') {
return addKeywords(req, res);
}
if (req.method === 'DELETE') {
return deleteKeywords(req, res);
}
if (req.method === 'PUT') {
return updateKeywords(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const getKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGetResponse>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') {
return res.status(400).json({ error: 'Domain is Required!' });
}
const domain = (req.query.domain as string).replace('-', '.');
try {
const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain } });
const keywords: KeywordType[] = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
const slimKeywords = keywords.map((keyword) => {
const historyArray = Object.keys(keyword.history).map((dateKey:string) => ({
date: new Date(dateKey).getTime(),
dateRaw: dateKey,
position: keyword.history[dateKey],
}));
const historySorted = historyArray.sort((a, b) => a.date - b.date);
const lastWeekHistory :KeywordHistory = {};
historySorted.slice(-7).forEach((x:any) => { lastWeekHistory[x.dateRaw] = x.position; });
return { ...keyword, lastResult: [], history: lastWeekHistory };
});
console.log('getKeywords: ', keywords.length);
return res.status(200).json({ keywords: slimKeywords });
} catch (error) {
console.log(error);
return res.status(400).json({ error: 'Error Loading Keywords for this Domain.' });
}
};
const addKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGetResponse>) => {
const { keywords, device, country, domain, tags } = req.body;
if (keywords && device && country) {
const keywordsArray = keywords.replaceAll('\n', ',').split(',').map((item:string) => item.trim());
const tagsArray = tags ? tags.split(',').map((item:string) => item.trim()) : [];
const keywordsToAdd: any = []; // QuickFIX for bug: https://github.com/sequelize/sequelize-typescript/issues/936
keywordsArray.forEach((keyword: string) => {
const newKeyword = {
keyword,
device,
domain,
country,
position: 0,
updating: true,
history: JSON.stringify({}),
url: '',
tags: JSON.stringify(tagsArray),
sticky: false,
lastUpdated: new Date().toString(),
added: new Date().toString(),
};
keywordsToAdd.push(newKeyword);
});
try {
const newKeywords:Keyword[] = await Keyword.bulkCreate(keywordsToAdd);
const formattedkeywords = newKeywords.map((el) => el.get({ plain: true }));
const keywordsParsed: KeywordType[] = parseKeywords(formattedkeywords);
const settings = await getAppSettings();
refreshAndUpdateKeywords(newKeywords, settings); // Queue the SERP Scraping Process
return res.status(201).json({ keywords: keywordsParsed });
} catch (error) {
return res.status(400).json({ error: 'Could Not Add New Keyword!' });
}
} else {
return res.status(400).json({ error: 'Necessary Keyword Data Missing' });
}
};
const deleteKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsDeleteRes>) => {
if (!req.query.id && typeof req.query.id !== 'string') {
return res.status(400).json({ error: 'keyword ID is Required!' });
}
console.log('req.query.id: ', req.query.id);
try {
const keywordsToRemove = (req.query.id as string).split(',').map((item) => parseInt(item, 10));
const removeQuery = { where: { ID: { [Op.in]: keywordsToRemove } } };
const removedKeywordCount: number = await Keyword.destroy(removeQuery);
return res.status(200).json({ keywordsRemoved: removedKeywordCount });
} catch (error) {
return res.status(400).json({ error: 'Could Not Remove Keyword!' });
}
};
const updateKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGetResponse>) => {
if (!req.query.id && typeof req.query.id !== 'string') {
return res.status(400).json({ error: 'keyword ID is Required!' });
}
if (req.body.sticky === undefined && !req.body.tags === undefined) {
return res.status(400).json({ error: 'keyword Payload Missing!' });
}
const keywordIDs = (req.query.id as string).split(',').map((item) => parseInt(item, 10));
const { sticky, tags } = req.body;
try {
let keywords: KeywordType[] = [];
if (sticky !== undefined) {
await Keyword.update({ sticky }, { where: { ID: { [Op.in]: keywordIDs } } });
const updateQuery = { where: { ID: { [Op.in]: keywordIDs } } };
const updatedKeywords:Keyword[] = await Keyword.findAll(updateQuery);
const formattedKeywords = updatedKeywords.map((el) => el.get({ plain: true }));
keywords = parseKeywords(formattedKeywords);
return res.status(200).json({ keywords });
}
if (tags) {
const tagsKeywordIDs = Object.keys(tags);
for (const keywordID of tagsKeywordIDs) {
const response = await Keyword.findOne({ where: { ID: keywordID } });
if (response) {
await response.update({ tags: JSON.stringify(tags[keywordID]) });
}
}
return res.status(200).json({ keywords });
}
return res.status(400).json({ error: 'Invalid Payload!' });
} catch (error) {
console.log('ERROR updateKeywords: ', error);
return res.status(200).json({ error: 'Error Updating keywords!' });
}
};

36
pages/api/login.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';
import Cookies from 'cookies';
type loginResponse = {
success?: boolean
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
return loginUser(req, res);
}
return res.status(401).json({ success: false, error: 'Invalid Method' });
}
const loginUser = async (req: NextApiRequest, res: NextApiResponse<loginResponse>) => {
if (!req.body.username || !req.body.password) {
return res.status(401).json({ error: 'Username Password Missing' });
}
if (req.body.username === process.env.USER
&& req.body.password === process.env.PASSWORD && process.env.SECRET) {
const token = jwt.sign({ user: process.env.USER }, process.env.SECRET);
const cookies = new Cookies(req, res);
const expireDate = new Date();
const sessDuration = process.env.SESSION_DURATION;
expireDate.setHours((sessDuration && parseInt(sessDuration, 10)) || 24);
cookies.set('token', token, { httpOnly: true, sameSite: 'lax', maxAge: expireDate.getTime() });
return res.status(200).json({ success: true, error: null });
}
const error = req.body.username !== process.env.USER ? 'Incorrect Username' : 'Incorrect Password';
return res.status(401).json({ success: false, error });
};

25
pages/api/logout.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import Cookies from 'cookies';
import verifyUser from '../../utils/verifyUser';
type logoutResponse = {
success?: boolean
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'POST') {
return logout(req, res);
}
return res.status(401).json({ success: false, error: 'Invalid Method' });
}
const logout = async (req: NextApiRequest, res: NextApiResponse<logoutResponse>) => {
const cookies = new Cookies(req, res);
cookies.set('token', null, { maxAge: new Date().getTime() });
return res.status(200).json({ success: true, error: null });
};

71
pages/api/notify.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import nodeMailer from 'nodemailer';
import db from '../../database/database';
import Domain from '../../database/models/domain';
import Keyword from '../../database/models/keyword';
import generateEmail from '../../utils/generateEmail';
import parseKeywords from '../../utils/parseKeywords';
import { getAppSettings } from './settings';
type NotifyResponse = {
success?: boolean
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
await db.sync();
return notify(req, res);
}
return res.status(401).json({ success: false, error: 'Invalid Method' });
}
const notify = async (req: NextApiRequest, res: NextApiResponse<NotifyResponse>) => {
try {
const settings = await getAppSettings();
const {
smtp_server = '',
smtp_port = '',
smtp_username = '',
smtp_password = '',
notification_email = '',
notification_email_from = '',
} = settings;
if (!smtp_server || !smtp_port || !smtp_username || !smtp_password || !notification_email) {
return res.status(401).json({ success: false, error: 'SMTP has not been setup properly!' });
}
const fromEmail = `SerpBear <${notification_email_from || 'no-reply@serpbear.com'}>`;
const transporter = nodeMailer.createTransport({
host: smtp_server,
port: parseInt(smtp_port, 10),
auth: { user: smtp_username, pass: smtp_password },
});
const allDomains: Domain[] = await Domain.findAll();
if (allDomains && allDomains.length > 0) {
const domains = allDomains.map((el) => el.get({ plain: true }));
for (const domain of domains) {
if (domain.notification !== false) {
const query = { where: { domain: domain.domain } };
const domainKeywords:Keyword[] = await Keyword.findAll(query);
const keywordsArray = domainKeywords.map((el) => el.get({ plain: true }));
const keywords: KeywordType[] = parseKeywords(keywordsArray);
await transporter.sendMail({
from: fromEmail,
to: domain.notification_emails || notification_email,
subject: `[${domain.domain}] Keyword Positions Update`,
html: await generateEmail(domain.domain, keywords),
});
// console.log(JSON.stringify(result, null, 4));
}
}
}
return res.status(200).json({ success: true, error: null });
} catch (error) {
console.log(error);
return res.status(401).json({ success: false, error: 'Error Sending Notification Email.' });
}
};

116
pages/api/refresh.ts Normal file
View File

@@ -0,0 +1,116 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Op } from 'sequelize';
import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import refreshKeywords from '../../utils/refresh';
import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser';
import parseKeywords from '../../utils/parseKeywords';
import { removeFromRetryQueue, retryScrape } from '../../utils/scraper';
type KeywordsRefreshRes = {
keywords?: KeywordType[]
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await db.sync();
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'POST') {
return refresTheKeywords(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const refresTheKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsRefreshRes>) => {
if (!req.query.id && typeof req.query.id !== 'string') {
return res.status(400).json({ error: 'keyword ID is Required!' });
}
if (req.query.id === 'all' && !req.query.domain) {
return res.status(400).json({ error: 'When Refreshing all Keywords of a domian, the Domain name Must be provided.' });
}
const keywordIDs = req.query.id !== 'all' && (req.query.id as string).split(',').map((item) => parseInt(item, 10));
const { domain } = req.query || {};
console.log('keywordIDs: ', keywordIDs);
try {
const settings = await getAppSettings();
if (!settings || (settings && settings.scraper_type === 'never')) {
return res.status(400).json({ error: 'Scraper has not been set up yet.' });
}
const query = req.query.id === 'all' && domain ? { domain } : { ID: { [Op.in]: keywordIDs } };
await Keyword.update({ updating: true }, { where: query });
const keywordQueries: Keyword[] = await Keyword.findAll({ where: query });
let keywords = [];
// If Single Keyword wait for the scraping process,
// else, Process the task in background. Do not wait.
if (keywordIDs && keywordIDs.length === 0) {
const refreshed: KeywordType[] = await refreshAndUpdateKeywords(keywordQueries, settings);
keywords = refreshed;
} else {
refreshAndUpdateKeywords(keywordQueries, settings);
keywords = parseKeywords(keywordQueries.map((el) => el.get({ plain: true })));
}
return res.status(200).json({ keywords });
} catch (error) {
console.log('ERROR refresThehKeywords: ', error);
return res.status(400).json({ error: 'Error refreshing keywords!' });
}
};
export const refreshAndUpdateKeywords = async (initKeywords:Keyword[], settings:SettingsType) => {
const formattedKeywords = initKeywords.map((el) => el.get({ plain: true }));
const refreshed: any = await refreshKeywords(formattedKeywords, settings);
// const fetchKeywords = await refreshKeywords(initialKeywords.map( k=> k.keyword ));
const updatedKeywords: KeywordType[] = [];
for (const keywordRaw of initKeywords) {
const keywordPrased = parseKeywords([keywordRaw.get({ plain: true })]);
const keyword = keywordPrased[0];
const udpatedkeyword = refreshed.find((item:any) => item.ID && item.ID === keyword.ID);
if (udpatedkeyword && keyword) {
const newPos = udpatedkeyword.position;
const newPosition = newPos !== false ? newPos : keyword.position;
const { history } = keyword;
const currentDate = new Date();
history[`${currentDate.getFullYear()}-${currentDate.getMonth() + 1}-${currentDate.getDate()}`] = newPosition;
const updatedVal = {
position: newPosition,
updating: false,
url: udpatedkeyword.url,
lastResult: udpatedkeyword.result,
history,
lastUpdated: udpatedkeyword.error ? keyword.lastUpdated : new Date().toJSON(),
lastUpdateError: udpatedkeyword.error ? new Date().toJSON() : 'false',
};
updatedKeywords.push({ ...keyword, ...updatedVal });
// If failed, Add to Retry Queue Cron
if (udpatedkeyword.error) {
await retryScrape(keyword.ID);
} else {
await removeFromRetryQueue(keyword.ID);
}
// Update the Keyword Position in Database
try {
await keywordRaw.update({
...updatedVal,
lastResult: JSON.stringify(udpatedkeyword.result),
history: JSON.stringify(history),
});
console.log('[SUCCESS] Updating the Keyword: ', keyword.keyword);
} catch (error) {
console.log('[ERROR] Updating SERP for Keyword', keyword.keyword, error);
}
}
}
return updatedKeywords;
};

84
pages/api/settings.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr';
import { writeFile, readFile } from 'fs/promises';
import verifyUser from '../../utils/verifyUser';
type SettingsGetResponse = {
settings?: object | null,
error?: string,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'GET') {
return getSettings(req, res);
}
if (req.method === 'PUT') {
return updateSettings(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const getSettings = async (req: NextApiRequest, res: NextApiResponse<SettingsGetResponse>) => {
const settings = await getAppSettings();
if (settings) {
return res.status(200).json({ settings });
}
return res.status(400).json({ error: 'Error Loading Settings!' });
};
const updateSettings = async (req: NextApiRequest, res: NextApiResponse<SettingsGetResponse>) => {
const { settings } = req.body || {};
// console.log('### settings: ', settings);
if (!settings) {
return res.status(200).json({ error: 'Settings Data not Provided!' });
}
try {
const cryptr = new Cryptr(process.env.SECRET as string);
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api) : '';
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password) : '';
const securedSettings = { ...settings, scaping_api, smtp_password };
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(securedSettings), { encoding: 'utf-8' });
return res.status(200).json({ settings });
} catch (error) {
console.log('ERROR updateSettings: ', error);
return res.status(200).json({ error: 'Error Updating Settings!' });
}
};
export const getAppSettings = async () : Promise<SettingsType> => {
try {
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
let decryptedSettings = settings;
try {
const cryptr = new Cryptr(process.env.SECRET as string);
const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : '';
const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : '';
decryptedSettings = { ...settings, scaping_api, smtp_password };
} catch (error) {
console.log('Error Decrypting Settings API Keys!');
}
return decryptedSettings;
} catch (error) {
console.log(error);
const settings = {
scraper_type: 'none',
notification_interval: 'never',
notification_email: '',
notification_email_from: '',
smtp_server: '',
smtp_port: '',
smtp_username: '',
smtp_password: '',
};
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(settings), { encoding: 'utf-8' });
return settings;
}
};

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { NextPage } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
// import { useQuery } from 'react-query';
// import toast from 'react-hot-toast';
import Sidebar from '../../../components/common/Sidebar';
import TopBar from '../../../components/common/TopBar';
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 Settings from '../../../components/settings/Settings';
import { useFetchDomains } from '../../../services/domains';
import { useFetchKeywords } from '../../../services/keywords';
import { useFetchSettings } from '../../../services/settings';
const SingleDomain: NextPage = () => {
const router = useRouter();
const [noScrapprtError, setNoScrapprtError] = useState(false);
const [showAddKeywords, setShowAddKeywords] = useState(false);
const [showAddDomain, setShowAddDomain] = useState(false);
const [showDomainSettings, setShowDomainSettings] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [keywordSPollInterval, setKeywordSPollInterval] = useState<undefined|number>(undefined);
const { data: appSettings } = useFetchSettings();
const { data: domainsData } = useFetchDomains(router);
const { keywordsData, keywordsLoading } = useFetchKeywords(router, setKeywordSPollInterval, keywordSPollInterval);
const theDomains: Domain[] = (domainsData && domainsData.domains) || [];
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
const activDomain: Domain|null = useMemo(() => {
let active:Domain|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:Domain) => x.slug === router.query.slug);
}
return active;
}, [router.query.slug, domainsData]);
useEffect(() => {
console.log('appSettings.settings: ', appSettings && appSettings.settings);
if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) {
setNoScrapprtError(true);
}
}, [appSettings]);
// console.log('Domains Data:', router, activDomain, theKeywords);
return (
<div className="Domain ">
{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.
</div>
)}
{activDomain && activDomain.domain
&& <Head>
<title>{`${activDomain.domain} - SerpBear` } </title>
</Head>
}
<TopBar showSettings={() => setShowSettings(true)} />
<div className="flex w-full max-w-7xl mx-auto">
<Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} />
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-20 w-full">
{activDomain && activDomain.domain
&& <DomainHeader
domain={activDomain}
domains={theDomains}
showAddModal={setShowAddKeywords}
showSettingsModal={setShowDomainSettings}
exportCsv={() => exportCSV(theKeywords, activDomain.domain)}
/>}
<KeywordsTable
isLoading={keywordsLoading}
domain={activDomain}
keywords={theKeywords}
showAddModal={showAddKeywords}
setShowAddModal={setShowAddKeywords}
/>
</div>
</div>
{showAddDomain && <AddDomain closeModal={() => setShowAddDomain(false)} />}
{showDomainSettings && theDomains && activDomain && activDomain.domain
&& <DomainSettings
domain={activDomain}
domains={theDomains}
closeModal={setShowDomainSettings}
/>
}
{showSettings && <Settings closeSettings={() => setShowSettings(false)} />}
</div>
);
};
export default SingleDomain;

View File

@@ -1,72 +1,82 @@
import type { NextPage } from 'next'
import Head from 'next/head'
import Image from 'next/image'
import styles from '../styles/Home.module.css'
import type { NextPage } from 'next';
import { useEffect, useState } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
// import { useEffect, useState } from 'react';
import { Toaster } from 'react-hot-toast';
import Icon from '../components/common/Icon';
import AddDomain from '../components/domains/AddDomain';
// import verifyUser from '../utils/verifyUser';
const Home: NextPage = () => {
const [loading, setLoading] = useState<boolean>(false);
const [domains, setDomains] = useState<Domain[]>([]);
const router = useRouter();
useEffect(() => {
setLoading(true);
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/domains`)
.then((result) => {
return result.json();
})
.then((domainsRes:any) => {
if (domainsRes?.domains && domainsRes.domains.length > 0) {
const firstDomainItem = domainsRes.domains[0].slug;
setDomains(domainsRes.domains);
router.push(`/domain/${firstDomainItem}`);
}
setLoading(false);
return false;
})
.catch((err) => {
console.log(err);
setLoading(false);
});
}, [router]);
return (
<div className={styles.container}>
<div>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<title>SerpBear</title>
<meta name="description" content="SerpBear Google Keyword Position Tracking App" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className={styles.description}>
Get started by editing{' '}
<code className={styles.code}>pages/index.tsx</code>
</p>
<div className={styles.grid}>
<a href="https://nextjs.org/docs" className={styles.card}>
<h2>Documentation &rarr;</h2>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className={styles.card}>
<h2>Learn &rarr;</h2>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a
href="https://github.com/vercel/next.js/tree/canary/examples"
className={styles.card}
>
<h2>Examples &rarr;</h2>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className={styles.card}
>
<h2>Deploy &rarr;</h2>
<p>
Instantly deploy your Next.js site to a public URL with Vercel.
</p>
</a>
</div>
<main role={'main'} className='main flex items-center justify-center w-full h-screen'>
<Icon type='loading' size={36} color="#999" />
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<span className={styles.logo}>
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
</span>
</a>
</footer>
<Toaster position='bottom-center' containerClassName="react_toaster" />
{!loading && domains.length === 0 && <AddDomain closeModal={() => console.log('Cannot Close Modal!')} />}
</div>
)
}
);
};
export default Home
// export const getServerSideProps = async (context:NextPageContext) => {
// const { req, res } = context;
// const authorized = verifyUser(req as NextApiRequest, res as NextApiResponse);
// // console.log('####### authorized: ', authorized);
// if (authorized !== 'authorized') {
// return { redirect: { destination: '/login', permanent: false } };
// }
// let domains: Domain[] = [];
// try {
// const fetchOpts = { method: 'GET', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
// const domainsRes = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/domains`, fetchOpts).then((result) => result.json());
// // console.log(domainsRes);
// domains = domainsRes.domains;
// if (domains.length > 0) {
// const firstDomainItem = domains[0].slug;
// return { redirect: { destination: `/domain/${firstDomainItem}`, permanent: false } };
// }
// } catch (error) {
// console.log(error);
// }
// // console.log('domains: ', domains);
// return { props: { authorized, domains } };
// };
export default Home;

119
pages/login/index.tsx Normal file
View File

@@ -0,0 +1,119 @@
import type { NextPage } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState } from 'react';
import Icon from '../../components/common/Icon';
type LoginError = {
type: string,
msg: string,
}
const Login: NextPage = () => {
const [error, setError] = useState<LoginError|null>(null);
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
const router = useRouter();
const loginuser = async () => {
let loginError: LoginError |null = null;
if (!username || !password) {
if (!username && !password) {
loginError = { type: 'empty_username_password', msg: 'Please Insert Your App Username & Password to login.' };
}
if (!username && password) {
loginError = { type: 'empty_username', msg: 'Please Insert Your App Username' };
}
if (!password && username) {
loginError = { type: 'empty_password', msg: 'Please Insert Your App Password' };
}
setError(loginError);
setTimeout(() => { setError(null); }, 3000);
} else {
try {
const header = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'POST', headers: header, body: JSON.stringify({ username, password }) };
const fetchRoute = `${process.env.NEXT_PUBLIC_APP_URL}/api/login`;
const res = await fetch(fetchRoute, fetchOpts).then((result) => result.json());
// console.log(res);
if (!res.success) {
let errorType = '';
if (res.error && res.error.toLowerCase().includes('username')) {
errorType = 'incorrect_username';
}
if (res.error && res.error.toLowerCase().includes('password')) {
errorType = 'incorrect_password';
}
setError({ type: errorType, msg: res.error });
setTimeout(() => { setError(null); }, 3000);
} else {
router.push('/');
}
} catch (fetchError) {
setError({ type: 'unknown', msg: 'Could not login, Ther Server is not responsive.' });
setTimeout(() => { setError(null); }, 3000);
}
}
};
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700';
// eslint-disable-next-line max-len
const inputStyle = 'w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200';
const errorBorderStyle = 'border-red-400 focus:border-red-400';
return (
<div className={'Login'}>
<Head>
<title>Login - SerpBear</title>
</Head>
<div className='flex items-center justify-center w-full h-screen'>
<div className='w-80 mt-[-300px]'>
<h3 className="py-7 text-2xl font-bold text-blue-700 text-center">
<span className=' relative top-[3px] mr-1'>
<Icon type="logo" size={30} color="#364AFF" />
</span> SerpBear
</h3>
<div className='relative bg-[white] rounded-md text-sm border p-5'>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Username</label>
<input
className={`
${inputStyle}
${error && error.type.includes('username') ? errorBorderStyle : ''}
`}
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Password</label>
<input
className={`
${inputStyle}
${error && error.type.includes('password') ? errorBorderStyle : ''}
`}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
<button
onClick={() => loginuser()}
className={'py-3 px-5 w-full rounded cursor-pointer bg-blue-700 text-white font-semibold text-sm'}>
Login
</button>
{error && error.msg
&& <div
className={'absolute w-full bottom-[-100px] ml-[-20px] rounded text-center p-3 bg-red-100 text-red-600 text-sm font-semibold'}>
{error.msg}
</div>
}
</div>
</div>
</div>
</div>
);
};
export default Login;

70
playground.js Normal file
View File

@@ -0,0 +1,70 @@
// const Cryptr = require('cryptr');
// const { promises, readFile } = require('fs');
// const { readFile } = require('fs');
require('dotenv').config({ path: './.env.local' });
// const getAppSettings = async () => {
// // console.log('process.env.SECRET: ', process.env.SECRET);
// try {
// const settingsRaw = await promises.readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
// const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
// let decryptedSettings = settings;
// try {
// const cryptr = new Cryptr(process.env.SECRET);
// const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : '';
// const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : '';
// decryptedSettings = { ...settings, scaping_api, smtp_password };
// } catch (error) {
// console.log('Error Decrypting Settings API Keys!');
// }
// return decryptedSettings;
// } catch (error) {
// console.log(error);
// const settings = {
// scraper_type: 'none',
// notification_interval: 'daily',
// notification_email: '',
// smtp_server: '',
// smtp_port: '',
// smtp_username: '',
// smtp_password: '',
// };
// await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(settings), { encoding: 'utf-8' });
// return settings;
// }
// };
// getAppSettings().then((settings) => {
// const notif_interval = (!settings.notification_interval || settings.notification_interval === 'never') ? false : settings.notification_interval;
// console.log('RUN Notif', notif_interval);
// console.log('### Sending Notification Email...');
// const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
// fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/notify`, fetchOpts)
// .then((res) => res.json())
// .then((data) => console.log(data))
// .catch((err) => {
// console.log('ERROR Making Cron Request..');
// console.log(err);
// });
// });
// readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => {
// console.log(data);
// if (data) {
// const keywordsToRetry = data ? JSON.parse(data) : [];
// if (keywordsToRetry.length > 0) {
// const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
// fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/refresh?id=${keywordsToRetry.join(',')}`, fetchOpts)
// .then((res) => res.json())
// .then((refreshedData) => console.log(refreshedData))
// .catch((fetchErr) => {
// console.log('ERROR Making Cron Request..');
// console.log(fetchErr);
// });
// }
// } else {
// console.log('ERROR Reading Failed Scrapes Queue File..', err);
// }
// });

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/fflags.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
public/flagSprite42.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

22
public/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "SerpBear",
"short_name": "SerpBear",
"icons": [
{
"src": "/icon.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#1d4ed8",
"background_color": "#FFFFFF",
"start_url": "/",
"display": "standalone",
"orientation": "portrait"
}

98
services/domains.tsx Normal file
View File

@@ -0,0 +1,98 @@
import { useRouter, NextRouter } from 'next/router';
import toast from 'react-hot-toast';
import { useMutation, useQuery, useQueryClient } from 'react-query';
type UpdatePayload = {
domainSettings: DomainSettings,
domain: Domain
}
export async function fetchDomains(router: NextRouter) {
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/domains`, { method: 'GET' });
if (res.status >= 400 && res.status < 600) {
if (res.status === 401) {
console.log('Unauthorized!!');
router.push('/login');
}
throw new Error('Bad response from server');
}
return res.json();
}
export function useFetchDomains(router: NextRouter) {
return useQuery('domains', () => fetchDomains(router));
}
export function useAddDomain(onSuccess:Function) {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation(async (domainName:string) => {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domain: domainName }) };
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/domains`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async (data) => {
console.log('Domain Added!!!', data);
const newDomain:Domain = data.domain;
toast(`${newDomain.domain} Added Successfully!`, { icon: '✔️' });
onSuccess(false);
if (newDomain && newDomain.slug) {
router.push(`/domain/${data.domain.slug}`);
}
queryClient.invalidateQueries(['domains']);
},
onError: () => {
console.log('Error Adding New Domain!!!');
toast('Error Adding New Domain');
},
});
}
export function useUpdateDomain(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async ({ domainSettings, domain }: UpdatePayload) => {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify(domainSettings) };
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/domains?domain=${domain.domain}`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async () => {
console.log('Settings Updated!!!');
toast('Settings Updated!', { icon: '✔️' });
onSuccess();
queryClient.invalidateQueries(['domains']);
},
onError: () => {
console.log('Error Updating Domain Settings!!!');
toast('Error Updating Domain Settings', { icon: '⚠️' });
},
});
}
export function useDeleteDomain(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async (domain:Domain) => {
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/domains?domain=${domain.domain}`, { method: 'DELETE' });
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async () => {
toast('Domain Removed Successfully!', { icon: '✔️' });
onSuccess();
queryClient.invalidateQueries(['domains']);
},
onError: () => {
console.log('Error Removing Domain!!!');
toast('Error Removing Domain', { icon: '⚠️' });
},
});
}

163
services/keywords.tsx Normal file
View File

@@ -0,0 +1,163 @@
import toast from 'react-hot-toast';
import { NextRouter } from 'next/router';
import { useMutation, useQuery, useQueryClient } from 'react-query';
type KeywordsInput = {
keywords: string,
device: string,
country: string,
domain: string,
tags: string,
}
export const fetchKeywords = async (router: NextRouter) => {
if (!router.query.slug) { return []; }
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/keywords?domain=${router.query.slug}`, { method: 'GET' });
return res.json();
};
export function useFetchKeywords(router: NextRouter, setKeywordSPollInterval:Function, keywordSPollInterval:undefined|number = undefined) {
const { data: keywordsData, isLoading: keywordsLoading, isError } = useQuery(
['keywords', router.query.slug],
() => fetchKeywords(router),
{
refetchInterval: keywordSPollInterval,
onSuccess: (data) => {
// If Keywords are Manually Refreshed check if the any of the keywords position are still being fetched
// If yes, then refecth the keywords every 5 seconds until all the keywords position is updated by the server
if (data.keywords && data.keywords.length > 0 && setKeywordSPollInterval) {
const hasRefreshingKeyword = data.keywords.some((x:KeywordType) => x.updating);
if (hasRefreshingKeyword) {
setKeywordSPollInterval(5000);
} else {
if (keywordSPollInterval) {
toast('Keywords Refreshed!', { icon: '✔️' });
}
setKeywordSPollInterval(undefined);
}
}
},
},
);
return { keywordsData, keywordsLoading, isError };
}
export function useAddKeywords(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async (newKeywords:KeywordsInput) => {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'POST', headers, body: JSON.stringify(newKeywords) };
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/keywords`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async () => {
console.log('Keywords Added!!!');
toast('Keywords Added Successfully!', { icon: '✔️' });
onSuccess();
queryClient.invalidateQueries(['keywords']);
},
onError: () => {
console.log('Error Adding New Keywords!!!');
toast('Error Adding New Keywords', { icon: '⚠️' });
},
});
}
export function useDeleteKeywords(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async (keywordIDs:number[]) => {
const keywordIds = keywordIDs.join(',');
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/keywords?id=${keywordIds}`, { method: 'DELETE' });
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async () => {
console.log('Removed Keyword!!!');
onSuccess();
toast('Keywords Removed Successfully!', { icon: '✔️' });
queryClient.invalidateQueries(['keywords']);
},
onError: () => {
console.log('Error Removing Keyword!!!');
toast('Error Removing the Keywords', { icon: '⚠️' });
},
});
}
export function useFavKeywords(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async ({ keywordID, sticky }:{keywordID:number, sticky:boolean}) => {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify({ sticky }) };
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/keywords?id=${keywordID}`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async (data) => {
onSuccess();
const isSticky = data.keywords[0] && data.keywords[0].sticky;
toast(isSticky ? 'Keywords Made Favorite!' : 'Keywords Unfavorited!', { icon: '✔️' });
queryClient.invalidateQueries(['keywords']);
},
onError: () => {
console.log('Error Changing Favorite Status!!!');
toast('Error Changing Favorite Status.', { icon: '⚠️' });
},
});
}
export function useUpdateKeywordTags(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async ({ tags }:{tags:{ [ID:number]: string[] }}) => {
const keywordIds = Object.keys(tags).join(',');
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify({ tags }) };
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/keywords?id=${keywordIds}`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async () => {
onSuccess();
toast('Keyword Tags Updated!', { icon: '✔️' });
queryClient.invalidateQueries(['keywords']);
},
onError: () => {
console.log('Error Updating Keyword Tags!!!');
toast('Error Updating Keyword Tags.', { icon: '⚠️' });
},
});
}
export function useRefreshKeywords(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async ({ ids = [], domain = '' } : {ids?: number[], domain?: string}) => {
const keywordIds = ids.join(',');
console.log(keywordIds);
const query = ids.length === 0 && domain ? `?id=all&domain=${domain}` : `?id=${keywordIds}`;
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/refresh${query}`, { method: 'POST' });
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async () => {
console.log('Keywords Added to Refresh Queue!!!');
onSuccess();
toast('Keywords Added to Refresh Queue', { icon: '🔄' });
queryClient.invalidateQueries(['keywords']);
},
onError: () => {
console.log('Error Refreshing Keywords!!!');
toast('Error Refreshing Keywords.', { icon: '⚠️' });
},
});
}

41
services/settings.ts Normal file
View File

@@ -0,0 +1,41 @@
import toast from 'react-hot-toast';
import { useMutation, useQuery, useQueryClient } from 'react-query';
export async function fetchSettings() {
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/settings`, { method: 'GET' });
return res.json();
}
export function useFetchSettings() {
return useQuery('settings', () => fetchSettings());
}
const useUpdateSettings = (onSuccess:Function|undefined) => {
const queryClient = useQueryClient();
return useMutation(async (settings: SettingsType) => {
// console.log('settings: ', JSON.stringify(settings));
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify({ settings }) };
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/settings`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async () => {
if (onSuccess) {
onSuccess();
}
toast('Settings Updated!', { icon: '✔️' });
queryClient.invalidateQueries(['settings']);
},
onError: () => {
console.log('Error Updating App Settings!!!');
toast('Error Updating App Settings.', { icon: '⚠️' });
},
});
};
export default useUpdateSettings;

279
styles/fflag.css Normal file
View File

@@ -0,0 +1,279 @@
.fflag {
background-image:url('../public/flagSprite42.png');
background-repeat:no-repeat;
background-size: 100% 49494%;
display: inline-block;
overflow: hidden;
position: relative;
vertical-align: middle;
box-sizing: content-box;
}
.fflag-CH,
.fflag-NP {box-shadow: none!important}
.fflag-DZ {background-position:center 0.2287%}
.fflag-AO {background-position:center 0.4524%}
.fflag-BJ {background-position:center 0.6721%}
.fflag-BW {background-position:center 0.8958%}
.fflag-BF {background-position:center 1.1162%}
.fflag-BI {background-position:center 1.3379%}
.fflag-CM {background-position:center 1.5589%}
.fflag-CV {background-position:center 1.7805%}
.fflag-CF {background-position:center 2.0047%}
.fflag-TD {background-position:center 2.2247%}
.fflag-CD {background-position:left 2.4467%}
.fflag-DJ {background-position:left 2.6674%}
.fflag-EG {background-position:center 2.8931%}
.fflag-GQ {background-position:center 3.1125%}
.fflag-ER {background-position:left 3.3325%}
.fflag-ET {background-position:center 3.5542%}
.fflag-GA {background-position:center 3.7759%}
.fflag-GM {background-position:center 4.0015%}
.fflag-GH {background-position:center 4.2229%}
.fflag-GN {background-position:center 4.441%}
.fflag-GW {background-position:left 4.66663%}
.fflag-CI {background-position:center 4.8844%}
.fflag-KE {background-position:center 5.1061%}
.fflag-LS {background-position:center 5.3298%}
.fflag-LR {background-position:left 5.5495%}
.fflag-LY {background-position:center 5.7712%}
.fflag-MG {background-position:center 5.994%}
.fflag-MW {background-position:center 6.2156%}
.fflag-ML {background-position:center 6.4363%}
.fflag-MR {background-position:center 6.658%}
.fflag-MU {background-position:center 6.8805%}
.fflag-YT {background-position:center 7.1038%}
.fflag-MA {background-position:center 7.3231%}
.fflag-MZ {background-position:left 7.5448%}
.fflag-NA {background-position:left 7.7661%}
.fflag-NE {background-position:center 7.98937%}
.fflag-NG {background-position:center 8.2099%}
.fflag-CG {background-position:center 8.4316%}
.fflag-RE {background-position:center 8.6533%}
.fflag-RW {background-position:right 8.875%}
.fflag-SH {background-position:center 9.0967%}
.fflag-ST {background-position:center 9.32237%}
.fflag-SN {background-position:center 9.5426%}
.fflag-SC {background-position:left 9.7628%}
.fflag-SL {background-position:center 9.9845%}
.fflag-SO {background-position:center 10.2052%}
.fflag-ZA {background-position:left 10.4269%}
.fflag-SS {background-position:left 10.6486%}
.fflag-SD {background-position:center 10.8703%}
.fflag-SR {background-position:center 11.0945%}
.fflag-SZ {background-position:center 11.3135%}
.fflag-TG {background-position:left 11.5354%}
.fflag-TN {background-position:center 11.7593%}
.fflag-UG {background-position:center 11.9799%}
.fflag-TZ {background-position:center 12.2005%}
.fflag-EH {background-position:center 12.4222%}
.fflag-YE {background-position:center 12.644%}
.fflag-ZM {background-position:center 12.8664%}
.fflag-ZW {background-position:left 13.0873%}
.fflag-AI {background-position:center 13.309%}
.fflag-AG {background-position:center 13.5307%}
.fflag-AR {background-position:center 13.7524%}
.fflag-AW {background-position:left 13.9741%}
.fflag-BS {background-position:left 14.1958%}
.fflag-BB {background-position:center 14.4175%}
.fflag-BQ {background-position:center 14.6415%}
.fflag-BZ {background-position:center 14.8609%}
.fflag-BM {background-position:center 15.0826%}
.fflag-BO {background-position:center 15.306%}
.fflag-VG {background-position:center 15.528%}
.fflag-BR {background-position:center 15.7496%}
.fflag-CA {background-position:center 15.9694%}
.fflag-KY {background-position:center 16.1911%}
.fflag-CL {background-position:left 16.4128%}
.fflag-CO {background-position:left 16.6345%}
.fflag-KM {background-position:center 16.8562%}
.fflag-CR {background-position:center 17.0779%}
.fflag-CU {background-position:left 17.2996%}
.fflag-CW {background-position:center 17.5213%}
.fflag-DM {background-position:center 17.743%}
.fflag-DO {background-position:center 17.968%}
.fflag-EC {background-position:center 18.1864%}
.fflag-SV {background-position:center 18.4081%}
.fflag-FK {background-position:center 18.6298%}
.fflag-GF {background-position:center 18.8515%}
.fflag-GL {background-position:left 19.0732%}
.fflag-GD {background-position:center 19.2987%}
.fflag-GP {background-position:center 19.518%}
.fflag-GT {background-position:center 19.7383%}
.fflag-GY {background-position:center 19.96%}
.fflag-HT {background-position:center 20.1817%}
.fflag-HN {background-position:center 20.4034%}
.fflag-JM {background-position:center 20.6241%}
.fflag-MQ {background-position:center 20.8468%}
.fflag-MX {background-position:center 21.0685%}
.fflag-MS {background-position:center 21.2902%}
.fflag-NI {background-position:center 21.5119%}
.fflag-PA {background-position:center 21.7336%}
.fflag-PY {background-position:center 21.9553%}
.fflag-PE {background-position:center 22.177%}
.fflag-PR {background-position:left 22.4002%}
.fflag-BL {background-position:center 22.6204%}
.fflag-KN {background-position:center 22.8421%}
.fflag-LC {background-position:center 23.0638%}
.fflag-PM {background-position:center 23.2855%}
.fflag-VC {background-position:center 23.5072%}
.fflag-SX {background-position:left 23.732%}
.fflag-TT {background-position:center 23.9506%}
.fflag-TC {background-position:center 24.1723%}
.fflag-US {background-position:center 24.394%}
.fflag-VI {background-position:center 24.6157%}
.fflag-UY {background-position:left 24.8374%}
.fflag-VE {background-position:center 25.0591%}
.fflag-AB {background-position:center 25.279%}
.fflag-AF {background-position:center 25.5025%}
.fflag-AZ {background-position:center 25.7242%}
.fflag-BD {background-position:center 25.9459%}
.fflag-BT {background-position:center 26.1676%}
.fflag-BN {background-position:center 26.3885%}
.fflag-KH {background-position:center 26.611%}
.fflag-CN {background-position:left 26.8327%}
.fflag-GE {background-position:center 27.0544%}
.fflag-HK {background-position:center 27.2761%}
.fflag-IN {background-position:center 27.4978%}
.fflag-ID {background-position:center 27.7195%}
.fflag-JP {background-position:center 27.9412%}
.fflag-KZ {background-position:center 28.1615%}
.fflag-LA {background-position:center 28.3846%}
.fflag-MO {background-position:center 28.6063%}
.fflag-MY {background-position:center 28.829%}
.fflag-MV {background-position:center 29.0497%}
.fflag-MN {background-position:left 29.2714%}
.fflag-MM {background-position:center 29.4931%}
.fflag-NP {background-position:left 29.7148%}
.fflag-KP {background-position:left 29.9365%}
.fflag-MP {background-position:center 30.1582%}
.fflag-PW {background-position:center 30.3799%}
.fflag-PG {background-position:center 30.6016%}
.fflag-PH {background-position:left 30.8233%}
.fflag-SG {background-position:left 31.045%}
.fflag-KR {background-position:center 31.2667%}
.fflag-LK {background-position:right 31.4884%}
.fflag-TW {background-position:left 31.7101%}
.fflag-TJ {background-position:center 31.9318%}
.fflag-TH {background-position:center 32.1535%}
.fflag-TL {background-position:left 32.3752%}
.fflag-TM {background-position:center 32.5969%}
.fflag-VN {background-position:center 32.8186%}
.fflag-AL {background-position:center 33.0403%}
.fflag-AD {background-position:center 33.25975%}
.fflag-AM {background-position:center 33.4837%}
.fflag-AT {background-position:center 33.7054%}
.fflag-BY {background-position:left 33.9271%}
.fflag-BE {background-position:center 34.1488%}
.fflag-BA {background-position:center 34.3705%}
.fflag-BG {background-position:center 34.5922%}
.fflag-HR {background-position:center 34.8139%}
.fflag-CY {background-position:center 35.0356%}
.fflag-CZ {background-position:left 35.2555%}
.fflag-DK {background-position:center 35.479%}
.fflag-EE {background-position:center 35.7007%}
.fflag-FO {background-position:center 35.9224%}
.fflag-FI {background-position:center 36.1441%}
.fflag-FR {background-position:center 36.3658%}
.fflag-DE {background-position:center 36.5875%}
.fflag-GI {background-position:center 36.8092%}
.fflag-GR {background-position:left 37.0309%}
.fflag-GG {background-position:center 37.2526%}
.fflag-HU {background-position:center 37.4743%}
.fflag-IS {background-position:center 37.696%}
.fflag-IE {background-position:center 37.9177%}
.fflag-IM {background-position:center 38.1394%}
.fflag-IT {background-position:center 38.3611%}
.fflag-JE {background-position:center 38.5828%}
.fflag-XK {background-position:center 38.8045%}
.fflag-LV {background-position:center 39.0262%}
.fflag-LI {background-position:left 39.2479%}
.fflag-LT {background-position:center 39.4696%}
.fflag-LU {background-position:center 39.6913%}
.fflag-MT {background-position:left 39.913%}
.fflag-MD {background-position:center 40.1347%}
.fflag-MC {background-position:center 40.3564%}
.fflag-ME {background-position:center 40.5781%}
.fflag-NL {background-position:center 40.7998%}
.fflag-MK {background-position:center 41.0215%}
.fflag-NO {background-position:center 41.2432%}
.fflag-PL {background-position:center 41.4649%}
.fflag-PT {background-position:center 41.6866%}
.fflag-RO {background-position:center 41.9083%}
.fflag-RU {background-position:center 42.13%}
.fflag-SM {background-position:center 42.3517%}
.fflag-RS {background-position:center 42.5734%}
.fflag-SK {background-position:center 42.7951%}
.fflag-SI {background-position:center 43.0168%}
.fflag-ES {background-position:left 43.2385%}
.fflag-SE {background-position:center 43.4602%}
.fflag-CH {background-position:center 43.6819%}
.fflag-TR {background-position:center 43.9036%}
.fflag-UA {background-position:center 44.1253%}
.fflag-GB {background-position:center 44.347%}
.fflag-VA {background-position:right 44.5687%}
.fflag-BH {background-position:center 44.7904%}
.fflag-IR {background-position:center 45.0121%}
.fflag-IQ {background-position:center 45.2338%}
.fflag-IL {background-position:center 45.4555%}
.fflag-KW {background-position:left 45.6772%}
.fflag-JO {background-position:left 45.897%}
.fflag-KG {background-position:center 46.1206%}
.fflag-LB {background-position:center 46.3423%}
.fflag-OM {background-position:left 46.561%}
.fflag-PK {background-position:center 46.7857%}
.fflag-PS {background-position:center 47.0074%}
.fflag-QA {background-position:center 47.2291%}
.fflag-SA {background-position:center 47.4508%}
.fflag-SY {background-position:center 47.6725%}
.fflag-AE {background-position:center 47.8942%}
.fflag-UZ {background-position:left 48.1159%}
.fflag-AS {background-position:right 48.3376%}
.fflag-AU {background-position:center 48.5593%}
.fflag-CX {background-position:center 48.781%}
.fflag-CC {background-position:center 49.002%}
.fflag-CK {background-position:center 49.2244%}
.fflag-FJ {background-position:center 49.4445%}
.fflag-PF {background-position:center 49.6678%}
.fflag-GU {background-position:center 49.8895%}
.fflag-KI {background-position:center 50.1112%}
.fflag-MH {background-position:left 50.3329%}
.fflag-FM {background-position:center 50.5546%}
.fflag-NC {background-position:center 50.7763%}
.fflag-NZ {background-position:center 50.998%}
.fflag-NR {background-position:left 51.2197%}
.fflag-NU {background-position:center 51.4414%}
.fflag-NF {background-position:center 51.6631%}
.fflag-WS {background-position:left 51.8848%}
.fflag-SB {background-position:left 52.1065%}
.fflag-TK {background-position:center 52.3282%}
.fflag-TO {background-position:left 52.5499%}
.fflag-TV {background-position:center 52.7716%}
.fflag-VU {background-position:left 52.9933%}
.fflag-WF {background-position:center 53.215%}
.fflag-TD.ff-round,
.fflag-GN.ff-round,
.fflag-CI.ff-round,
.fflag-ML.ff-round,
.fflag-NG.ff-round,
.fflag-BE.ff-round,
.fflag-FR.ff-round,
.fflag-IE.ff-round,
.fflag-IT.ff-round,
.fflag-RO.ff-round {background-size:100% 50000%}
.fflag.ff-sm {width: 18px;height: 11px}
.fflag.ff-md {width: 27px;height: 17px}
.fflag.ff-lg {width: 42px;height: 27px}
.fflag.ff-xl {width: 60px;height: 37px}
/* ff-round = circular icons */
.ff-round {
background-size: 160%;
background-clip: content-box;
border-radius: 50%;
}
.ff-round.ff-sm {width: 12px; height: 12px}
.ff-round.ff-md {width: 18px; height: 18px}
.ff-round.ff-lg {width: 24px; height: 24px}
.ff-round.ff-xl {width: 32px; height: 32px}

View File

@@ -1,26 +1,97 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('./fflag.css');
body{
background-color: #f8f9ff;
}
a {
color: inherit;
text-decoration: none;
.domKeywords{
min-height: 70vh;
border-color: #E9EBFF;
box-shadow: 0 0 20px rgba(20, 34, 71, 0.05);
}
* {
box-sizing: border-box;
.customShadow{
border-color: #E9EBFF;
box-shadow: 0 0 20px rgba(20, 34, 71, 0.05);
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
body {
color: white;
background: black;
}
.styled-scrollbar {
scrollbar-color: #d6dbec transparent;
scrollbar-width: thin;
}
.styled-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
border-radius: 0px;
background: #f5f7ff;
margin-right: 4px;
border: 0px solid transparent;
}
.styled-scrollbar::-webkit-scrollbar-thumb {
width: 6px;
height: 6px;
border-radius: 0px;
color: #d6dbec;
background: #d6d8e1;
border: 0px solid transparent;
box-shadow: none;
}
.ct-area {
fill: #10b98d73;
}
.ct-label.ct-horizontal {
font-size: 11px;
}
.ct-label.ct-vertical {
font-size: 12px;
}
.chart_tooltip{
width: 95px;
height: 75px;
background-color: white;
position: absolute;
display: none;
padding: 0 8px;
box-sizing: border-box;
font-size: 12px;
text-align: left;
z-index: 1000;
top: 12px;
left: 12px;
pointer-events: none;
border: 1px solid;
border-radius: 4px;
border-color: #a7aed3;
box-shadow: 0 0 10px rgb(0 0 0 / 12%);
font-family: 'Trebuchet MS', Roboto, Ubuntu, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.react_toaster{
font-size: 13px;
}
.domKeywords_head--alpha_desc .domKeywords_head_keyword:after,
.domKeywords_head--pos_desc .domKeywords_head_position:after
{content: '↓' ; display: inline-block; margin-left: 2px; font-size: 14px; opacity: 0.8;}
.domKeywords_head--alpha_asc .domKeywords_head_keyword:after,
.domKeywords_head--pos_asc .domKeywords_head_position:after
{content: '↑' ; display: inline-block; margin-left: 2px; font-size: 14px; opacity: 0.8;}
.keywordDetails__section__results{
height: calc(100vh - 550px);
}
.settings__content{
height: calc(100vh - 185px);
overflow: auto;
}

24
tailwind.config.js Normal file
View File

@@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
purge: {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
},
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
safelist: [
'max-h-48',
'w-[150px]',
'w-[240px]',
'min-w-[270px]',
'min-w-[180px]'
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@@ -13,8 +13,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
"incremental": true,
"experimentalDecorators": true,
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "types.d.ts", "utils/generateEmail__.js"],
"exclude": ["node_modules"],
}

69
types.d.ts vendored Normal file
View File

@@ -0,0 +1,69 @@
/* eslint-disable no-unused-vars */
type Domain = {
ID: number,
domain: string,
slug: string,
tags?: string[],
notification: boolean,
notification_interval: string,
notification_emails: string,
lastUpdated: string,
added: string,
keywordCount: number
}
type KeywordHistory = {
[date:string] : number
}
type KeywordType = {
ID: number,
keyword: string,
device: string,
country: string,
domain: string,
lastUpdated: string,
added: string,
position: number,
sticky: boolean,
history: KeywordHistory,
lastResult: KeywordLastResult[],
url: string,
tags: string[],
updating: boolean,
lastUpdateError: string
}
type KeywordLastResult = {
position: number,
url: string,
title: string
}
type KeywordFilters = {
countries: string[],
tags: string[],
search: string,
}
type countryData = {
[ISO:string] : string[]
}
type DomainSettings = {
notification_interval: string,
notification_emails: string,
}
type SettingsType = {
scraper_type: string,
scaping_api?: string,
proxy?: string,
notification_interval: string,
notification_email: string,
notification_email_from: string,
smtp_server: string,
smtp_port: string,
smtp_username: string,
smtp_password: string
}

258
utils/countries.ts Normal file
View File

@@ -0,0 +1,258 @@
const countries: countryData = {
AD: ['Andorra', 'Andorra la Vella', 'ca'],
AE: ['United Arab Emirates', 'Abu Dhabi', 'ar'],
AF: ['Afghanistan', 'Kabul', 'ps'],
AG: ['Antigua and Barbuda', "Saint John's", 'en'],
AI: ['Anguilla', 'The Valley', 'en'],
AL: ['Albania', 'Tirana', 'sq'],
AM: ['Armenia', 'Yerevan', 'hy'],
AO: ['Angola', 'Luanda', 'pt'],
AQ: ['Antarctica', '', ''],
AR: ['Argentina', 'Buenos Aires', 'es'],
AS: ['American Samoa', 'Pago Pago', 'en'],
AT: ['Austria', 'Vienna', 'de'],
AU: ['Australia', 'Canberra', 'en'],
AW: ['Aruba', 'Oranjestad', 'nl'],
AX: ['Åland', 'Mariehamn', 'sv'],
AZ: ['Azerbaijan', 'Baku', 'az'],
BA: ['Bosnia and Herzegovina', 'Sarajevo', 'bs'],
BB: ['Barbados', 'Bridgetown', 'en'],
BD: ['Bangladesh', 'Dhaka', 'bn'],
BE: ['Belgium', 'Brussels', 'nl'],
BF: ['Burkina Faso', 'Ouagadougou', 'fr'],
BG: ['Bulgaria', 'Sofia', 'bg'],
BH: ['Bahrain', 'Manama', 'ar'],
BI: ['Burundi', 'Bujumbura', 'fr'],
BJ: ['Benin', 'Porto-Novo', 'fr'],
BL: ['Saint Barthélemy', 'Gustavia', 'fr'],
BM: ['Bermuda', 'Hamilton', 'en'],
BN: ['Brunei', 'Bandar Seri Begawan', 'ms'],
BO: ['Bolivia', 'Sucre', 'es'],
BQ: ['Bonaire', 'Kralendijk', 'nl'],
BR: ['Brazil', 'Brasília', 'pt'],
BS: ['Bahamas', 'Nassau', 'en'],
BT: ['Bhutan', 'Thimphu', 'dz'],
BV: ['Bouvet Island', '', 'no'],
BW: ['Botswana', 'Gaborone', 'en'],
BY: ['Belarus', 'Minsk', 'be'],
BZ: ['Belize', 'Belmopan', 'en'],
CA: ['Canada', 'Ottawa', 'en'],
CC: ['Cocos [Keeling] Islands', 'West Island', 'en'],
CD: ['Democratic Republic of the Congo', 'Kinshasa', 'fr'],
CF: ['Central African Republic', 'Bangui', 'fr'],
CG: ['Republic of the Congo', 'Brazzaville', 'fr'],
CH: ['Switzerland', 'Bern', 'de'],
CI: ['Ivory Coast', 'Yamoussoukro', 'fr'],
CK: ['Cook Islands', 'Avarua', 'en'],
CL: ['Chile', 'Santiago', 'es'],
CM: ['Cameroon', 'Yaoundé', 'en'],
CN: ['China', 'Beijing', 'zh'],
CO: ['Colombia', 'Bogotá', 'es'],
CR: ['Costa Rica', 'San José', 'es'],
CU: ['Cuba', 'Havana', 'es'],
CV: ['Cape Verde', 'Praia', 'pt'],
CW: ['Curacao', 'Willemstad', 'nl'],
CX: ['Christmas Island', 'Flying Fish Cove', 'en'],
CY: ['Cyprus', 'Nicosia', 'el'],
CZ: ['Czech Republic', 'Prague', 'cs'],
DE: ['Germany', 'Berlin', 'de'],
DJ: ['Djibouti', 'Djibouti', 'fr'],
DK: ['Denmark', 'Copenhagen', 'da'],
DM: ['Dominica', 'Roseau', 'en'],
DO: ['Dominican Republic', 'Santo Domingo', 'es'],
DZ: ['Algeria', 'Algiers', 'ar'],
EC: ['Ecuador', 'Quito', 'es'],
EE: ['Estonia', 'Tallinn', 'et'],
EG: ['Egypt', 'Cairo', 'ar'],
EH: ['Western Sahara', 'El Aaiún', 'es'],
ER: ['Eritrea', 'Asmara', 'ti'],
ES: ['Spain', 'Madrid', 'es'],
ET: ['Ethiopia', 'Addis Ababa', 'am'],
FI: ['Finland', 'Helsinki', 'fi'],
FJ: ['Fiji', 'Suva', 'en'],
FK: ['Falkland Islands', 'Stanley', 'en'],
FM: ['Micronesia', 'Palikir', 'en'],
FO: ['Faroe Islands', 'Tórshavn', 'fo'],
FR: ['France', 'Paris', 'fr'],
GA: ['Gabon', 'Libreville', 'fr'],
GB: ['United Kingdom', 'London', 'en'],
GD: ['Grenada', "St. George's", 'en'],
GE: ['Georgia', 'Tbilisi', 'ka'],
GF: ['French Guiana', 'Cayenne', 'fr'],
GG: ['Guernsey', 'St. Peter Port', 'en'],
GH: ['Ghana', 'Accra', 'en'],
GI: ['Gibraltar', 'Gibraltar', 'en'],
GL: ['Greenland', 'Nuuk', 'kl'],
GM: ['Gambia', 'Banjul', 'en'],
GN: ['Guinea', 'Conakry', 'fr'],
GP: ['Guadeloupe', 'Basse-Terre', 'fr'],
GQ: ['Equatorial Guinea', 'Malabo', 'es'],
GR: ['Greece', 'Athens', 'el'],
GS: [
'South Georgia and the South Sandwich Islands',
'King Edward Point',
'en',
],
GT: ['Guatemala', 'Guatemala City', 'es'],
GU: ['Guam', 'Hagåtña', 'en'],
GW: ['Guinea-Bissau', 'Bissau', 'pt'],
GY: ['Guyana', 'Georgetown', 'en'],
HK: ['Hong Kong', 'City of Victoria', 'zh'],
HM: ['Heard Island and McDonald Islands', '', 'en'],
HN: ['Honduras', 'Tegucigalpa', 'es'],
HR: ['Croatia', 'Zagreb', 'hr'],
HT: ['Haiti', 'Port-au-Prince', 'fr'],
HU: ['Hungary', 'Budapest', 'hu'],
ID: ['Indonesia', 'Jakarta', 'id'],
IE: ['Ireland', 'Dublin', 'ga'],
IL: ['Israel', 'Jerusalem', 'he'],
IM: ['Isle of Man', 'Douglas', 'en'],
IN: ['India', 'New Delhi', 'hi'],
IO: ['British Indian Ocean Territory', 'Diego Garcia', 'en'],
IQ: ['Iraq', 'Baghdad', 'ar'],
IR: ['Iran', 'Tehran', 'fa'],
IS: ['Iceland', 'Reykjavik', 'is'],
IT: ['Italy', 'Rome', 'it'],
JE: ['Jersey', 'Saint Helier', 'en'],
JM: ['Jamaica', 'Kingston', 'en'],
JO: ['Jordan', 'Amman', 'ar'],
JP: ['Japan', 'Tokyo', 'ja'],
KE: ['Kenya', 'Nairobi', 'en'],
KG: ['Kyrgyzstan', 'Bishkek', 'ky'],
KH: ['Cambodia', 'Phnom Penh', 'km'],
KI: ['Kiribati', 'South Tarawa', 'en'],
KM: ['Comoros', 'Moroni', 'ar'],
KN: ['Saint Kitts and Nevis', 'Basseterre', 'en'],
KP: ['North Korea', 'Pyongyang', 'ko'],
KR: ['South Korea', 'Seoul', 'ko'],
KW: ['Kuwait', 'Kuwait City', 'ar'],
KY: ['Cayman Islands', 'George Town', 'en'],
KZ: ['Kazakhstan', 'Astana', 'kk'],
LA: ['Laos', 'Vientiane', 'lo'],
LB: ['Lebanon', 'Beirut', 'ar'],
LC: ['Saint Lucia', 'Castries', 'en'],
LI: ['Liechtenstein', 'Vaduz', 'de'],
LK: ['Sri Lanka', 'Colombo', 'si'],
LR: ['Liberia', 'Monrovia', 'en'],
LS: ['Lesotho', 'Maseru', 'en'],
LT: ['Lithuania', 'Vilnius', 'lt'],
LU: ['Luxembourg', 'Luxembourg', 'fr'],
LV: ['Latvia', 'Riga', 'lv'],
LY: ['Libya', 'Tripoli', 'ar'],
MA: ['Morocco', 'Rabat', 'ar'],
MC: ['Monaco', 'Monaco', 'fr'],
MD: ['Moldova', 'Chișinău', 'ro'],
ME: ['Montenegro', 'Podgorica', 'sr'],
MF: ['Saint Martin', 'Marigot', 'en'],
MG: ['Madagascar', 'Antananarivo', 'fr'],
MH: ['Marshall Islands', 'Majuro', 'en'],
MK: ['North Macedonia', 'Skopje', 'mk'],
ML: ['Mali', 'Bamako', 'fr'],
MM: ['Myanmar [Burma]', 'Naypyidaw', 'my'],
MN: ['Mongolia', 'Ulan Bator', 'mn'],
MO: ['Macao', 'Macao', 'zh'],
MP: ['Northern Mariana Islands', 'Saipan', 'en'],
MQ: ['Martinique', 'Fort-de-France', 'fr'],
MR: ['Mauritania', 'Nouakchott', 'ar'],
MS: ['Montserrat', 'Plymouth', 'en'],
MT: ['Malta', 'Valletta', 'mt'],
MU: ['Mauritius', 'Port Louis', 'en'],
MV: ['Maldives', 'Malé', 'dv'],
MW: ['Malawi', 'Lilongwe', 'en'],
MX: ['Mexico', 'Mexico City', 'es'],
MY: ['Malaysia', 'Kuala Lumpur', 'ms'],
MZ: ['Mozambique', 'Maputo', 'pt'],
NA: ['Namibia', 'Windhoek', 'en'],
NC: ['New Caledonia', 'Nouméa', 'fr'],
NE: ['Niger', 'Niamey', 'fr'],
NF: ['Norfolk Island', 'Kingston', 'en'],
NG: ['Nigeria', 'Abuja', 'en'],
NI: ['Nicaragua', 'Managua', 'es'],
NL: ['Netherlands', 'Amsterdam', 'nl'],
NO: ['Norway', 'Oslo', 'no'],
NP: ['Nepal', 'Kathmandu', 'ne'],
NR: ['Nauru', 'Yaren', 'en'],
NU: ['Niue', 'Alofi', 'en'],
NZ: ['New Zealand', 'Wellington', 'en'],
OM: ['Oman', 'Muscat', 'ar'],
PA: ['Panama', 'Panama City', 'es'],
PE: ['Peru', 'Lima', 'es'],
PF: ['French Polynesia', 'Papeetē', 'fr'],
PG: ['Papua New Guinea', 'Port Moresby', 'en'],
PH: ['Philippines', 'Manila', 'en'],
PK: ['Pakistan', 'Islamabad', 'en'],
PL: ['Poland', 'Warsaw', 'pl'],
PM: ['Saint Pierre and Miquelon', 'Saint-Pierre', 'fr'],
PN: ['Pitcairn Islands', 'Adamstown', 'en'],
PR: ['Puerto Rico', 'San Juan', 'es'],
PS: ['Palestine', 'Ramallah', 'ar'],
PT: ['Portugal', 'Lisbon', 'pt'],
PW: ['Palau', 'Ngerulmud', 'en'],
PY: ['Paraguay', 'Asunción', 'es'],
QA: ['Qatar', 'Doha', 'ar'],
RE: ['Réunion', 'Saint-Denis', 'fr'],
RO: ['Romania', 'Bucharest', 'ro'],
RS: ['Serbia', 'Belgrade', 'sr'],
RU: ['Russia', 'Moscow', 'ru'],
RW: ['Rwanda', 'Kigali', 'rw'],
SA: ['Saudi Arabia', 'Riyadh', 'ar'],
SB: ['Solomon Islands', 'Honiara', 'en'],
SC: ['Seychelles', 'Victoria', 'fr'],
SD: ['Sudan', 'Khartoum', 'ar'],
SE: ['Sweden', 'Stockholm', 'sv'],
SG: ['Singapore', 'Singapore', 'en'],
SH: ['Saint Helena', 'Jamestown', 'en'],
SI: ['Slovenia', 'Ljubljana', 'sl'],
SJ: ['Svalbard and Jan Mayen', 'Longyearbyen', 'no'],
SK: ['Slovakia', 'Bratislava', 'sk'],
SL: ['Sierra Leone', 'Freetown', 'en'],
SM: ['San Marino', 'City of San Marino', 'it'],
SN: ['Senegal', 'Dakar', 'fr'],
SO: ['Somalia', 'Mogadishu', 'so'],
SR: ['Suriname', 'Paramaribo', 'nl'],
SS: ['South Sudan', 'Juba', 'en'],
ST: ['São Tomé and Príncipe', 'São Tomé', 'pt'],
SV: ['El Salvador', 'San Salvador', 'es'],
SX: ['Sint Maarten', 'Philipsburg', 'nl'],
SY: ['Syria', 'Damascus', 'ar'],
SZ: ['Swaziland', 'Lobamba', 'en'],
TC: ['Turks and Caicos Islands', 'Cockburn Town', 'en'],
TD: ['Chad', "N'Djamena", 'fr'],
TF: ['French Southern Territories', 'Port-aux-Français', 'fr'],
TG: ['Togo', 'Lomé', 'fr'],
TH: ['Thailand', 'Bangkok', 'th'],
TJ: ['Tajikistan', 'Dushanbe', 'tg'],
TK: ['Tokelau', 'Fakaofo', 'en'],
TL: ['East Timor', 'Dili', 'pt'],
TM: ['Turkmenistan', 'Ashgabat', 'tk'],
TN: ['Tunisia', 'Tunis', 'ar'],
TO: ['Tonga', "Nuku'alofa", 'en'],
TR: ['Turkey', 'Ankara', 'tr'],
TT: ['Trinidad and Tobago', 'Port of Spain', 'en'],
TV: ['Tuvalu', 'Funafuti', 'en'],
TW: ['Taiwan', 'Taipei', 'zh'],
TZ: ['Tanzania', 'Dodoma', 'sw'],
UA: ['Ukraine', 'Kyiv', 'uk'],
UG: ['Uganda', 'Kampala', 'en'],
UM: ['U.S. Minor Outlying Islands', '', 'en'],
US: ['United States', 'New York', 'en'],
UY: ['Uruguay', 'Montevideo', 'es'],
UZ: ['Uzbekistan', 'Tashkent', 'uz'],
VA: ['Vatican City', 'Vatican City', 'it'],
VC: ['Saint Vincent and the Grenadines', 'Kingstown', 'en'],
VE: ['Venezuela', 'Caracas', 'es'],
VG: ['British Virgin Islands', 'Road Town', 'en'],
VI: ['U.S. Virgin Islands', 'Charlotte Amalie', 'en'],
VN: ['Vietnam', 'Hanoi', 'vi'],
VU: ['Vanuatu', 'Port Vila', 'bi'],
WF: ['Wallis and Futuna', 'Mata-Utu', 'fr'],
WS: ['Samoa', 'Apia', 'sm'],
XK: ['Kosovo', 'Pristina', 'sq'],
YE: ['Yemen', "Sana'a", 'ar'],
YT: ['Mayotte', 'Mamoudzou', 'fr'],
ZA: ['South Africa', 'Pretoria', 'af'],
ZM: ['Zambia', 'Lusaka', 'en'],
ZW: ['Zimbabwe', 'Harare', 'en'],
};
export default countries;

30
utils/exportcsv.ts Normal file
View File

@@ -0,0 +1,30 @@
import countries from './countries';
/**
* Generates CSV File form the given domain & keywords, and automatically downloads it.
* @param {KeywordType[]} keywords - The keywords of the domain
* @param {string} domain - The domain name.
* @returns {void}
*/
const exportCSV = (keywords: KeywordType[], domain:string) => {
const csvHeader = 'ID,Keyword,Position,URL,Country,Device,Updated,Added,Tags\r\n';
let csvBody = '';
keywords.forEach((keywordData) => {
const { ID, keyword, position, url, country, device, lastUpdated, added, tags } = keywordData;
// eslint-disable-next-line max-len
csvBody += `${ID}, ${keyword}, ${position === 0 ? '-' : position}, ${url || '-'}, ${countries[country][0]}, ${device}, ${lastUpdated}, ${added}, ${tags.join(',')}\r\n`;
});
const blob = new Blob([csvHeader + csvBody], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `${domain}-keywords_serp.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export default exportCSV;

107
utils/generateEmail.ts Normal file
View File

@@ -0,0 +1,107 @@
import dayjs from 'dayjs';
import { readFile } from 'fs/promises';
import path from 'path';
const serpBearLogo = 'https://i.imgur.com/ikAdjQq.png';
const mobileIcon = 'https://i.imgur.com/SqXD9rd.png';
const desktopIcon = 'https://i.imgur.com/Dx3u0XD.png';
/**
* Geenrate Human readable Time string.
* @param {number} date - Keywords to scrape
* @returns {string}
*/
const timeSince = (date:number) : string => {
const seconds = Math.floor(((new Date().getTime() / 1000) - date));
let interval = Math.floor(seconds / 31536000);
if (interval > 1) return `${interval} years ago`;
interval = Math.floor(seconds / 2592000);
if (interval > 1) return `${interval} months ago`;
interval = Math.floor(seconds / 86400);
if (interval >= 1) return `${interval} days ago`;
interval = Math.floor(seconds / 3600);
if (interval >= 1) return `${interval} hours ago`;
interval = Math.floor(seconds / 60);
if (interval > 1) return `${interval} minutes ago`;
return `${Math.floor(seconds)} seconds ago`;
};
/**
* Returns a Keyword's position change value by comparing the current position with previous position.
* @param {KeywordHistory} history - Keywords to scrape
* @param {number} position - Keywords to scrape
* @returns {number}
*/
const getPositionChange = (history:KeywordHistory, position:number) : number => {
let status = 0;
if (Object.keys(history).length >= 2) {
const historyArray = Object.keys(history).map((dateKey) => ({
date: new Date(dateKey).getTime(),
dateRaw: dateKey,
position: history[dateKey],
}));
const historySorted = historyArray.sort((a, b) => a.date - b.date);
const previousPos = historySorted[historySorted.length - 2].position;
status = previousPos - position;
}
return status;
};
/**
* Generate the Email HTML based on given domain name and its keywords
* @param {string} domainName - Keywords to scrape
* @param {keywords[]} keywords - Keywords to scrape
* @returns {Promise}
*/
const generateEmail = async (domainName:string, keywords:KeywordType[]) : Promise<string> => {
const emailTemplate = await readFile(path.join(__dirname, '..', '..', '..', '..', 'email', 'email.html'), { encoding: 'utf-8' });
const currentDate = dayjs(new Date()).format('MMMM D, YYYY');
const keywordsCount = keywords.length;
let improved = 0; let declined = 0;
let keywordsTable = '';
keywords.forEach((keyword) => {
let positionChangeIcon = '';
const positionChange = getPositionChange(keyword.history, keyword.position);
const deviceIconImg = keyword.device === 'desktop' ? desktopIcon : mobileIcon;
const countryFlag = `<img class="flag" src="https://flagcdn.com/w20/${keyword.country.toLowerCase()}.png" alt="${keyword.country}" title="${keyword.country}" />`;
const deviceIcon = `<img class="device" src="${deviceIconImg}" alt="${keyword.device}" title="${keyword.device}" />`;
if (positionChange > 0) { positionChangeIcon = '<span style="color:#5ed7c3;">▲</span>'; improved += 1; }
if (positionChange < 0) { positionChangeIcon = '<span style="color:#fca5a5;">▼</span>'; declined += 1; }
const posChangeIcon = positionChange ? `<span class="pos_change">${positionChangeIcon} ${positionChange}</span>` : '';
keywordsTable += `<tr class="keyword">
<td>${countryFlag} ${deviceIcon} ${keyword.keyword}</td>
<td>${keyword.position}${posChangeIcon}</td>
<td>${timeSince(new Date(keyword.lastUpdated).getTime() / 1000)}</td>
</tr>`;
});
const stat = `${improved > 0 ? `${improved} Improved` : ''}
${improved > 0 && declined > 0 ? ', ' : ''} ${declined > 0 ? `${declined} Declined` : ''}`;
const updatedEmail = emailTemplate
.replace('{{logo}}', `<img class="logo_img" src="${serpBearLogo}" alt="SerpBear" />`)
.replace('{{currentDate}}', currentDate)
.replace('{{domainName}}', domainName)
.replace('{{keywordsCount}}', keywordsCount.toString())
.replace('{{keywordsTable}}', keywordsTable)
.replace('{{appURL}}', process.env.NEXT_PUBLIC_APP_URL || '')
.replace('{{stat}}', stat)
.replace('{{preheader}}', stat);
// const writePath = path.join(__dirname, '..', 'email', 'email_update.html');
// await writeFile(writePath, updatedEmail, {encoding:'utf-8'});
return updatedEmail;
};
export default generateEmail;

18
utils/parseKeywords.ts Normal file
View File

@@ -0,0 +1,18 @@
import Keyword from '../database/models/keyword';
/**
* Parses the SQL Keyword Model object to frontend cosumable object.
* @param {Keyword[]} allKeywords - Keywords to scrape
* @returns {KeywordType[]}
*/
const parseKeywords = (allKeywords: Keyword[]) : KeywordType[] => {
const parsedItems = allKeywords.map((keywrd:Keyword) => ({
...keywrd,
history: JSON.parse(keywrd.history),
tags: JSON.parse(keywrd.tags),
lastResult: JSON.parse(keywrd.lastResult),
}));
return parsedItems;
};
export default parseKeywords;

53
utils/refresh.ts Normal file
View File

@@ -0,0 +1,53 @@
import { performance } from 'perf_hooks';
import { RefreshResult, scrapeKeywordFromGoogle } from './scraper';
/**
* Refreshes the Keywords position by Scraping Google Search Result by
* Determining whether the keywords should be scraped in Parallal or not
* @param {KeywordType[]} keywords - Keywords to scrape
* @param {SettingsType} settings - The App Settings that contain the Scraper settings
* @returns {Promise}
*/
const refreshKeywords = async (keywords:KeywordType[], settings:SettingsType): Promise<RefreshResult[]> => {
if (!keywords || keywords.length === 0) { return []; }
const start = performance.now();
let refreshedResults: RefreshResult[] = [];
if (settings.scraper_type === 'scrapingant') {
refreshedResults = await refreshParallal(keywords, settings);
} else {
for (const keyword of keywords) {
console.log('START SCRAPE: ', keyword.keyword);
const refreshedkeywordData = await scrapeKeywordFromGoogle(keyword, settings);
refreshedResults.push(refreshedkeywordData);
}
}
const end = performance.now();
console.log(`time taken: ${end - start}ms`);
// console.log('refreshedResults: ', refreshedResults);
return refreshedResults;
};
/**
* Scrape Google Keyword Search Result in Prallal.
* @param {KeywordType[]} keywords - Keywords to scrape
* @param {SettingsType} settings - The App Settings that contain the Scraper settings
* @returns {Promise}
*/
const refreshParallal = async (keywords:KeywordType[], settings:SettingsType) : Promise<RefreshResult[]> => {
const promises: Promise<RefreshResult>[] = keywords.map((keyword) => {
return scrapeKeywordFromGoogle(keyword, settings);
});
return Promise.all(promises).then((promiseData) => {
console.log('ALL DONE!!!');
return promiseData;
}).catch((err) => {
console.log(err);
return [];
});
};
export default refreshKeywords;

211
utils/scraper.ts Normal file
View File

@@ -0,0 +1,211 @@
import axios, { AxiosResponse, CreateAxiosDefaults } from 'axios';
// import axiosRetry from 'axios-retry';
// import path from 'path';
import cheerio from 'cheerio';
import { readFile, writeFile } from 'fs/promises';
import HttpsProxyAgent from 'https-proxy-agent';
import countries from './countries';
type SearchResult = {
title: string,
url: string,
position: number,
}
type SERPObject = {
postion:number|boolean,
url:string
}
export type RefreshResult = false | {
ID: number,
keyword: string,
position:number|boolean,
url: string,
result: SearchResult[],
error?: boolean
}
/**
* Creates a SERP Scraper client promise based on the app settings.
* @param {KeywordType} keyword - the keyword to get the SERP for.
* @param {SettingsType} settings - the App Settings that contains the scraper details
* @returns {Promise}
*/
export const getScraperClient = (keyword:KeywordType, settings:SettingsType): Promise<AxiosResponse> | false => {
let apiURL = '';
const axiosConfig: CreateAxiosDefaults = {};
let userAgent = keyword && keyword.device === 'mobile' ? {
// eslint-disable-next-line max-len
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; SM-G996U Build/QP1A.190711.020; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Mobile Safari/537.36',
} : undefined;
if (settings && settings.scraper_type === 'scrapingant' && settings.scaping_api) {
const scraperCountries = ['AE', 'BR', 'CN', 'DE', 'ES', 'FR', 'GB', 'HK', 'PL', 'IN', 'IT', 'IL', 'JP', 'NL', 'RU', 'SA', 'US', 'CZ'];
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
const lang = countries[country][2];
apiURL = `https://api.scrapingant.com/v2/general?url=https%3A%2F%2Fwww.google.com%2Fsearch%3Fnum%3D100%26hl%3D${lang}%26q%3D${encodeURI(keyword.keyword)}&x-api-key=${settings.scaping_api}&proxy_country=${country}&browser=false`;
axiosConfig.headers = userAgent;
}
if (settings && settings.scraper_type === 'scrapingrobot' && settings.scaping_api) {
const country = keyword.country || 'US';
const lang = countries[country][2];
apiURL = `https://api.scrapingrobot.com/?url=https%3A%2F%2Fwww.google.com%2Fsearch%3Fnum%3D100%26hl%3D${lang}%26q%3D${encodeURI(keyword.keyword)}&token=${settings.scaping_api}&proxyCountry=${country}&render=false${keyword.device === 'mobile' ? '&mobile=true' : ''}`;
userAgent = undefined;
}
if (settings && settings.scraper_type === 'proxy' && settings.proxy) {
apiURL = `https://www.google.com/search?num=100&q=${encodeURI(keyword.keyword)}`;
const proxies = settings.proxy.split(/\r?\n|\r|\n/g);
let proxyURL = '';
if (proxies.length > 1) {
proxyURL = proxies[Math.floor(Math.random() * proxies.length)];
} else {
const [firstProxy] = proxies;
proxyURL = firstProxy;
}
// axiosConfig.baseURL = apiURL;
axiosConfig.httpsAgent = new (HttpsProxyAgent as any)(proxyURL.trim());
axiosConfig.headers = userAgent;
axiosConfig.proxy = false;
}
const client = axios.create(axiosConfig);
// axiosRetry(client, { retries: 3 });
return client.get(apiURL);
};
/**
* Scrape Google Search result as object array from the Google Search's HTML content
* @param {string} keyword - the keyword to search for in Google.
* @param {string} settings - the App Settings
* @returns {RefreshResult[]}
*/
export const scrapeKeywordFromGoogle = async (keyword:KeywordType, settings:SettingsType) : Promise<RefreshResult> => {
let refreshedResults: RefreshResult = false;
const scraperClient = getScraperClient(keyword, settings);
if (!scraperClient) { return false; }
try {
const res = await scraperClient;
if (res.data) {
// writeFile(`result${index}.txt`, res.data, { encoding: 'utf-8'});
const extracted = extractScrapedResult(res.data, settings.scraper_type);
const serp = getSerp(keyword.domain, extracted);
refreshedResults = { ID: keyword.ID, keyword: keyword.keyword, position: serp.postion, url: serp.url, result: extracted };
// console.log(extracted);
console.log('SERP: ', keyword.keyword, serp.postion, serp.url);
}
} catch (error:any) {
console.log('#### SCRAPE ERROR: ', keyword.keyword, error?.code, error?.response?.status, error?.response?.data);
// If Failed, Send back the original Keyword
refreshedResults = {
ID: keyword.ID,
keyword: keyword.keyword,
position: keyword.position,
url: keyword.url,
result: keyword.lastResult,
error: true,
};
}
return refreshedResults;
};
/**
* Extracts the Google Search result as object array from the Google Search's HTML content
* @param {string} content - scraped google search page html data.
* @param {string} scraper_type - the type of scraper (Proxy or Scraper)
* @returns {SearchResult[]}
*/
export const extractScrapedResult = (content:string, scraper_type:string): SearchResult[] => {
const extractedResult = [];
const $ = cheerio.load(content);
const hasNumberofResult = $('body').find('#search > div > div');
const searchResult = hasNumberofResult.children();
if (scraper_type === 'proxy') {
const mainContent = $('body').find('#main');
const children = $(mainContent).find('h3');
for (let index = 1; index < children.length; index += 1) {
const title = $(children[index]).text();
const url = $(children[index]).closest('a').attr('href');
const cleanedURL = url ? url.replace('/url?q=', '').replace(/&sa=.*/, '') : '';
extractedResult.push({ title, url: cleanedURL, position: index });
}
} else {
for (let i = 1; i < searchResult.length; i += 1) {
if (searchResult[i]) {
const title = $(searchResult[i]).find('h3').html();
const url = $(searchResult[i]).find('a').attr('href');
if (title && url) {
extractedResult.push({ title, url, position: i });
// console.log(i, ' ',title, ' ', url);
}
}
}
}
return extractedResult;
};
/**
* Find in the domain's position from the extracted search result.
* @param {string} domain - Domain Name to look for.
* @param {SearchResult[]} result - The search result array extracted from the Google Search result.
* @returns {SERPObject}
*/
export const getSerp = (domain:string, result:SearchResult[]) : SERPObject => {
if (result.length === 0 || !domain) { return { postion: false, url: '' }; }
const foundItem = result.find((item) => {
const itemDomain = item.url.match(/^(?:https?:)?(?:\/\/)?([^/?]+)/i);
return itemDomain && itemDomain.includes(domain);
});
return { postion: foundItem ? foundItem.position : 0, url: foundItem && foundItem.url ? foundItem.url : '' };
};
/**
* When a Refresh request is failed, automatically add the keyword id to a failed_queue.json file
* so that the retry cron tries to scrape it every hour until the scrape is successful.
* @param {string} keywordID - The keywordID of the failed Keyword Scrape.
* @returns {void}
*/
export const retryScrape = async (keywordID: number) : Promise<void> => {
if (!keywordID) { return; }
let currentQueue: number[] = [];
// const filePath = path.join(__dirname, '..', '..', '..', '..', 'data', 'failed_queue.json');
const filePath = `${process.cwd()}/data/failed_queue.json`;
const currentQueueRaw = await readFile(filePath, { encoding: 'utf-8' }).catch((err) => { console.log(err); return '[]'; });
currentQueue = JSON.parse(currentQueueRaw);
if (!currentQueue.includes(keywordID)) {
currentQueue.push(keywordID);
}
await writeFile(filePath, JSON.stringify(currentQueue), { encoding: 'utf-8' }).catch((err) => { console.log(err); return '[]'; });
};
/**
* When a Refresh request is completed, remove it from the failed retry queue.
* @param {string} keywordID - The keywordID of the failed Keyword Scrape.
* @returns {void}
*/
export const removeFromRetryQueue = async (keywordID: number) : Promise<void> => {
if (!keywordID) { return; }
let currentQueue: number[] = [];
// const filePath = path.join(__dirname, '..', '..', '..', '..', 'data', 'failed_queue.json');
const filePath = `${process.cwd()}/data/failed_queue.json`;
const currentQueueRaw = await readFile(filePath, { encoding: 'utf-8' }).catch((err) => { console.log(err); return '[]'; });
currentQueue = JSON.parse(currentQueueRaw);
currentQueue = currentQueue.filter((item) => item !== keywordID);
await writeFile(filePath, JSON.stringify(currentQueue), { encoding: 'utf-8' }).catch((err) => { console.log(err); return '[]'; });
};

72
utils/sortFilter.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* When a Refresh request is failed, automatically add the keyword id to a failed_queue.json file
* so that the retry cron tries to scrape it every hour until the scrape is successful.
* @param {KeywordType[]} theKeywords - The Keywords to sort.
* @param {string} sortBy - The sort method.
* @returns {KeywordType[]}
*/
export const sortKeywords = (theKeywords:KeywordType[], sortBy:string) : KeywordType[] => {
let sortedItems = [];
switch (sortBy) {
case 'date_asc':
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => new Date(b.added).getTime() - new Date(a.added).getTime());
break;
case 'date_desc':
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => new Date(a.added).getTime() - new Date(b.added).getTime());
break;
case 'pos_asc':
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (b.position > a.position ? 1 : -1));
break;
case 'pos_desc':
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.position > b.position ? 1 : -1));
break;
case 'alpha_asc':
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (b.keyword > a.keyword ? 1 : -1));
break;
case 'alpha_desc':
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.keyword > b.keyword ? 1 : -1));
break;
default:
return theKeywords;
}
// Stick Favorites item to top
sortedItems = sortedItems.sort((a: KeywordType, b: KeywordType) => (b.sticky > a.sticky ? 1 : -1));
return sortedItems;
};
/**
* Filters the Keywords by Device when the Device buttons are switched
* @param {KeywordType[]} sortedKeywords - The Sorted Keywords.
* @param {string} device - Device name (desktop or mobile).
* @returns {{desktop: KeywordType[], mobile: KeywordType[] } }
*/
export const keywordsByDevice = (sortedKeywords: KeywordType[], device: string): {[key: string]: KeywordType[] } => {
const deviceKeywords: {[key:string] : KeywordType[]} = { desktop: [], mobile: [] };
sortedKeywords.forEach((keyword) => {
if (keyword.device === device) { deviceKeywords[device].push(keyword); }
});
return deviceKeywords;
};
/**
* Fitlers the keywords by country, search string or tags.
* @param {KeywordType[]} keywords - The keywords.
* @param {KeywordFilters} filterParams - The user Selected filter object.
* @returns {KeywordType[]}
*/
export const filterKeywords = (keywords: KeywordType[], filterParams: KeywordFilters):KeywordType[] => {
const filteredItems:KeywordType[] = [];
keywords.forEach((keywrd) => {
const countryMatch = filterParams.countries.length === 0 ? true : filterParams.countries && filterParams.countries.includes(keywrd.country);
const searchMatch = !filterParams.search ? true : filterParams.search && keywrd.keyword.includes(filterParams.search);
const tagsMatch = filterParams.tags.length === 0 ? true : filterParams.tags && keywrd.tags.find((x) => filterParams.tags.includes(x));
if (countryMatch && searchMatch && tagsMatch) {
filteredItems.push(keywrd);
}
});
return filteredItems;
};

47
utils/verifyUser.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import Cookies from 'cookies';
import jwt from 'jsonwebtoken';
/**
* Psuedo Middleware: Verifies the user by their cookie value or their API Key
* When accessing with API key only certain routes are accessible.
* @param {NextApiRequest} req - The Next Request
* @param {NextApiResponse} res - The Next Response.
* @returns {string}
*/
const verifyUser = (req: NextApiRequest, res: NextApiResponse): string => {
const cookies = new Cookies(req, res);
const token = cookies && cookies.get('token');
const allowedApiRoutes = ['GET:/api/keyword', 'GET:/api/keywords', 'GET:/api/domains', 'POST:/api/refresh', 'POST:/api/cron', 'POST:/api/notify'];
const verifiedAPI = req.headers.authorization ? req.headers.authorization.substring('Bearer '.length) === process.env.APIKEY : false;
const accessingAllowedRoute = req.url && req.method && allowedApiRoutes.includes(`${req.method}:${req.url.replace(/\?(.*)/, '')}`);
console.log(req.method, req.url);
let authorized: string = '';
if (token && process.env.SECRET) {
jwt.verify(token, process.env.SECRET, (err) => {
// console.log(err);
authorized = err ? 'Not authorized' : 'authorized';
});
} else if (verifiedAPI && accessingAllowedRoute) {
authorized = 'authorized';
} else {
if (!token) {
authorized = 'Not authorized';
}
if (token && !process.env.SECRET) {
authorized = 'Token has not been Setup.';
}
if (verifiedAPI && !accessingAllowedRoute) {
authorized = 'This Route cannot be accessed with API.';
}
if (req.headers.authorization && !verifiedAPI) {
authorized = 'Invalid API Key Provided.';
}
}
return authorized;
};
export default verifyUser;