diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2089a02 --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..67ac4d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +USER=admin +PASSWORD=0123456789 +SECRET=4715aed3216f7b0a38e6b534a958362654e96d10fbc04700770d572af3dce43625dd +APIKEY=5saedXklbslhnapihe2pihp3pih4fdnakhjwq5 +SESSION_DURATION=24 +NEXT_PUBLIC_APP_URL=http://localhost:3000 \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index bffb357..825baa9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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" + } + ] + } } diff --git a/.gitignore b/.gitignore index c87c9b3..d9d7391 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ceab69 --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/README.md b/README.md index c87e042..adf1461 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/__test__/components/Icon.test.tsx b/__test__/components/Icon.test.tsx new file mode 100644 index 0000000..b05cc1a --- /dev/null +++ b/__test__/components/Icon.test.tsx @@ -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(); + expect(document.querySelector('svg')).toBeInTheDocument(); + }); +}); diff --git a/__test__/components/Keyword.test.tsx b/__test__/components/Keyword.test.tsx new file mode 100644 index 0000000..e3994c1 --- /dev/null +++ b/__test__/components/Keyword.test.tsx @@ -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(); + expect(await screen.findByText('compress image')).toBeInTheDocument(); + }); + it('Should Render Position Correctly', async () => { + render(); + const positionElement = document.querySelector('.keyword_position'); + expect(positionElement?.childNodes[0].nodeValue).toBe('19'); + }); + it('Should Display Position Change arrow', async () => { + render(); + const positionElement = document.querySelector('.keyword_position i'); + expect(positionElement?.textContent).toBe('▲ 1'); + }); + it('Should Display the SERP Page URL', async () => { + render(); + const positionElement = document.querySelector('.keyword_url'); + expect(positionElement?.textContent).toBe('/'); + }); + it('Should Display the Keyword Options on dots Click', async () => { + render(); + 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(); + // 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(); + // }); +}); diff --git a/__test__/components/Modal.test.tsx b/__test__/components/Modal.test.tsx new file mode 100644 index 0000000..51605cd --- /dev/null +++ b/__test__/components/Modal.test.tsx @@ -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; + +// jest.mock('../../components/common/Icon', () => () =>
); + +describe('Modal Component', () => { + it('Renders without crashing', async () => { + render( console.log() }>
); + // mockedUseEffect.mock.calls[0](); + expect(document.querySelector('.modal')).toBeInTheDocument(); + }); +// it('Sets up the escapae key shortcut', async () => { +// render( console.log() }>
); +// expect(mockedUseEffect).toBeCalled(); +// }); + it('Displays the Given Content', async () => { + render( console.log() }> +
+

Hello Modal!!

+
+
); + expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument(); + }); + it('Renders Modal Title', async () => { + render( console.log() } title="Sample Modal Title">

Some Modal Content

); + expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument(); + }); +}); diff --git a/__test__/components/Sidebar.test.tsx b/__test__/components/Sidebar.test.tsx new file mode 100644 index 0000000..a5845ea --- /dev/null +++ b/__test__/components/Sidebar.test.tsx @@ -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( console.log() } />); + expect( + await screen.findByText('SerpBear'), + ).toBeInTheDocument(); + }); +}); diff --git a/__test__/components/Topbar.test.tsx b/__test__/components/Topbar.test.tsx new file mode 100644 index 0000000..2881dd0 --- /dev/null +++ b/__test__/components/Topbar.test.tsx @@ -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( console.log() } />); + expect( + await screen.findByText('SerpBear'), + ).toBeInTheDocument(); + }); +}); diff --git a/__test__/data.ts b/__test__/data.ts new file mode 100644 index 0000000..82e7f31 --- /dev/null +++ b/__test__/data.ts @@ -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', + }, +]; diff --git a/__test__/hooks/domains.tests.tsx b/__test__/hooks/domains.tests.tsx new file mode 100644 index 0000000..9244bd0 --- /dev/null +++ b/__test__/hooks/domains.tests.tsx @@ -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); + }); + }); +}); diff --git a/__test__/pages/domain.test.tsx b/__test__/pages/domain.test.tsx new file mode 100644 index 0000000..4fc3495 --- /dev/null +++ b/__test__/pages/domain.test.tsx @@ -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; +const useFetchKeywordsFunc = useFetchKeywords as jest.Mock; +const useDeleteKeywordsFunc = useDeleteKeywords as jest.Mock; +const useFavKeywordsFunc = useFavKeywords as jest.Mock; +const useRefreshKeywordsFunc = useRefreshKeywords as jest.Mock; +const useAddDomainFunc = useAddDomain as jest.Mock; +const useAddKeywordsFunc = useAddKeywords as jest.Mock; +const useUpdateDomainFunc = useUpdateDomain as jest.Mock; +const useDeleteDomainFunc = useDeleteDomain as jest.Mock; + +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(); + // 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(); + // screen.debug(undefined, Infinity); + expect(useFetchDomains).toHaveBeenCalled(); + // expect(await result.findByText(/compressimage/i)).toBeInTheDocument(); + }); + it('Should Render the Keywords', async () => { + render(); + const keywordsCount = document.querySelectorAll('.keyword').length; + expect(keywordsCount).toBe(2); + }); + it('Should Display the Keywords Details Sidebar on Keyword Click.', async () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/__test__/pages/index.test.tsx b/__test__/pages/index.test.tsx new file mode 100644 index 0000000..d8f0f58 --- /dev/null +++ b/__test__/pages/index.test.tsx @@ -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( + + + , + ); + // 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( + + + , + ); + expect(await screen.findByText('Add Domain')).toBeInTheDocument(); + }); +}); diff --git a/__test__/utils.tsx b/__test__/utils.tsx new file mode 100644 index 0000000..89ba832 --- /dev/null +++ b/__test__/utils.tsx @@ -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( + {ui}, + ); + return { + ...result, + rerender: (rerenderUi: React.ReactElement) => rerender( + {rerenderUi}, + ), + }; +} + +export function createWrapper() { + const testQueryClient = createTestQueryClient(); + // eslint-disable-next-line react/display-name + return ({ children }: {children: React.ReactNode}) => ( + {children} + ); +} diff --git a/components/common/Chart.tsx b/components/common/Chart.tsx new file mode 100644 index 0000000..01d7ffe --- /dev/null +++ b/components/common/Chart.tsx @@ -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 ; +}; + +export default Chart; diff --git a/components/common/ChartSlim.tsx b/components/common/ChartSlim.tsx new file mode 100644 index 0000000..696a632 --- /dev/null +++ b/components/common/ChartSlim.tsx @@ -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
+ +
; +}; + +export default ChartSlim; diff --git a/components/common/Icon.tsx b/components/common/Icon.tsx new file mode 100644 index 0000000..7e054bb --- /dev/null +++ b/components/common/Icon.tsx @@ -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 ( + + {type === 'logo' + && + + + + + } + {type === 'loading' + && + + + + + + } + {type === 'menu' + && + + + } + {type === 'close' + && + + + } + {type === 'download' + && + + + + } + {type === 'trash' + && + + + + } + {type === 'edit' + && + + + } + {type === 'check' + && + + + } + {type === 'error' + && + + + } + {type === 'question' + && + + + } + {type === 'caret-left' + && + + + } + {type === 'caret-right' + && + + + } + {type === 'caret-down' + && + + + } + {type === 'caret-up' + && + + + } + {type === 'search' + && + + + } + {type === 'settings' + && + + + } + {type === 'settings-alt' + && + + + + } + {type === 'logout' + && + + + } + {type === 'reload' + && + + + } + {type === 'dots' + && + + + } + {type === 'hamburger' + && + + + + + } + {type === 'star' + && + + + } + {type === 'star-filled' + && + + + } + {type === 'link' + && + + + + } + {type === 'link-alt' + && + + + + + + + } + {type === 'clock' + && + + + } + {type === 'sort' + && + + + } + {type === 'desktop' + && + + + } + {type === 'mobile' + && + + + + + } + {type === 'tags' + && + + + } + {type === 'filter' + && + + + } + + ); + }; + + export default Icon; diff --git a/components/common/Modal.tsx b/components/common/Modal.tsx new file mode 100644 index 0000000..3dbc68a --- /dev/null +++ b/components/common/Modal.tsx @@ -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 ( +
+
+ {title &&

{title}

} + +
{children}
+
+
+ ); +}; + +export default Modal; diff --git a/components/common/SelectField.tsx b/components/common/SelectField.tsx new file mode 100644 index 0000000..2d08159 --- /dev/null +++ b/components/common/SelectField.tsx @@ -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 ( +
+
setShowOptions(!showOptions)}> + + {selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel} + + {multiple && selected.length > 2 + && {(selected.length)}} + +
+ {showOptions && ( +
+
    + {options.map((opt) => { + const itemActive = selected.includes(opt.value); + return ( +
  • selectItem(opt)} + > + {multiple && ( + + + + )} + {flags && } + {opt.label} +
  • + ); + })} +
+ {emptyMsg && options.length === 0 &&

{emptyMsg}

} +
+ )} +
+ ); + }; + + export default SelectField; diff --git a/components/common/Sidebar.tsx b/components/common/Sidebar.tsx new file mode 100644 index 0000000..7cc8733 --- /dev/null +++ b/components/common/Sidebar.tsx @@ -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 ( +
+

+ SerpBear +

+
+ +
+
+ +
+
+ ); + }; + + export default Sidebar; diff --git a/components/common/TopBar.tsx b/components/common/TopBar.tsx new file mode 100644 index 0000000..a5b7bc7 --- /dev/null +++ b/components/common/TopBar.tsx @@ -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(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 ( +
+ +

+ SerpBear +

+
+ + +
+
+ ); + }; + + export default TopBar; diff --git a/components/common/generateChartData.ts b/components/common/generateChartData.ts new file mode 100644 index 0000000..b345c24 --- /dev/null +++ b/components/common/generateChartData.ts @@ -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; +}; diff --git a/components/domains/AddDomain.tsx b/components/domains/AddDomain.tsx new file mode 100644 index 0000000..647517a --- /dev/null +++ b/components/domains/AddDomain.tsx @@ -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(''); + const [newDomainError, setNewDomainError] = useState(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) => { + if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(false); } + setNewDomain(e.currentTarget.value); + }; + + return ( + { closeModal(false); }} title={'Add New Domain'}> +
+

+ Domain Name {newDomainError && Not a Valid Domain} +

+ { + if (e.code === 'Enter') { + e.preventDefault(); + addDomain(); + } + }} + /> +
+ + +
+
+
+ ); +}; + +export default AddDomain; diff --git a/components/domains/DomainHeader.tsx b/components/domains/DomainHeader.tsx new file mode 100644 index 0000000..7f46a12 --- /dev/null +++ b/components/domains/DomainHeader.tsx @@ -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(false); + + const { mutate: refreshMutate } = useRefreshKeywords(() => {}); + + const buttonStyle = 'leading-6 inline-block px-2 py-2 text-gray-500 hover:text-gray-700'; + return ( +
+
+

+ {domain && domain.domain && <>{domain.domain.charAt(0)}{domain.domain.slice(1)}} +

+
+ 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'} + /> +
+
+
+ +
+ + + +
+ +
+
+ ); +}; + +export default DomainHeader; diff --git a/components/domains/DomainSettings.tsx b/components/domains/DomainSettings.tsx new file mode 100644 index 0000000..3c63ee0 --- /dev/null +++ b/components/domains/DomainSettings.tsx @@ -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(false); + const [settingsError, setSettingsError] = useState({ type: '', msg: '' }); + const [domainSettings, setDomainSettings] = useState({ 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) => { + 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 ( +
+ closeModal(false)} title={'Domain Settings'} width="[500px]"> +
+
+

Notification Emails + {settingsError.type === 'email' && {settingsError.msg}} +

+ +
+
+
+ + +
+
+ {showRemoveDomain && ( + setShowRemoveDomain(false) } title={`Remove Domain ${domain.domain}`}> +
+

Are you sure you want to remove this Domain? Removing this domain will remove all its keywords.

+
+ + +
+
+
+ )} +
+ ); +}; + +export default DomainSettings; diff --git a/components/keywords/AddKeywords.tsx b/components/keywords/AddKeywords.tsx new file mode 100644 index 0000000..76fb718 --- /dev/null +++ b/components/keywords/AddKeywords.tsx @@ -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(''); + const [newKeywordsData, setNewKeywordsData] = useState({ 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 ( + { closeModal(false); }} title={'Add New Keywords'} width="[420px]"> +
+
+
+ +
+ +
+
+ { return { label: countries[countryISO][0], value: countryISO }; })} + defaultLabel='All Countries' + updateField={(updated:string[]) => setNewKeywordsData({ ...newKeywordsData, country: updated[0] })} + rounded='rounded' + maxHeight={48} + flags={true} + /> +
+
    +
  • setNewKeywordsData({ ...newKeywordsData, device: 'desktop' })} + > Desktop
  • +
  • setNewKeywordsData({ ...newKeywordsData, device: 'mobile' })} + > Mobile
  • +
+
+ +
+ {/* TODO: Insert Existing Tags as Suggestions */} + setNewKeywordsData({ ...newKeywordsData, tags: e.target.value })} + /> + +
+
+ {error &&
{error}
} +
+ + +
+
+
+ ); +}; + +export default AddKeywords; diff --git a/components/keywords/Keyword.tsx b/components/keywords/Keyword.tsx new file mode 100644 index 0000000..60e7e6f --- /dev/null +++ b/components/keywords/Keyword.tsx @@ -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 {'-'}; + } + if (updating) { + return ; + } + return position; + }; + + return ( +
+
+ + showKeywordDetails()}> + {keyword} + + {sticky && } + {lastUpdateError !== 'false' + && + } +
+
+ {renderPosition()} + {!updating && positionChange > 0 && ▲ {positionChange}} + {!updating && positionChange < 0 && ▼ {positionChange}} +
+ {chartData.labels.length > 0 && ( +
+ +
+ )} +
+ {turncatedURL || '-'}
+
+ + +
+ + {lastUpdateError !== 'false' && showPositionError + &&
+ Error Updating Keyword position (Tried ) + setPositionError(false)}> + + +
+ } +
+ ); + }; + + export default Keyword; diff --git a/components/keywords/KeywordDetails.tsx b/components/keywords/KeywordDetails.tsx new file mode 100644 index 0000000..71f0ce8 --- /dev/null +++ b/components/keywords/KeywordDetails.tsx @@ -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(keyword.history); + const [keywordSearchResult, setKeywordSearchResult] = useState([]); + const [chartTime, setChartTime] = useState('30'); + const searchResultContainer = useRef(null); + const searchResultFound = useRef(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 ( +
+
+
+

+ {keyword.keyword} + {keyword.position} +

+ +
+
+ +
+
+

SERP History

+
+ setChartTime(updatedTime[0])} + multiple={false} + rounded={'rounded'} + /> +
+
+
+ +
+
+
+
+

Google Search Result + + + +

+ {dayjs(updatedDate).format('MMMM D, YYYY')} +
+
+ {keywordSearchResult && Array.isArray(keywordSearchResult) && keywordSearchResult.length > 0 && ( + keywordSearchResult.map((item, index) => { + const { position } = keyword; + const domainExist = position < 100 && index === (position - 1); + return ( +
+

+ {`${index + 1}. ${item.title}`} +

+ {/*

{item.description}

*/} + {item.url} +
+ ); + }) + )} +
+
+
+
+
+ ); +}; + +export default KeywordDetails; diff --git a/components/keywords/KeywordFilter.tsx b/components/keywords/KeywordFilter.tsx new file mode 100644 index 0000000..c0a95d3 --- /dev/null +++ b/components/keywords/KeywordFilter.tsx @@ -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({ 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) => { + 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 ( +
+
+
    +
  • setDevice('desktop')}> + + Desktop + {keywordCounts.desktop} +
  • +
  • setDevice('mobile')}> + + Mobile + {keywordCounts.mobile} +
  • +
+
+
+
+ +
+
+
+ filterCountry(updated)} + flags={true} + /> +
+
+ ({ label: tag, value: tag }))} + defaultLabel='All Tags' + updateField={(updated:string[]) => filterTags(updated)} + emptyMsg="No Tags Found for this Domain" + /> +
+
+ +
+
+
+ + {sortOptions && ( +
    + {sortOptionChoices.map((sortOption) => { + return
  • { updateSort(sortOption.value); showSortOptions(false); }}> + {sortOption.label} +
  • ; + })} +
+ )} +
+
+
+ ); +}; + +export default KeywordFilters; diff --git a/components/keywords/KeywordTagManager.tsx b/components/keywords/KeywordTagManager.tsx new file mode 100644 index 0000000..558b57b --- /dev/null +++ b/components/keywords/KeywordTagManager.tsx @@ -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 ( + { closeModal(false); }} title={`Tags for Keyword "${keyword && keyword.keyword}"`}> +
+ {keyword && keyword.tags.length > 0 && ( +
    + {keyword.tags.map((tag:string) => { + return
  • + {tag} + +
  • ; + })} +
+ )} + {keyword && keyword.tags.length === 0 && ( +
No Tags Added to this Keyword.
+ )} +
+
+ {inputError && {inputError}} + + setTagInput(e.target.value)} + onKeyDown={(e) => { + if (e.code === 'Enter') { + e.preventDefault(); + addTag(); + } + }} + /> + +
+
+ + ); +}; + +export default KeywordTagManager; diff --git a/components/keywords/KeywordsTable.tsx b/components/keywords/KeywordsTable.tsx new file mode 100644 index 0000000..f5fc915 --- /dev/null +++ b/components/keywords/KeywordsTable.tsx @@ -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('desktop'); + const [selectedKeywords, setSelectedKeywords] = useState([]); + const [showKeyDetails, setShowKeyDetails] = useState(null); + const [showRemoveModal, setShowRemoveModal] = useState(false); + const [showTagManager, setShowTagManager] = useState(null); + const [filterParams, setFilterParams] = useState({ countries: [], tags: [], search: '' }); + const [sortBy, setSortBy] = useState('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 ( +
+
+ {selectedKeywords.length > 0 && ( + + )} + {selectedKeywords.length === 0 && ( + setFilterParams(params)} + updateSort={(sorted:string) => setSortBy(sorted)} + sortBy={sortBy} + keywords={keywords} + device={device} + setDevice={setDevice} + /> + )} +
+
+ +
+ {processedKeywords[device] && processedKeywords[device].length > 0 + && processedKeywords[device].map((keyword, index) => 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 && ( +

No Keywords Added for this Device Type.

+ )} + {isLoading && ( +

Loading Keywords...

+ )} +
+
+
+
+ {showKeyDetails && showKeyDetails.ID && ( + setShowKeyDetails(null)} /> + )} + {showRemoveModal && selectedKeywords.length > 0 && ( + { setSelectedKeywords([]); setShowRemoveModal(false); }} title={'Remove Keywords'}> +
+

Are you sure you want to remove {selectedKeywords.length > 1 ? 'these' : 'this'} Keyword?

+
+ + +
+
+
+ )} + {showAddModal && domain && ( + setShowAddModal(false)} + /> + )} + {showTagManager && ( + k.ID === showTagManager)} + closeModal={() => setShowTagManager(null)} + /> + )} + +
+ ); + }; + + export default KeywordsTable; diff --git a/components/settings/Settings.tsx b/components/settings/Settings.tsx new file mode 100644 index 0000000..30d74af --- /dev/null +++ b/components/settings/Settings.tsx @@ -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('scraper'); + const [settings, setSettings] = useState(defaultSettings); + const [settingsError, setSettingsError] = useState(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 ( +
+
+ {isLoading &&
} +
+

Settings

+ +
+
+
    +
  • setCurrentTab('scraper')}> + Scraper +
  • +
  • setCurrentTab('notification')}> + Notification +
  • +
+
+ {currentTab === 'scraper' && ( +
+
+ +
+ + updateSettings('scraper_type', updatedTime[0])} + multiple={false} + rounded={'rounded'} + minWidth={270} + /> +
+ {['scrapingant', 'scrapingrobot'].includes(settings.scraper_type) && ( +
+ + updateSettings('scaping_api', event.target.value)} + /> +
+ )} + {settings.scraper_type === 'proxy' && ( +
+ +