156 Commits

Author SHA1 Message Date
towfiqi
7e8840c2e2 chore(release): 1.0.2 2024-02-15 08:47:46 +06:00
towfiqi
0e64b95cd5 fix: Resolves Incorrect Position issue.
closes #164
2024-02-15 08:30:31 +06:00
towfiqi
e5ad7a3175 chore(release): 1.0.1 2024-02-13 23:55:32 +06:00
towfiqi
e5dd411aa9 fix: Resolves the app crash issue when there is no database.
closes #161, #162
2024-02-13 23:54:55 +06:00
towfiqi
c3ddb9d3c3 chore(release): 1.0.0 2024-02-09 21:47:39 +06:00
towfiqi
dbf540cfdb fix: Resolves missing Keyword Loading Spinner issue. 2024-02-09 21:18:23 +06:00
towfiqi
1f0831ed13 chore: Updates vulnerable dependencies. 2024-02-09 00:56:59 +06:00
towfiqi
b4ad69baaa feat: Adds Serper.dev integration
closes #138
2024-02-09 00:43:28 +06:00
towfiqi
f04b10cf6b feat: Adds the ability to setup Search Console through the UI.
- Adds the ability to add domain specific Search Console API Info through the Domain Settings panel.
- Adds the ability to add global Search Console API Info through the App Settings Panel.
- Adds better Search Console Error logging.
- Changes the App Settings Sidebar UI.
- Changers the Domain Settings Modal UI.
- Replaces html Input field with custom InputField component.
- Adds a new /domain api route to get the full domain info which includes the domain level Search console API.

closes #59, #146
2024-02-08 22:14:24 +06:00
towfiqi
b2e97b2ebe feat: Adds the Ability to set Search Console Property type via Domain Settings.
- Previously only domain properties worked with SerpBear. This feature adds the ability to add URL properties as well.
- Adds a new field "search_console" in Domain Table.
- Adds a new Search Console option in Domain Settings Modal UI.
- When the  new "This is a URL Property" option is enabled, the exact Property URL should be provided.

closes #50
2024-02-06 23:42:28 +06:00
towfiqi
1041cb3c0b feat: Adds ValueSerp Integration.
closes #105, #106
2024-02-06 13:32:24 +06:00
towfiqi
3719f21d98 feat: Adds the ability for city level scraping for scapers that allow it.
- Only available for scrapers that allows custom location or city level scraping.
- When a city level keyword is added the city name is displayed in the keyword title.

closes #139, #151
2024-02-06 13:22:32 +06:00
towfiqi
444ba5d461 refactor: Adds Keyword Table migration to add new fields.
- Adds city, latlong and settings fields to Keyword table.
2024-02-04 23:43:13 +06:00
towfiqi
dd54e535c9 build: adds database migration method. 2024-02-04 23:39:39 +06:00
towfiqi
34d121dac7 refactor: removes unnecessary useEffect 2024-02-04 10:27:44 +06:00
towfiqi
3c2a1b8a5b feat: adds the ability to add url as a domain.
You can now track specific marketplace/social domain URLs. For example a reddit.com post, an amazon.com product, github repo etc.

closes: #53, #90, #119
2024-02-03 20:17:51 +06:00
towfiqi
e2ecdef10e Chore: Fixes Typo 2024-02-03 10:15:07 +06:00
towfiqi
633ab2c467 fix: Resolves Keywords filter crashing issue. 2024-02-03 10:14:46 +06:00
towfiqi
7c6c7fc3d1 chore(release): 0.3.4 2024-01-15 23:24:07 +06:00
towfiqi
cca9f95358 fix: fixes local SC data not being removed on deleting domain. 2024-01-15 23:19:57 +06:00
towfiqi
faa88c9254 feat: adds ability to add multiple domains at once. 2024-01-15 23:05:35 +06:00
towfiqi
8b0ee562cf test: Updates test for SingleKeyword component. 2024-01-13 20:18:04 +06:00
towfiqi
2f08bb3f62 docs: Adds docs for Google Search console related functions 2024-01-13 20:06:04 +06:00
towfiqi
897aa0b7d7 chore: removes mocks from production build. 2024-01-13 12:04:15 +06:00
towfiqi
e166b588aa fix: Resolves incorrect keyword average SC data values in Tracker 2024-01-13 11:58:16 +06:00
towfiqi
c897a52550 feat: Adds the ability to show/hide Keys & Passwords in Settings Panel 2024-01-13 11:31:40 +06:00
towfiqi
df3a738788 fix: resolves newly added Domain's Update time rendering issue 2024-01-13 10:25:10 +06:00
towfiqi
4a47cedad8 refactor: improves Performance & Code Readability
- Replaces useEffet with useMemo & useLayoutEffect where necessary.
- Converts some useEffects to separate hooks.
- Moves the functions defined within components to utils.
- Splits the Keywords renderPosition function to its own component.
2024-01-13 10:10:49 +06:00
towfiqi
2783de5c65 refactor: separates client and backend utils. 2024-01-12 22:40:28 +06:00
towfiqi
08f44911d7 chore(release): 0.3.3 2023-11-12 20:59:08 +06:00
towfiqi
9dce1d5b48 chore: resolves test issues 2023-11-12 20:58:03 +06:00
towfiqi
fbd23ede25 feat: Shows total keywords count in domains page 2023-11-11 12:48:31 +06:00
towfiqi
60c68bd339 feat: Adds ability to visit pages from Insight tab 2023-11-11 11:42:14 +06:00
towfiqi
2339e31af9 feat: Domains now show their favicon.
closes #130
2023-11-11 11:29:59 +06:00
towfiqi
4a60271cac fix: Resolves Website Thumbnail missing issue
You can now set your own thum.io key to fetch screenshot. More in docs.

closes #131
2023-11-11 11:17:44 +06:00
towfiqi
c870250fbd chore(release): 0.3.2 2023-11-09 20:35:49 +06:00
towfiqi
da92f11afa chore: resolves linter nag. 2023-11-09 20:35:22 +06:00
towfiqi
9b9b74af4c fix: Resolves issue with adding long tld emails
closes #127
2023-11-09 20:35:01 +06:00
Towfiq I
291aa60bbb Merge pull request #129 from SearchApi/feature/integrate-searchapi
feat: Integrates SearchAPI
fix: Resolves build issue due to missing jest types.
fix: Resolves keyword SERP fetch issue.
2023-11-09 20:01:02 +06:00
SebastjanPrachovskij
8a35e358e6 Integrate SearchApi to SerpBear
Remove unnecessary spacing

Fix keyword.id & authorization for searchapi
2023-11-09 14:50:11 +02:00
towfiqi
f164b287be chore(release): 0.3.1 2023-11-04 22:05:59 +06:00
towfiqi
97dd0b131b fix: Updates vulnerable dependencies 2023-11-04 22:05:10 +06:00
towfiqi
454454a422 fix: Removes dev files from docker volumes 2023-11-04 11:25:56 +06:00
towfiqi
4620f11c4b Fixed: Resolves missing Scrapers list on new install 2023-11-04 10:47:22 +06:00
towfiqi
7ab435ed8b chore(release): 0.3.0 2023-11-03 22:24:46 +06:00
towfiqi
9feff13f18 fix: Fxies special character keyword scrape issue.
closes #113 #122
2023-11-03 21:44:23 +06:00
towfiqi
f57bca23da fix: Resolves missing keyword scrape spinner issue 2023-11-03 21:30:00 +06:00
towfiqi
392122a710 fix: Fixes the weekly cron day issue.
closes #118
2023-11-03 21:05:28 +06:00
towfiqi
fc183d246d feat: Displays the Best position of the keywords
closes #89
2023-11-03 20:45:36 +06:00
towfiqi
994afbcedb chore: changes toggle button ui 2023-11-03 13:51:50 +06:00
towfiqi
d3d336fa71 feat: Remembers last selected coutry
closes #101
2023-11-03 12:47:29 +06:00
towfiqi
6f34d64fd5 chore: opens keyword detail view on chart click 2023-11-03 12:43:56 +06:00
towfiqi
a0014c7650 chore: breakup Settings component 2023-11-03 12:20:02 +06:00
towfiqi
dc3c7a722b feat: Adds ability to disable/clear retry queue for failed keywords 2023-11-03 11:59:51 +06:00
towfiqi
8a949ce4c0 fix: Cron stopped on failing to parse failed queue
closes #116
2023-11-03 11:58:17 +06:00
towfiqi
312d12f589 chore: fixes tag menu overlap issue. 2023-11-02 22:46:55 +06:00
towfiqi
be80ed7ef3 fix: Fixes import order error in some instances.
closes #114
2023-11-02 22:35:25 +06:00
towfiqi
4748ffc382 feat: Adds ability to search w/o case sensitivity
closes #115
2023-11-02 22:21:07 +06:00
towfiqi
c0470cfa9d fix: Fixes issue with adding hyphenated subdomains. 2023-11-02 22:15:26 +06:00
towfiqi
1d6b2be95a feat: Refresh All feature now shows update real-time 2023-11-02 21:51:06 +06:00
towfiqi
0d846b29f1 chore(release): 0.2.6 2023-03-29 21:01:13 +06:00
towfiqi
3b96dab9cc chore: Adds Space Serp Details. 2023-03-29 20:54:55 +06:00
towfiqi
0a83924ffe feat: Add option to Delay Between scrapes.
fixes #87
2023-03-29 20:54:30 +06:00
towfiqi
d9505158c4 fix: Fixes first Keryword Error cut off issue. 2023-03-29 20:11:56 +06:00
towfiqi
9757fde02e fix: Fixes lags when tracking thousands of keywords
fixes #88
2023-03-29 12:59:22 +06:00
towfiqi
0538a8c016 feat: Integrates Space Serp. 2023-03-29 12:14:28 +06:00
Towfiq I
cace34f39a Merge pull request #85 from Teeth-Talk/hotfix-typo
fix(components): fix typo "Goolge" -> "Google"
2023-03-29 10:49:04 +06:00
Martin Silha
dce7c412e8 fix(components): fix typo "Goolge" -> "Google" 2023-03-16 21:44:59 +00:00
towfiqi
e61dfb5b90 chore(release): 0.2.5 2023-03-07 11:08:42 +06:00
towfiqi
b9d58a721d fix: Settings Update Toast was not showing up. 2023-03-05 19:39:14 +06:00
towfiqi
b83df5f3db feat: Adds current App version Number in Footer. 2023-03-05 19:23:07 +06:00
towfiqi
3b6d034d6f feat: Adds Keyword Scraping Interval Settings.
fixes #81, #76
2023-03-05 12:28:21 +06:00
towfiqi
5dd366b91e fix: Fixes Broken Image thumbnail loading issue. 2023-03-05 12:14:08 +06:00
towfiqi
5fc1779783 chore(release): 0.2.4 2023-02-15 23:33:37 +06:00
towfiqi
c5af94a146 feat: Keyword ranking pages can now be clicked. 2023-02-15 23:33:23 +06:00
towfiqi
c406588953 fix: Fixes broken Login on windows
fixes: 77, 80
2023-02-15 23:32:38 +06:00
towfiqi
3c48d130b6 fix: Fixes Node Cron memory leak issue. 2023-02-15 23:30:16 +06:00
towfiqi
99dbbd1dd9 chore(release): 0.2.3 2023-01-12 22:01:40 +06:00
towfiqi
acc0b39d80 fix: ScrapingAnt Mobile Keyword Scrape not working 2023-01-12 12:26:54 +06:00
towfiqi
a1108d240e fix: Mobile Keyword Scraping not working.
fixes #58
2023-01-12 12:25:42 +06:00
towfiqi
d9e0d0107a chore: keyword details title style issue fix. 2023-01-11 21:30:34 +06:00
towfiqi
9e9dad7631 feat: Ability to tag multiple keywords at once
fixes #54
2023-01-11 21:29:52 +06:00
towfiqi
8139e399c1 fix: Fixes Position and View Sort.
fixes #46
2023-01-11 13:15:09 +06:00
towfiqi
cb24696a1f fix: Fixes wrong CTR value for Search Console Data
fixes #48
2023-01-11 12:42:27 +06:00
towfiqi
b50733defc feat: Set USERNAME as well as USER variable
Mac Users weren't able to set USER variable for the project.
2023-01-11 12:29:29 +06:00
Towfiq
0c8b457eee chore(release): 0.2.2 2022-12-25 15:59:12 +06:00
Towfiq
123ad81dae fix: Fixes bug that prevents Saving API settings
fixes: issue #45
2022-12-25 15:58:44 +06:00
Towfiq
6c48b87e5e chore(release): 0.2.1 2022-12-24 20:28:07 +06:00
Towfiq
cf8b2c6913 refactor: Scraper 2022-12-24 20:27:55 +06:00
Towfiq
6d6e2f63d0 fixes: Broken Proxy Scraper
fixes #33
2022-12-24 08:29:44 +06:00
Towfiq
74c9603293 fixes: Wrong position change indicator when >100 2022-12-24 08:28:09 +06:00
Towfiq
be5ec96699 chore(release): 0.2.0 2022-12-21 19:23:34 +06:00
Towfiq
0fdb43c0a5 fix: Email Notification was not being sent. 2022-12-21 13:30:51 +06:00
Towfiq
e904afdd33 chore: Updated Readme. 2022-12-21 13:10:22 +06:00
Towfiq
ee32435d05 feat: Highlights tracked keywords in Discovery tab 2022-12-21 13:04:07 +06:00
Towfiq
6a1f1d4adf fix: Keyword Detail View's broken Search Result 2022-12-21 10:56:35 +06:00
Towfiq
671f89e492 fix: Ability to add SMTP without user/pass.
Some Services Doesn't require SMTP user/pass.
fixes #30
2022-12-21 08:31:00 +06:00
Towfiq
9b71f8400b feat: Adds better error logging for debugging issues 2022-12-21 08:08:12 +06:00
Towfiq
ca0dcfc492 chore: fixes typos 2022-12-21 07:47:04 +06:00
Towfiq
b0bfba4404 fix: Fixes Docker Deployment failure after the SC integration. 2022-12-20 21:55:57 +06:00
Towfiq
b740ef337b fix: hides Search Console Stats if its not connected 2022-12-20 21:54:52 +06:00
Towfiq
e3bd5b9c07 fix: Backend error on loading the domains page. 2022-12-20 21:53:42 +06:00
Towfiq
89824ece23 fix: Minor UI Issues. 2022-12-20 21:52:55 +06:00
Towfiq
c2b63280cb fix: backend error on addind new domain 2022-12-20 21:51:47 +06:00
Towfiq
1ebf7aef4b fixes: Codacy Issues. 2022-12-20 14:38:55 +06:00
Towfiq I
457f55353f Merge pull request #37 from towfiqi/search-console-integration
feat: Adds Domains Page & Integrates Google Search Console
2022-12-20 14:36:02 +06:00
Towfiq
49b4769528 feat: integrates Google Search Console. 2022-12-20 13:24:29 +06:00
Towfiq I
35aae7b7e2 Merge pull request #19 from ilyazub/integrate-serpapi
Integrate SerpApi into SerpBear
2022-12-09 22:02:04 +06:00
Illia
ad6a354cb9 feat: Integrate SerpApi
Changes:
* Integrate organic results data from SerpApi (https://serpapi.com).
* Require API Key for Serply and SerpApi on the Settings page.
2022-12-08 15:22:27 +02:00
Towfiq
6ac6c23168 chore(release): 0.1.7 2022-12-08 09:51:06 +06:00
Towfiq
f00006371d fix: Email notifcations now sent everyday at 3am 2022-12-08 09:50:43 +06:00
Towfiq
a6af9d3475 fix: Throws better error logs in cron for debugging 2022-12-08 09:47:53 +06:00
Towfiq
480767deb2 fix: shortens hours and minutes in notif emails 2022-12-08 09:47:02 +06:00
Towfiq
62e0fba311 chore: Removes Unecessay code 2022-12-08 09:46:22 +06:00
Towfiq
6fb862e2a3 chore: Removes duplicate license badge. 2022-12-06 18:04:16 +06:00
Towfiq
612cd8d6b3 chore: Adds codacy badge. 2022-12-06 18:02:20 +06:00
Towfiq
1dfc01673e style: Fixes more codacy nags 2022-12-06 18:01:25 +06:00
Towfiq
329ed24db8 style: Fixes Codacy issues. 2022-12-06 13:31:32 +06:00
Towfiq
dc8676a027 style: linter fixes. 2022-12-06 13:22:56 +06:00
Towfiq
a3362bc6ad style: fixes stylelint issues. 2022-12-06 13:08:58 +06:00
Towfiq
1e8b4b6bf6 chore: adds stylelint 2022-12-06 13:08:27 +06:00
Towfiq
02a2d5fec2 chore(release): 0.1.6 2022-12-05 22:58:54 +06:00
Towfiq
1711a10b1f Merge branch 'main' of https://github.com/towfiqi/serpbear 2022-12-05 22:52:13 +06:00
Towfiq I
a42b69bd2b Merge pull request #20 from DennisCiba/main
fix: Filter duplicates and empty lines on keyword creation
2022-12-05 22:50:26 +06:00
Dennis Ciba
7dfb4f99f8 Fix state mutation 2022-12-05 17:17:52 +01:00
Towfiq
d22992bf64 fix: Sort was buggy for keyword with >100 position
resolves: #23
2022-12-05 19:15:24 +06:00
Towfiq
b450540d95 fix(UI): Adds tooltip for Domain action icons. 2022-12-04 20:34:26 +06:00
Towfiq
8688f323a5 chore: removed unnecessary file. 2022-12-04 20:33:09 +06:00
Towfiq
e9d7730ae7 fix: invalid json markup 2022-12-04 20:32:44 +06:00
Towfiq
a59903551e fix: CSS Linter issues. 2022-12-04 20:32:30 +06:00
Dennis Ciba
2e85529183 Filter duplicates and empty lines on keyword creation 2022-12-04 12:24:31 +01:00
Towfiq
57182f17f6 chore(release): 0.1.5 2022-12-03 12:29:23 +06:00
Towfiq
d6da18fb01 fix: First search result items were being skipped.
resolvres: #13
2022-12-03 12:29:04 +06:00
Towfiq
dd6a801ffd fix: failed scrape messes up lastResult data in db 2022-12-03 12:28:21 +06:00
Towfiq
e1799fb2f3 feat: keyword not in first 100 now shows >100 2022-12-03 12:27:09 +06:00
Towfiq
c8ee418822 Merge branch 'main' of https://github.com/towfiqi/serpbear 2022-12-02 09:20:02 +06:00
Towfiq I
e7ab7d2db2 Merge pull request #9 from serply-inc/add-serply-io
added support for serplyio
2022-12-02 09:19:00 +06:00
Towfiq
efb565ba00 fix: domains with - were not loading the keywords.
resolves: #11
2022-12-02 07:36:09 +06:00
Towfiq
a11b0f223c fix: removes empty spaces when adding domain. 2022-12-02 07:34:52 +06:00
googio
e6136db742 merge in main branch into add-serply 2022-12-01 13:28:43 -05:00
Towfiq
d01b65db04 chore(release): 0.1.4 2022-12-01 23:41:11 +06:00
googio
f51380442b added support for serplyio 2022-12-01 12:24:28 -05:00
Towfiq
691055811c fix: Emails were sending serps of previous day.
Emails are now sent after 7 hours of daily SERP scrape CRON.
2022-12-01 22:18:42 +06:00
Towfiq
6d7cfec953 fix: scraper fails when result has domain w/o www
When user adds a domain with www and the google search result has the domain without www, the scraper fails.
2022-12-01 22:15:47 +06:00
Towfiq
8c8064f222 feat: Failed scrape now shows error details in UI. 2022-12-01 22:08:54 +06:00
Towfiq
3d1c690076 fix: Domains with www weren't loading keywords.
resolves: #8
2022-12-01 22:04:56 +06:00
Towfiq
1ed298f633 fix: Fixes Broken ScrapingRobot Integration. 2022-12-01 20:25:44 +06:00
Towfiq
38dc164514 fix: scraper fails if matched domain has www
resolves: #6 , #7
2022-12-01 19:05:56 +06:00
Towfiq
7446b7868a chore(release): 0.1.3 2022-12-01 12:01:30 +06:00
Towfiq
be4db26316 feat: Adds a search field in Country select field.
resolves: #2
2022-12-01 12:01:13 +06:00
Towfiq
9fa80cf609 fix: No hint on how to add multiple keywords.
resolves: #3
2022-12-01 12:00:27 +06:00
Towfiq
5acbe181ec fix: could not add 2 character domains.
resolves #1
2022-12-01 11:59:10 +06:00
Towfiq
a45237b230 fix: license location. 2022-11-30 23:37:13 +06:00
Towfiq
79b99a3896 chore: adds license. 2022-11-30 23:35:12 +06:00
Towfiq
4da725167e chore: Added Doc Link to readme. 2022-11-30 20:54:29 +06:00
Towfiq
ddd10909ad chore: Remove Unnecessary comments 2022-11-30 20:50:32 +06:00
125 changed files with 11519 additions and 12378 deletions

View File

@@ -12,6 +12,8 @@
"no-await-in-loop": "off",
"arrow-body-style":"off",
"max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}],
"import/no-extraneous-dependencies": "off",
"no-unused-vars": "off",
"import/extensions": [
"error",
"ignorePackages",

8
.sequelizerc Normal file
View File

@@ -0,0 +1,8 @@
const path = require('path');
module.exports = {
'config': path.resolve('database', 'config.js'),
'models-path': path.resolve('database', 'models'),
'seeders-path': path.resolve('database', 'seeders'),
'migrations-path': path.resolve('database', 'migrations')
};

22
.stylelintrc.json Normal file
View File

@@ -0,0 +1,22 @@
{
"extends": "stylelint-config-standard",
"rules": {
"indentation": 3,
"declaration-block-single-line-max-declarations": 2,
"selector-class-pattern":null,
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"variants",
"responsive",
"screen"
]
}
],
"declaration-block-trailing-semicolon": null,
"no-descending-specificity": null
}
}

View File

@@ -2,6 +2,256 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [1.0.2](https://github.com/towfiqi/serpbear/compare/v1.0.1...v1.0.2) (2024-02-15)
### Bug Fixes
* Resolves Incorrect Position issue. ([0e64b95](https://github.com/towfiqi/serpbear/commit/0e64b95cd5303525535ea84a77181281d7f5618e)), closes [#164](https://github.com/towfiqi/serpbear/issues/164)
### [1.0.1](https://github.com/towfiqi/serpbear/compare/v1.0.0...v1.0.1) (2024-02-13)
### Bug Fixes
* Resolves the app crash issue when there is no database. ([e5dd411](https://github.com/towfiqi/serpbear/commit/e5dd411aa9aef58ebb226f2b793a2632ab9069a7)), closes [#161](https://github.com/towfiqi/serpbear/issues/161) [#162](https://github.com/towfiqi/serpbear/issues/162)
## [1.0.0](https://github.com/towfiqi/serpbear/compare/v0.3.4...v1.0.0) (2024-02-09)
### Features
* Adds Serper.dev integration ([b4ad69b](https://github.com/towfiqi/serpbear/commit/b4ad69baaa0f865938f8b0eace6732a9e6b1b381)), closes [#138](https://github.com/towfiqi/serpbear/issues/138)
* Adds the ability for city level scraping for scapers that allow it. ([3719f21](https://github.com/towfiqi/serpbear/commit/3719f21d98d173219cef5656579fa0e5340ccdbf)), closes [#139](https://github.com/towfiqi/serpbear/issues/139) [#151](https://github.com/towfiqi/serpbear/issues/151)
* adds the ability to add url as a domain. ([3c2a1b8](https://github.com/towfiqi/serpbear/commit/3c2a1b8a5b8a2a4a2179a5031582f8202c2e494a)), closes [#53](https://github.com/towfiqi/serpbear/issues/53) [#90](https://github.com/towfiqi/serpbear/issues/90) [#119](https://github.com/towfiqi/serpbear/issues/119)
* Adds the Ability to set Search Console Property type via Domain Settings. ([b2e97b2](https://github.com/towfiqi/serpbear/commit/b2e97b2ebec380f0edf7ddc0640c2126eff006ac)), closes [#50](https://github.com/towfiqi/serpbear/issues/50)
* Adds the ability to setup Search Console through the UI. ([f04b10c](https://github.com/towfiqi/serpbear/commit/f04b10cf6b065e3023965112a60e0aa702212a4b)), closes [#59](https://github.com/towfiqi/serpbear/issues/59) [#146](https://github.com/towfiqi/serpbear/issues/146)
* Adds ValueSerp Integration. ([1041cb3](https://github.com/towfiqi/serpbear/commit/1041cb3c0bb69e9034696624e03433be28e83ac6)), closes [#105](https://github.com/towfiqi/serpbear/issues/105) [#106](https://github.com/towfiqi/serpbear/issues/106)
### Bug Fixes
* Resolves Keywords filter crashing issue. ([633ab2c](https://github.com/towfiqi/serpbear/commit/633ab2c467be5b7b86d4547ae0c59034e595a42d))
* Resolves missing Keyword Loading Spinner issue. ([dbf540c](https://github.com/towfiqi/serpbear/commit/dbf540cfdb16ddb02c9d26618e3680d34799f57f))
### [0.3.4](https://github.com/towfiqi/serpbear/compare/v0.3.3...v0.3.4) (2024-01-15)
### Features
* adds ability to add multiple domains at once. ([faa88c9](https://github.com/towfiqi/serpbear/commit/faa88c92542194f19b5cfe2b5cfd07d7d4f7ee46))
* Adds the ability to show/hide Keys & Passwords in Settings Panel ([c897a52](https://github.com/towfiqi/serpbear/commit/c897a525509baf5b9e8df18d82f5e87aec64f66e))
### Bug Fixes
* fixes local SC data not being removed on deleting domain. ([cca9f95](https://github.com/towfiqi/serpbear/commit/cca9f95358b2d3ea06edb33595cdbf616a175469))
* Resolves incorrect keyword average SC data values in Tracker ([e166b58](https://github.com/towfiqi/serpbear/commit/e166b588aa6c8db55d61b5bc13db66514575c745))
* resolves newly added Domain's Update time rendering issue ([df3a738](https://github.com/towfiqi/serpbear/commit/df3a738788fa957e7246a0feefe395a9eadd5baf))
### [0.3.3](https://github.com/towfiqi/serpbear/compare/v0.3.2...v0.3.3) (2023-11-12)
### Features
* Adds ability to visit pages from Insight tab ([60c68bd](https://github.com/towfiqi/serpbear/commit/60c68bd339db7aeed35aea035dd21691702ffee3))
* Domains now show their favicon. ([2339e31](https://github.com/towfiqi/serpbear/commit/2339e31af9e90bf918f5bcd4f23114f38cef0313)), closes [#130](https://github.com/towfiqi/serpbear/issues/130)
* Shows total keywords count in domains page ([fbd23ed](https://github.com/towfiqi/serpbear/commit/fbd23ede256062c72ec2f7e3983a0a02f0240725))
### Bug Fixes
* Resolves Website Thumbnail missing issue ([4a60271](https://github.com/towfiqi/serpbear/commit/4a60271cac1209dc02748c4d31943bb21c9ecaf2)), closes [#131](https://github.com/towfiqi/serpbear/issues/131)
### [0.3.2](https://github.com/towfiqi/serpbear/compare/v0.3.1...v0.3.2) (2023-11-09)
### Bug Fixes
* Resolves issue with adding long tld emails ([9b9b74a](https://github.com/towfiqi/serpbear/commit/9b9b74af4c249e27458d29ba052e96ab2db8b640)), closes [#127](https://github.com/towfiqi/serpbear/issues/127)
### [0.3.1](https://github.com/towfiqi/serpbear/compare/v0.3.0...v0.3.1) (2023-11-04)
### Bug Fixes
* Removes dev files from docker volumes ([454454a](https://github.com/towfiqi/serpbear/commit/454454a422bab4d37a2d43ad95868e293a97b88e))
* Updates vulnerable dependencies ([97dd0b1](https://github.com/towfiqi/serpbear/commit/97dd0b131be4cec73d07f35062334dd1881f0013))
## [0.3.0](https://github.com/towfiqi/serpbear/compare/v0.2.6...v0.3.0) (2023-11-03)
### Features
* Adds ability to disable/clear retry queue for failed keywords ([dc3c7a7](https://github.com/towfiqi/serpbear/commit/dc3c7a722b18248115969c51f2495ccf1c43926d))
* Adds ability to search w/o case sensitivity ([4748ffc](https://github.com/towfiqi/serpbear/commit/4748ffc382161c5d861b8d43e8eba466a031e2bc)), closes [#115](https://github.com/towfiqi/serpbear/issues/115)
* Displays the Best position of the keywords ([fc183d2](https://github.com/towfiqi/serpbear/commit/fc183d246d55e0eecf43c91f6da8a59192e8e771)), closes [#89](https://github.com/towfiqi/serpbear/issues/89)
* Refresh All feature now shows update real-time ([1d6b2be](https://github.com/towfiqi/serpbear/commit/1d6b2be95aa133b7998f5cf098f15aa32f5badd2))
* Remembers last selected coutry ([d3d336f](https://github.com/towfiqi/serpbear/commit/d3d336fa71cc789624b10f3cdd1a2b5983053e6f)), closes [#101](https://github.com/towfiqi/serpbear/issues/101)
### Bug Fixes
* Resolves missing keyword scrape spinner issue ([f57bca2](https://github.com/towfiqi/serpbear/commit/f57bca23daa3fe888af4c19a681dcec6b6100d83))
* Cron stopped on failing to parse failed queue ([8a949ce](https://github.com/towfiqi/serpbear/commit/8a949ce4c078ff377e91a95c4b86ef2b15dae88b)), closes [#116](https://github.com/towfiqi/serpbear/issues/116)
* Fixes import order error in some instances. ([be80ed7](https://github.com/towfiqi/serpbear/commit/be80ed7ef3dd0a315c5ad67d17e61a4797dc274c)), closes [#114](https://github.com/towfiqi/serpbear/issues/114)
* Fixes issue with adding hyphenated subdomains. ([c0470cf](https://github.com/towfiqi/serpbear/commit/c0470cfa9d0dac86317c886065b461cfe82ffb16))
* Fixes the weekly cron day issue. ([392122a](https://github.com/towfiqi/serpbear/commit/392122a7101683342830e900c6f0c39f9272bb34)), closes [#118](https://github.com/towfiqi/serpbear/issues/118)
* Fxies special character keyword scrape issue. ([9feff13](https://github.com/towfiqi/serpbear/commit/9feff13f18a4d72203dde694a147831f990b37fb)), closes [#113](https://github.com/towfiqi/serpbear/issues/113) [#122](https://github.com/towfiqi/serpbear/issues/122)
### [0.2.6](https://github.com/towfiqi/serpbear/compare/v0.2.5...v0.2.6) (2023-03-29)
### Features
* Add option to Delay Between scrapes. ([0a83924](https://github.com/towfiqi/serpbear/commit/0a83924ffe2243c52849c167c6c15d9688ff1dc7)), closes [#87](https://github.com/towfiqi/serpbear/issues/87)
* Integrates Space Serp. ([0538a8c](https://github.com/towfiqi/serpbear/commit/0538a8c01601d2f6365848580591a248528e67c7))
### Bug Fixes
* **components:** fix typo "Goolge" -> "Google" ([dce7c41](https://github.com/towfiqi/serpbear/commit/dce7c412e813fc845973f36ad1c9fa91df4a6611))
* Fixes first Keryword Error cut off issue. ([d950515](https://github.com/towfiqi/serpbear/commit/d9505158c439a924a1c86eb8243faf2a15bed43e))
* Fixes lags when tracking thousands of keywords ([9757fde](https://github.com/towfiqi/serpbear/commit/9757fde02ec83405546733381104c54ed6510681)), closes [#88](https://github.com/towfiqi/serpbear/issues/88)
### [0.2.5](https://github.com/towfiqi/serpbear/compare/v0.2.4...v0.2.5) (2023-03-07)
### Features
* Adds current App version Number in Footer. ([b83df5f](https://github.com/towfiqi/serpbear/commit/b83df5f3dbd64db657d31f0526438e7165e1b475))
* Adds Keyword Scraping Interval Settings. ([3b6d034](https://github.com/towfiqi/serpbear/commit/3b6d034d6f7da0b4259070220fffff44184dd680)), closes [#81](https://github.com/towfiqi/serpbear/issues/81) [#76](https://github.com/towfiqi/serpbear/issues/76)
### Bug Fixes
* Fixes Broken Image thumbnail loading issue. ([5dd366b](https://github.com/towfiqi/serpbear/commit/5dd366b91e2a94e658bf5250a8a0fa64c09e1c11))
* Settings Update Toast was not showing up. ([b9d58a7](https://github.com/towfiqi/serpbear/commit/b9d58a721df12f3f34220a3ae5da6897e23c83ec))
### [0.2.4](https://github.com/towfiqi/serpbear/compare/v0.2.3...v0.2.4) (2023-02-15)
### Features
* Keyword ranking pages can now be clicked. ([c5af94a](https://github.com/towfiqi/serpbear/commit/c5af94a1469713ed4092253d26953ee0ed28c25d))
### Bug Fixes
* Fixes broken Login on windows ([c406588](https://github.com/towfiqi/serpbear/commit/c406588953035e4177a64011c13eb0e3aedffe89))
* Fixes Node Cron memory leak issue. ([3c48d13](https://github.com/towfiqi/serpbear/commit/3c48d130b6f229a4ac27ec43ef1ea3a6640cecf6))
### [0.2.3](https://github.com/towfiqi/serpbear/compare/v0.2.2...v0.2.3) (2023-01-12)
### Features
* Ability to tag multiple keywords at once ([9e9dad7](https://github.com/towfiqi/serpbear/commit/9e9dad7631691b2a836fdd4c522b1f933b17e285)), closes [#54](https://github.com/towfiqi/serpbear/issues/54)
* Set USERNAME as well as USER variable ([b50733d](https://github.com/towfiqi/serpbear/commit/b50733defc2c06e0f92ca3e88fd1f74684eee9c0))
### Bug Fixes
* Fixes Position and View Sort. ([8139e39](https://github.com/towfiqi/serpbear/commit/8139e399c13ab8be767facef9a19c67dec06ed64)), closes [#46](https://github.com/towfiqi/serpbear/issues/46)
* Fixes wrong CTR value for Search Console Data ([cb24696](https://github.com/towfiqi/serpbear/commit/cb24696a1f47b02a11c68cd1c673ea8b1bacd144)), closes [#48](https://github.com/towfiqi/serpbear/issues/48)
* Mobile Keyword Scraping not working. ([a1108d2](https://github.com/towfiqi/serpbear/commit/a1108d240ea38ab0886ef3722b0c937ec5a45591)), closes [#58](https://github.com/towfiqi/serpbear/issues/58)
* ScrapingAnt Mobile Keyword Scrape not working ([acc0b39](https://github.com/towfiqi/serpbear/commit/acc0b39d80d4f9371967a0d425ed205c5d866eea))
### [0.2.2](https://github.com/towfiqi/serpbear/compare/v0.2.1...v0.2.2) (2022-12-25)
### Bug Fixes
* Fixes bug that prevents Saving API settings ([123ad81](https://github.com/towfiqi/serpbear/commit/123ad81dae10aa28848148d0f3da5cf1f7de7c57)), closes [#45](https://github.com/towfiqi/serpbear/issues/45)
### [0.2.1](https://github.com/towfiqi/serpbear/compare/v0.2.0...v0.2.1) (2022-12-24)
## [0.2.0](https://github.com/towfiqi/serpbear/compare/v0.1.7...v0.2.0) (2022-12-21)
### Features
* Adds better error logging for debugging issues ([9b71f84](https://github.com/towfiqi/serpbear/commit/9b71f8400bc17b75722b93cbe745543f6b30814a))
* Highlights tracked keywords in Discovery tab ([ee32435](https://github.com/towfiqi/serpbear/commit/ee32435d05c2a2ec6d446cd00e28058f07eb1ad4))
* Integrate SerpApi ([ad6a354](https://github.com/towfiqi/serpbear/commit/ad6a354cb93bc6584d71dd1216a8a03d8dba505b))
* integrates Google Search Console. ([49b4769](https://github.com/towfiqi/serpbear/commit/49b4769528d18e34c16386b73dfb662e7a9f45a0))
### Bug Fixes
* Ability to add SMTP without user/pass. ([671f89e](https://github.com/towfiqi/serpbear/commit/671f89e492b0f45d63ae7575c7d4970252c11296)), closes [#30](https://github.com/towfiqi/serpbear/issues/30)
* backend error on addind new domain ([c2b6328](https://github.com/towfiqi/serpbear/commit/c2b63280cb9d66b565dc51eb69ee960710ace895))
* Backend error on loading the domains page. ([e3bd5b9](https://github.com/towfiqi/serpbear/commit/e3bd5b9c0735939c6b06e9762a3ad041b8b05d6e))
* Email Notification was not being sent. ([0fdb43c](https://github.com/towfiqi/serpbear/commit/0fdb43c0a53460cd35daabc4703d26cb11db9601))
* Fixes Docker Deployment failure after the SC integration. ([b0bfba4](https://github.com/towfiqi/serpbear/commit/b0bfba440464f8fc7c31609c202e01416a41702d))
* hides Search Console Stats if its not connected ([b740ef3](https://github.com/towfiqi/serpbear/commit/b740ef337bbfb43f63528cac891d4cb254318dc7))
* Keyword Detail View's broken Search Result ([6a1f1d4](https://github.com/towfiqi/serpbear/commit/6a1f1d4adff89fc718c0f2ffe52a59ab15ad6c80))
* Minor UI Issues. ([89824ec](https://github.com/towfiqi/serpbear/commit/89824ece2349b510fa0b7d87b33cacd2c88efc95))
### [0.1.7](https://github.com/towfiqi/serpbear/compare/v0.1.6...v0.1.7) (2022-12-08)
### Bug Fixes
* Email notifcations now sent everyday at 3am ([f000063](https://github.com/towfiqi/serpbear/commit/f00006371d56c509eae00a72e164658c84fecd00))
* shortens hours and minutes in notif emails ([480767d](https://github.com/towfiqi/serpbear/commit/480767deb24072f9e250e4dd7bd3d710c4b6046c))
* Throws better error logs in cron for debugging ([a6af9d3](https://github.com/towfiqi/serpbear/commit/a6af9d347544f847e512c4ae55b14c640a897240))
### [0.1.6](https://github.com/towfiqi/serpbear/compare/v0.1.5...v0.1.6) (2022-12-05)
### Bug Fixes
* CSS Linter issues. ([a599035](https://github.com/towfiqi/serpbear/commit/a59903551eccb3f03f2bc026673bbf9fd0d4bc1e))
* invalid json markup ([e9d7730](https://github.com/towfiqi/serpbear/commit/e9d7730ae7ec647d333713248b271bae8693e77b))
* Sort was buggy for keyword with >100 position ([d22992b](https://github.com/towfiqi/serpbear/commit/d22992bf6489b11002faba60fa06b5c467867c8b)), closes [#23](https://github.com/towfiqi/serpbear/issues/23)
* **UI:** Adds tooltip for Domain action icons. ([b450540](https://github.com/towfiqi/serpbear/commit/b450540d9593d022c94708c9679b5bf7c0279c50))
### [0.1.5](https://github.com/towfiqi/serpbear/compare/v0.1.4...v0.1.5) (2022-12-03)
### Features
* keyword not in first 100 now shows >100 ([e1799fb](https://github.com/towfiqi/serpbear/commit/e1799fb2f35ab8c0f65eb90e66dcda10b8cb6f16))
### Bug Fixes
* domains with - were not loading the keywords. ([efb565b](https://github.com/towfiqi/serpbear/commit/efb565ba0086d1b3e69ea71456a892ca254856f7)), closes [#11](https://github.com/towfiqi/serpbear/issues/11)
* failed scrape messes up lastResult data in db ([dd6a801](https://github.com/towfiqi/serpbear/commit/dd6a801ffda3eacda957dd20d2c97fb6197fbdc2))
* First search result items were being skipped. ([d6da18f](https://github.com/towfiqi/serpbear/commit/d6da18fb0135e23dd869d1fb500e12ee2e782bfa)), closes [#13](https://github.com/towfiqi/serpbear/issues/13)
* removes empty spaces when adding domain. ([a11b0f2](https://github.com/towfiqi/serpbear/commit/a11b0f223c0647537ab23564df1d2f0b29eef4ae))
### [0.1.4](https://github.com/towfiqi/serpbear/compare/v0.1.3...v0.1.4) (2022-12-01)
### Features
* Failed scrape now shows error details in UI. ([8c8064f](https://github.com/towfiqi/serpbear/commit/8c8064f222ea8177b26b6dd28866d1f421faca39))
### Bug Fixes
* Domains with www weren't loading keywords. ([3d1c690](https://github.com/towfiqi/serpbear/commit/3d1c690076a03598f0ac3f3663d905479d945897)), closes [#8](https://github.com/towfiqi/serpbear/issues/8)
* Emails were sending serps of previous day. ([6910558](https://github.com/towfiqi/serpbear/commit/691055811c2ae70ce1b878346300048c1e23f2eb))
* Fixes Broken ScrapingRobot Integration. ([1ed298f](https://github.com/towfiqi/serpbear/commit/1ed298f633a9ae5b402b431f1e50b35ffd44a6dc))
* scraper fails if matched domain has www ([38dc164](https://github.com/towfiqi/serpbear/commit/38dc164514b066b2007f2f3b2ae68005621963cc)), closes [#6](https://github.com/towfiqi/serpbear/issues/6) [#7](https://github.com/towfiqi/serpbear/issues/7)
* scraper fails when result has domain w/o www ([6d7cfec](https://github.com/towfiqi/serpbear/commit/6d7cfec95304fa7a61beaab07f7cd6af215255c3))
### [0.1.3](https://github.com/towfiqi/serpbear/compare/v0.1.2...v0.1.3) (2022-12-01)
### Features
* Adds a search field in Country select field. ([be4db26](https://github.com/towfiqi/serpbear/commit/be4db26316e7522f567a4ce6fc27e0a0f73f89f2)), closes [#2](https://github.com/towfiqi/serpbear/issues/2)
### Bug Fixes
* could not add 2 character domains. ([5acbe18](https://github.com/towfiqi/serpbear/commit/5acbe181ec978b50b588af378d17fb3070c241d1)), closes [#1](https://github.com/towfiqi/serpbear/issues/1)
* license location. ([a45237b](https://github.com/towfiqi/serpbear/commit/a45237b230a9830461cf7fccd4c717235112713b))
* No hint on how to add multiple keywords. ([9fa80cf](https://github.com/towfiqi/serpbear/commit/9fa80cf6098854d2a5bd5a8202aa0fd6886d1ba0)), closes [#3](https://github.com/towfiqi/serpbear/issues/3)
### 0.1.2 (2022-11-30)

View File

@@ -10,6 +10,9 @@ COPY . .
FROM node:lts-alpine AS builder
WORKDIR /app
COPY --from=deps /app ./
RUN rm -rf /app/data
RUN rm -rf /app/__tests__
RUN rm -rf /app/__mocks__
RUN npm run build
@@ -27,13 +30,17 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# setup the cron
COPY --from=builder --chown=nextjs:nodejs /app/cron.js ./
COPY --from=builder --chown=nextjs:nodejs /app/email ./email
COPY --from=builder --chown=nextjs:nodejs /app/database ./database
COPY --from=builder --chown=nextjs:nodejs /app/.sequelizerc ./.sequelizerc
COPY --from=builder --chown=nextjs:nodejs /app/entrypoint.sh ./entrypoint.sh
RUN rm package.json
RUN npm init -y
RUN npm i cryptr dotenv node-cron
RUN npm i cryptr dotenv croner @googleapis/searchconsole sequelize-cli
RUN npm i -g concurrently
USER nextjs
EXPOSE 3000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["concurrently","node server.js", "node cron.js"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Amruth Pillai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,30 +1,35 @@
![SerpBear](https://i.imgur.com/0S2zIH3.png)
# SerpBear
![Codacy Badge](https://app.codacy.com/project/badge/Grade/7e7a0030c3f84c6fb56a3ce6273fbc1d) ![GitHub](https://img.shields.io/github/license/towfiqi/serpbear) ![GitHub package.json version](https://img.shields.io/github/package-json/v/towfiqi/serpbear) ![Docker Pulls](https://img.shields.io/docker/pulls/towfiqi/serpbear)
#### [Documentation](https://docs.serpbear.com/) | [Changelog](https://github.com/towfiqi/serpbear/blob/main/CHANGELOG.md) | [Docker Image](https://hub.docker.com/r/towfiqi/serpbear)
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.
![Easy to Use Search Engine Rank Tracker](https://i.imgur.com/bRzpmCK.gif)
![Easy to Use Search Engine Rank Tracker](https://erevanto.sirv.com/Images/serpbear/serpbear_readme_v2.gif)
**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.
#### 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.
- **Google Search Console Integration:** Get the actual visit count, impressions & more for Each keyword.
- **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.
**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.
#### How it Works
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SearchApi, SerpApi or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword. Also, When you connect your Googel Search Console account, the app shows actual search visits for each tracked keywords. You can also discover new keywords, and find the most performing keywords, countries, pages.
**Getting Started**
- **Step 1:** Deploy & Run the App.
- **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.
#### 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.
**Compare SerpBear with other SERP tracking services:**
#### Compare SerpBear with other SERP tracking services
|Service | Cost | SERP Lookup | API |
|--|--|--|--|
@@ -33,9 +38,15 @@ The App uses third party website scrapers like ScrapingAnt, ScrapingRobot or You
| SerpWatch.io | $29/mo | 7500/mo | Yes |
| Serpwatcher.com | $49/mo| 3000/mo | No |
| whatsmyserp.com | $49/mo| 30,000/mo| No |
| serply.io | $49/mo | 5000/mo | Yes |
| serpapi.com | From $50/mo** | From 5,000/mo** | Yes |
| spaceserp.com | $59/lifetime | 15,000/mo | Yes |
| SearchApi.io | From $40/mo | From 10,000/mo | Yes |
| valueserp.com | Pay As You Go | $2.50/1000 req | No |
(*) Free upto a limit. If you are using ScrapingAnt you can lookup 10,000 times per month for free.
(**) Free up to 100 per month. Paid from 5,000 to 10,000,000+ per month.
**Stack**
- Next.js for Frontend & Backend.
- Sqlite for Database.
- Next.js for Frontend & Backend.
- Sqlite for Database.

View File

@@ -2,10 +2,11 @@ export const dummyDomain = {
ID: 1,
domain: 'compressimage.io',
slug: 'compressimage-io',
keywordCount: 0,
keywordCount: 10,
avgPosition: 24,
lastUpdated: '2022-11-11T10:00:32.243',
added: '2022-11-11T10:00:32.244',
tags: [],
tags: '',
notification: true,
notification_interval: 'daily',
notification_emails: '',
@@ -33,7 +34,7 @@ export const dummyKeywords = [
lastResult: [],
sticky: false,
updating: false,
lastUpdateError: 'false',
lastUpdateError: false as false,
},
{
ID: 2,
@@ -56,6 +57,23 @@ export const dummyKeywords = [
lastResult: [],
sticky: false,
updating: false,
lastUpdateError: 'false',
lastUpdateError: false as false,
},
];
export const dummySettings = {
scaping_api: '',
scraper_type: 'none',
notification_interval: 'never',
notification_email: '',
notification_email_from: '',
smtp_server: '',
smtp_port: '',
smtp_username: '',
smtp_password: '',
scrape_retry: false,
search_console_integrated: false,
screenshot_key: '',
available_scapers: [],
failed_queue: [],
};

View File

@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import Modal from '../../components/common/Modal';
// jest.mock('React', () => ({
// ...jest.requireActual('React'),
// useEffect: jest.fn(),
// }));
// const mockedUseEffect = useEffect as jest.Mock<any>;
// jest.mock('../../components/common/Icon', () => () => <div data-testid="icon"/>);
describe('Modal Component', () => {
it('Renders without crashing', async () => {
render(<Modal closeModal={() => console.log() }><div></div></Modal>);
// mockedUseEffect.mock.calls[0]();
expect(document.querySelector('.modal')).toBeInTheDocument();
});
// it('Sets up the escapae key shortcut', async () => {
// render(<Modal closeModal={() => console.log() }><div></div></Modal>);
// expect(mockedUseEffect).toBeCalled();
// });
it('Displays the Given Content', async () => {
render(<Modal closeModal={() => console.log() }>
<div>
<h1>Hello Modal!!</h1>
</div>
</Modal>);
expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument();
});
it('Renders Modal Title', async () => {
render(<Modal closeModal={() => console.log() } title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument();
});
});

View File

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

View File

@@ -1,21 +0,0 @@
import { waitFor } from '@testing-library/react';
// import { useFetchDomains } from '../../services/domains';
// import { createWrapper } from '../utils';
jest.mock('next/router', () => ({
useRouter: () => ({
query: { slug: 'compressimage-io' },
push: (link:string) => { console.log('Pushed', link); },
}),
}));
describe('DomainHooks', () => {
it('useFetchDomains should fetch the Domains', async () => {
// const { result } = renderHook(() => useFetchDomains(), { wrapper: createWrapper() });
const result = { current: { isSuccess: false, data: '' } };
await waitFor(() => {
console.log('result.current: ', result.current.data);
return expect(result.current.isSuccess).toBe(true);
});
});
});

View File

@@ -0,0 +1,35 @@
import { fireEvent, render } from '@testing-library/react';
import DomainItem from '../../components/domains/DomainItem';
import { dummyDomain } from '../../__mocks__/data';
const updateThumbMock = jest.fn();
const domainItemProps = {
domain: dummyDomain,
selected: false,
isConsoleIntegrated: false,
thumb: '',
updateThumb: updateThumbMock,
};
describe('DomainItem Component', () => {
it('renders without crashing', async () => {
const { container } = render(<DomainItem {...domainItemProps} />);
expect(container.querySelector('.domItem')).toBeInTheDocument();
});
it('renders keywords count', async () => {
const { container } = render(<DomainItem {...domainItemProps} />);
const domStatskeywords = container.querySelector('.dom_stats div:nth-child(1)');
expect(domStatskeywords?.textContent).toBe('Keywords10');
});
it('renders avg position', async () => {
const { container } = render(<DomainItem {...domainItemProps} />);
const domStatsAvg = container.querySelector('.dom_stats div:nth-child(2)');
expect(domStatsAvg?.textContent).toBe('Avg position24');
});
it('updates domain thumbnail on relevant button click', async () => {
const { container } = render(<DomainItem {...domainItemProps} />);
const reloadThumbbBtn = container.querySelector('.domain_thumb button');
if (reloadThumbbBtn) fireEvent.click(reloadThumbbBtn);
expect(updateThumbMock).toHaveBeenCalledWith(dummyDomain.domain);
});
});

View File

@@ -1,8 +1,14 @@
import { fireEvent, render, screen } from '@testing-library/react';
import Keyword from '../../components/keywords/Keyword';
import { dummyKeywords } from '../data';
import { dummyKeywords } from '../../__mocks__/data';
const keywordFunctions = {
const keywordProps = {
keywordData: dummyKeywords[0],
selected: false,
index: 0,
showSCData: false,
scDataType: '',
style: {},
refreshkeyword: jest.fn(),
favoriteKeyword: jest.fn(),
removeKeyword: jest.fn(),
@@ -10,35 +16,37 @@ const keywordFunctions = {
manageTags: jest.fn(),
showKeywordDetails: jest.fn(),
};
jest.mock('react-chartjs-2', () => ({
Line: () => null,
}));
describe('Keyword Component', () => {
it('renders without crashing', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
render(<Keyword {...keywordProps} />);
expect(await screen.findByText('compress image')).toBeInTheDocument();
});
it('Should Render Position Correctly', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
render(<Keyword {...keywordProps} />);
const positionElement = document.querySelector('.keyword_position');
expect(positionElement?.childNodes[0].nodeValue).toBe('19');
});
it('Should Display Position Change arrow', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
render(<Keyword {...keywordProps} />);
const positionElement = document.querySelector('.keyword_position i');
expect(positionElement?.textContent).toBe('▲ 1');
});
it('Should Display the SERP Page URL', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
render(<Keyword {...keywordProps} />);
const positionElement = document.querySelector('.keyword_url');
expect(positionElement?.textContent).toBe('/');
});
it('Should Display the Keyword Options on dots Click', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
const button = document.querySelector('.keyword .keyword_dots');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
const { container } = render(<Keyword {...keywordProps} />);
const button = container.querySelector('.keyword_dots');
if (button) fireEvent.click(button);
expect(document.querySelector('.keyword_options')).toBeVisible();
});
// it('Should favorite Keywords', async () => {
// render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
// render(<Keyword {...keywordProps} />);
// const button = document.querySelector('.keyword .keyword_dots');
// if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
// const option = document.querySelector('.keyword .keyword_options li:nth-child(1) a');

View File

@@ -0,0 +1,28 @@
import { fireEvent, render, screen } from '@testing-library/react';
import Modal from '../../components/common/Modal';
const closeModalMock = jest.fn();
describe('Modal Component', () => {
it('Renders without crashing', async () => {
render(<Modal closeModal={closeModalMock }><div></div></Modal>);
expect(document.querySelector('.modal')).toBeInTheDocument();
});
it('Displays the Given Content', async () => {
render(<Modal closeModal={closeModalMock}>
<div>
<h1>Hello Modal!!</h1>
</div>
</Modal>);
expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument();
});
it('Renders Modal Title', async () => {
render(<Modal closeModal={closeModalMock} title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument();
});
it('Closes the modal on close button click', async () => {
const { container } = render(<Modal closeModal={closeModalMock} title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
const closeBtn = container.querySelector('.modal-close');
if (closeBtn) fireEvent.click(closeBtn);
expect(closeModalMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,23 @@
import { fireEvent, render, screen } from '@testing-library/react';
import Sidebar from '../../components/common/Sidebar';
import { dummyDomain } from '../../__mocks__/data';
const addDomainMock = jest.fn();
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
describe('Sidebar Component', () => {
it('renders without crashing', async () => {
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
expect(screen.getByText('SerpBear')).toBeInTheDocument();
});
it('renders domain list', async () => {
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
expect(screen.getByText('compressimage.io')).toBeInTheDocument();
});
it('calls showAddModal on Add Domain button click', async () => {
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
const addDomainBtn = screen.getByTestId('add_domain');
fireEvent.click(addDomainBtn);
expect(addDomainMock).toHaveBeenCalledWith(true);
});
});

View File

@@ -1,9 +1,15 @@
import { render, screen } from '@testing-library/react';
import TopBar from '../../components/common/TopBar';
jest.mock('next/router', () => ({
useRouter: () => ({
pathname: '/',
}),
}));
describe('TopBar Component', () => {
it('renders without crashing', async () => {
render(<TopBar showSettings={() => console.log() } />);
render(<TopBar showSettings={jest.fn} showAddModal={jest.fn} />);
expect(
await screen.findByText('SerpBear'),
).toBeInTheDocument();

View File

@@ -0,0 +1,27 @@
import { renderHook, waitFor } from '@testing-library/react';
import mockRouter from 'next-router-mock';
import { useFetchDomains } from '../../services/domains';
import { createWrapper } from '../../__mocks__/utils';
import { dummyDomain } from '../../__mocks__/data';
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
fetchMock.mockIf(`${window.location.origin}/api/domains`, async () => {
return new Promise((resolve) => {
resolve({
body: JSON.stringify({ domains: [dummyDomain] }),
status: 200,
});
});
});
describe('DomainHooks', () => {
it('useFetchDomains should fetch the Domains', async () => {
const { result } = renderHook(() => useFetchDomains(mockRouter), { wrapper: createWrapper() });
// const result = { current: { isSuccess: false, data: '' } };
await waitFor(() => {
return expect(result.current.isLoading).toBe(false);
});
});
});

View File

@@ -1,17 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import SingleDomain from '../../pages/domain/[slug]';
import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains';
import { useAddKeywords, useDeleteKeywords, useFavKeywords, useFetchKeywords, useRefreshKeywords } from '../../services/keywords';
import { dummyDomain, dummyKeywords } from '../data';
import { useAddKeywords, useDeleteKeywords,
useFavKeywords, useFetchKeywords, useRefreshKeywords, useFetchSingleKeyword } from '../../services/keywords';
import { dummyDomain, dummyKeywords, dummySettings } from '../../__mocks__/data';
import { useFetchSettings } from '../../services/settings';
jest.mock('../../services/domains');
jest.mock('../../services/keywords');
jest.mock('../../services/settings');
jest.mock('next/router', () => ({
useRouter: () => ({
query: { slug: dummyDomain.slug },
}),
}));
jest.mock('react-chartjs-2', () => ({
Line: () => null,
}));
const useFetchDomainsFunc = useFetchDomains as jest.Mock<any>;
const useFetchKeywordsFunc = useFetchKeywords as jest.Mock<any>;
const useDeleteKeywordsFunc = useDeleteKeywords as jest.Mock<any>;
@@ -21,11 +30,17 @@ const useAddDomainFunc = useAddDomain as jest.Mock<any>;
const useAddKeywordsFunc = useAddKeywords as jest.Mock<any>;
const useUpdateDomainFunc = useUpdateDomain as jest.Mock<any>;
const useDeleteDomainFunc = useDeleteDomain as jest.Mock<any>;
const useFetchSettingsFunc = useFetchSettings as jest.Mock<any>;
const useFetchSingleKeywordFunc = useFetchSingleKeyword as jest.Mock<any>;
describe('SingleDomain Page', () => {
const queryClient = new QueryClient();
beforeEach(() => {
useFetchSettingsFunc.mockImplementation(() => ({ data: { settings: dummySettings }, isLoading: false }));
useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false }));
useFetchKeywordsFunc.mockImplementation(() => ({ keywordsData: { keywords: dummyKeywords }, keywordsLoading: false }));
const fetchPayload = { history: dummyKeywords[0].history || [], searchResult: dummyKeywords[0].lastResult || [] };
useFetchSingleKeywordFunc.mockImplementation(() => ({ data: fetchPayload, isLoading: false }));
useDeleteKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useFavKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useRefreshKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
@@ -38,158 +53,154 @@ describe('SingleDomain Page', () => {
jest.clearAllMocks();
});
it('Render without crashing.', async () => {
const { getByTestId } = render(<SingleDomain />);
// screen.debug(undefined, Infinity);
expect(getByTestId('domain-header')).toBeInTheDocument();
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
expect(screen.getByTestId('domain-header')).toBeInTheDocument();
});
it('Should Call the useFetchDomains hook on render.', async () => {
render(<SingleDomain />);
// screen.debug(undefined, Infinity);
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
expect(useFetchDomains).toHaveBeenCalled();
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
});
it('Should Render the Keywords', async () => {
render(<SingleDomain />);
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(2);
});
it('Should Display the Keywords Details Sidebar on Keyword Click.', async () => {
render(<SingleDomain />);
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const keywords = document.querySelectorAll('.keyword');
const firstKeyword = keywords && keywords[0].querySelector('a');
if (firstKeyword) fireEvent(firstKeyword, new MouseEvent('click', { bubbles: true }));
if (firstKeyword) fireEvent.click(firstKeyword);
expect(useFetchSingleKeyword).toHaveBeenCalled();
expect(screen.getByTestId('keywordDetails')).toBeVisible();
});
it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=add_domain]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('add_domain');
if (button) fireEvent.click(button);
expect(screen.getByTestId('adddomain_modal')).toBeVisible();
});
it('Should Display the AddKeywords Modal on Add Keyword Button Click.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=add_keyword]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('add_keyword');
if (button) fireEvent.click(button);
expect(screen.getByTestId('addkeywords_modal')).toBeVisible();
});
it('Should display the Domain Settings on Settings Button click.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=show_domain_settings]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('show_domain_settings');
if (button) fireEvent.click(button);
expect(screen.getByTestId('domain_settings')).toBeVisible();
});
it('Device Tab change should be functioning.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=mobile_tab]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('mobile_tab');
if (button) fireEvent.click(button);
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(0);
});
it('Search Filter should function properly', async () => {
render(<SingleDomain />);
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const inputNode = screen.getByTestId('filter_input');
fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event
if (inputNode) fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event
expect(inputNode.getAttribute('value')).toBe('compressor');
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(1);
});
it('Country Filter should function properly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=filter_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('filter_button');
if (button) fireEvent.click(button);
expect(document.querySelector('.country_filter')).toBeVisible();
const countrySelect = document.querySelector('.country_filter .selected');
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
if (countrySelect) fireEvent.click(countrySelect);
expect(document.querySelector('.country_filter .select_list')).toBeVisible();
const firstCountry = document.querySelector('.country_filter .select_list ul li:nth-child(1)');
if (firstCountry) fireEvent(firstCountry, new MouseEvent('click', { bubbles: true }));
if (firstCountry) fireEvent.click(firstCountry);
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(0);
});
// Tags Filter should function properly
it('Tags Filter should Render & Function properly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=filter_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('filter_button');
if (button) fireEvent.click(button);
expect(document.querySelector('.tags_filter')).toBeVisible();
const countrySelect = document.querySelector('.tags_filter .selected');
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
if (countrySelect) fireEvent.click(countrySelect);
expect(document.querySelector('.tags_filter .select_list')).toBeVisible();
expect(document.querySelectorAll('.tags_filter .select_list ul li').length).toBe(1);
const firstTag = document.querySelector('.tags_filter .select_list ul li:nth-child(1)');
if (firstTag) fireEvent(firstTag, new MouseEvent('click', { bubbles: true }));
if (firstTag) fireEvent.click(firstTag);
expect(document.querySelectorAll('.keyword').length).toBe(1);
});
it('Sort Options Should be visible Sort Button on Click.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=sort_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('sort_button');
if (button) fireEvent.click(button);
expect(document.querySelector('.sort_options')).toBeVisible();
});
it('Sort: Position should sort keywords accordingly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=sort_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('sort_button');
if (button) fireEvent.click(button);
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(1)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
if (topPosSortOption) fireEvent.click(topPosSortOption);
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
if (button) fireEvent.click(button);
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(2)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor');
});
it('Sort: Date Added should sort keywords accordingly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=sort_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('sort_button');
if (button) fireEvent.click(button);
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(3)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
if (topPosSortOption) fireEvent.click(topPosSortOption);
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
if (button) fireEvent.click(button);
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(4)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor');
});
it('Sort: Alphabetical should sort keywords accordingly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=sort_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('sort_button');
if (button) fireEvent.click(button);
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(5)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
if (topPosSortOption) fireEvent.click(topPosSortOption);
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
if (button) fireEvent.click(button);
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(6)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor');
});

View File

@@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import * as ReactQuery from 'react-query';
import { dummyDomain } from '../../__mocks__/data';
import Domains from '../../pages/domains';
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
jest.spyOn(ReactQuery, 'useQuery').mockImplementation(jest.fn().mockReturnValue(
{ data: { domains: [dummyDomain] }, isLoading: false, isSuccess: true },
));
fetchMock.mockIf(`${window.location.origin}/api/domains`, async () => {
return new Promise((resolve) => {
resolve({
body: JSON.stringify({ domains: [dummyDomain] }),
status: 200,
});
});
});
describe('Domains Page', () => {
const queryClient = new QueryClient();
it('Renders without crashing', async () => {
render(
<QueryClientProvider client={queryClient}>
<Domains />
</QueryClientProvider>,
);
expect(screen.getByTestId('domains')).toBeInTheDocument();
});
it('Renders the Domain Component', async () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<Domains />
</QueryClientProvider>,
);
expect(container.querySelector('.domItem')).toBeInTheDocument();
});
it('Should Display Add Domain Modal on relveant Button Click.', async () => {
render(<QueryClientProvider client={queryClient}><Domains /></QueryClientProvider>);
const button = screen.getByTestId('addDomainButton');
if (button) fireEvent.click(button);
expect(screen.getByTestId('adddomain_modal')).toBeVisible();
});
it('Should Display the version number in Footer.', async () => {
render(<QueryClientProvider client={queryClient}><Domains /></QueryClientProvider>);
expect(screen.getByText('SerpBear v0.0.0')).toBeVisible();
});
});

View File

@@ -2,21 +2,16 @@ import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import Home from '../../pages/index';
const routerPush = jest.fn();
jest.mock('next/router', () => ({
useRouter: () => ({
push: routerPush,
}),
}));
describe('Home Page', () => {
const queryClient = new QueryClient();
it('Renders without crashing', async () => {
// const dummyDomain = {
// ID: 1,
// domain: 'compressimage.io',
// slug: 'compressimage-io',
// keywordCount: 0,
// lastUpdated: '2022-11-11T10:00:32.243',
// added: '2022-11-11T10:00:32.244',
// tags: [],
// notification: true,
// notification_interval: 'daily',
// notification_emails: '',
// };
render(
<QueryClientProvider client={queryClient}>
<Home />
@@ -26,12 +21,12 @@ describe('Home Page', () => {
expect(await screen.findByRole('main')).toBeInTheDocument();
expect(screen.queryByText('Add Domain')).not.toBeInTheDocument();
});
it('Should Display the Add Domain Modal when there are no Domains.', async () => {
it('Should redirect to /domains route.', async () => {
render(
<QueryClientProvider client={queryClient}>
<Home />
</QueryClientProvider>,
);
expect(await screen.findByText('Add Domain')).toBeInTheDocument();
expect(routerPush).toHaveBeenCalledWith('/domains');
});
});

View File

@@ -6,19 +6,20 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, T
type ChartProps ={
labels: string[],
sreies: number[]
sreies: number[],
reverse? : boolean,
}
const Chart = ({ labels, sreies }:ChartProps) => {
const Chart = ({ labels, sreies, reverse = true }:ChartProps) => {
const options = {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
scales: {
y: {
reverse: true,
reverse,
min: 1,
max: 100,
max: reverse ? 100 : undefined,
},
},
plugins: {

View File

@@ -35,7 +35,7 @@ const ChartSlim = ({ labels, sreies }:ChartProps) => {
},
};
return <div className='w-[120px] h-[30px] rounded border border-gray-200'>
return <div className='w-[100px] h-[30px] rounded border border-gray-200'>
<Line
datasetIdKey='XXX'
options={options}

View File

@@ -186,6 +186,94 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
<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>
}
{type === 'idea'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<g fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h1m8-9v1m8 8h1M5.6 5.6l.7.7m12.1-.7l-.7.7"/>
<path d="M9 16a5 5 0 1 1 6 0a3.5 3.5 0 0 0-1 3a2 2 0 0 1-4 0a3.5 3.5 0 0 0-1-3"/>
<path d="M9.7 17h4.6"/>
</g>
</svg>
}
{type === 'tracking'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill={color} d="M21 7a.78.78 0 0 0 0-.21a.64.64 0 0 0-.05-.17a1.1 1.1 0 0 0-.09-.14a.75.75 0 0 0-.14-.17l-.12-.07a.69.69 0 0 0-.19-.1h-.2A.7.7 0 0 0 20 6h-5a1 1 0 0 0 0 2h2.83l-4 4.71l-4.32-2.57a1 1 0 0 0-1.28.22l-5 6a1 1 0 0 0 .13 1.41A1 1 0 0 0 4 18a1 1 0 0 0 .77-.36l4.45-5.34l4.27 2.56a1 1 0 0 0 1.27-.21L19 9.7V12a1 1 0 0 0 2 0V7z"/>
</svg>
}
{type === 'google'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 256 262">
<path d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" fill="#4285F4"/>
<path d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" fill="#34A853"/>
<path d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" fill="#FBBC05"/>
<path d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" fill="#EB4335" />
</svg>
}
{type === 'cursor'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill="none" stroke={color} strokeWidth="2" d="M6 3l12 11l-5 1l3 5.5l-3 1.5l-3-6l-4 3z"/>
</svg>
}
{type === 'eye'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<g fill="none">
<path d="M21.257 10.962c.474.62.474 1.457 0 2.076C19.764 14.987 16.182 19 12 19c-4.182 0-7.764-4.013-9.257-5.962a1.692 1.692 0 0 1 0-2.076C4.236 9.013 7.818 5 12 5c4.182 0 7.764 4.013 9.257 5.962z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="12" cy="12" r="3" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</g>
</svg>
}
{type === 'eye-closed'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<g fill="none" stroke={color} strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M6.873 17.129c-1.845-1.31-3.305-3.014-4.13-4.09a1.693 1.693 0 0 1 0-2.077C4.236 9.013 7.818 5 12 5c1.876 0 3.63.807 5.13 1.874"/>
<path d="M14.13 9.887a3 3 0 1 0-4.243 4.242M4 20L20 4M10 18.704A7.124 7.124 0 0 0 12 19c4.182 0 7.764-4.013 9.257-5.962a1.694 1.694 0 0 0-.001-2.078A22.939 22.939 0 0 0 19.57 9"/>
</g>
</svg>
}
{type === 'target'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M19.938 13A8.004 8.004 0 0 1 13 19.938V22h-2v-2.062A8.004 8.004 0 0 1 4.062 13H2v-2h2.062A8.004 8.004 0 0 1 11 4.062V2h2v2.062A8.004 8.004 0 0 1 19.938 11H22v2h-2.062zM12 18a6 6 0 1 0 0-12a6 6 0 0 0 0 12zm0-3a3 3 0 1 0 0-6a3 3 0 0 0 0 6z" fill={color} fillRule="nonzero"/>
</svg>
}
{type === 'help'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M12 4c4.411 0 8 3.589 8 8s-3.589 8-8 8s-8-3.589-8-8s3.589-8 8-8m0-2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2zm4 8a4 4 0 0 0-8 0h2c0-1.103.897-2 2-2s2 .897 2 2s-.897 2-2 2a1 1 0 0 0-1 1v2h2v-1.141A3.991 3.991 0 0 0 16 10zm-3 6h-2v2h2v-2z" fill={color} />
</svg>
}
{type === 'date'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M22 2.25h-3.25V.75a.75.75 0 0 0-1.5-.001V2.25h-4.5V.75a.75.75 0 0 0-1.5-.001V2.25h-4.5V.75a.75.75 0 0 0-1.5-.001V2.25H2a2 2 0 0 0-2 1.999v17.75a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2V4.249a2 2 0 0 0-2-1.999zM22.5 22a.5.5 0 0 1-.499.5H2a.5.5 0 0 1-.5-.5V4.25a.5.5 0 0 1 .5-.499h3.25v1.5a.75.75 0 0 0 1.5.001V3.751h4.5v1.5a.75.75 0 0 0 1.5.001V3.751h4.5v1.5a.75.75 0 0 0 1.5.001V3.751H22a.5.5 0 0 1 .499.499z" fill={color} />
<path d="M5.25 9h3v2.25h-3z" fill={color} />
<path d="M5.25 12.75h3V15h-3z" fill={color} />
<path d="M5.25 16.5h3v2.25h-3z" fill={color} />
<path d="M10.5 16.5h3v2.25h-3z" fill={color} />
<path d="M10.5 12.75h3V15h-3z" fill={color} />
<path d="M10.5 9h3v2.25h-3z" fill={color} />
<path d="M15.75 16.5h3v2.25h-3z" fill={color} />
<path d="M15.75 12.75h3V15h-3z" fill={color} />
<path d="M15.75 9h3v2.25h-3z" fill={color} />
</svg>
}
{type === 'email'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill={color} d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z" />
</svg>
}
{type === 'scraper'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 16 16">
<path fill={color} d="M1 3.5A2.5 2.5 0 0 1 3.5 1h7A2.5 2.5 0 0 1 13 3.5v1.53a4.538 4.538 0 0 0-1-.004V5H2v5.5A1.5 1.5 0 0 0 3.5 12h2.954l-.72.72a2.52 2.52 0 0 0-.242.28H3.5A2.5 2.5 0 0 1 1 10.5zm7.931 3.224l-.577-.578a.5.5 0 1 0-.708.708l.745.744c.144-.306.324-.6.54-.874M2 4h10v-.5A1.5 1.5 0 0 0 10.5 2h-7A1.5 1.5 0 0 0 2 3.5zm4.354 2.854a.5.5 0 1 0-.708-.708l-2 2a.5.5 0 0 0 0 .708l2 2a.5.5 0 0 0 .708-.708L4.707 8.5zm6.538-.83c.366.042.471.48.21.742l-.975.975a1.507 1.507 0 1 0 2.132 2.132l.975-.975c.261-.261.7-.156.742.21a3.518 3.518 0 0 1-4.676 3.723l-2.726 2.727a1.507 1.507 0 1 1-2.132-2.132L9.168 10.7a3.518 3.518 0 0 1 3.724-4.676" />
</svg>
}
{type === 'city'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 48 48">
<g fill="none">
<path stroke={color} strokeLinecap="round" strokeLinejoin="round" strokeWidth={4} d="M4 42h40"></path>
<rect width={8} height={16} x={8} y={26} stroke={color} strokeLinejoin="round" strokeWidth={4} rx={2}></rect>
<path stroke={color} strokeLinecap="square" strokeLinejoin="round" strokeWidth={4} d="M12 34h1"></path>
<rect width={24} height={38} x={16} y={4} stroke={color} strokeLinejoin="round" strokeWidth={4} rx={2}></rect>
<path fill={color} d="M22 10h4v4h-4zm8 0h4v4h-4zm-8 7h4v4h-4zm8 0h4v4h-4zm0 7h4v4h-4zm0 7h4v4h-4z"></path>
</g>
</svg>
}
</span>
);
};

View File

@@ -0,0 +1,28 @@
type InputFieldProps = {
label: string;
value: string;
onChange: Function;
placeholder?: string;
classNames?: string;
hasError?: boolean;
}
const InputField = ({ label = '', value = '', placeholder = '', onChange, hasError = false }: InputFieldProps) => {
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div className="field--input w-full relative flex justify-between items-center">
<label className={labelStyle}>{label}</label>
<input
className={`p-2 border border-gray-200 rounded focus:outline-none w-[210px]
focus:border-blue-200 ${hasError ? ' border-red-400 focus:border-red-400' : ''} `}
type={'text'}
value={value}
onChange={(event) => onChange(event.target.value)}
autoComplete="off"
placeholder={placeholder}
/>
</div>
);
};
export default InputField;

View File

@@ -1,25 +1,17 @@
import React, { useEffect } from 'react';
import React from 'react';
import Icon from './Icon';
import useOnKey from '../../hooks/useOnKey';
type ModalProps = {
children: React.ReactNode,
width?: string,
title?: string,
verticalCenter?: boolean,
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 Modal = ({ children, width = '1/2', closeModal, title, verticalCenter = false }:ModalProps) => {
useOnKey('Escape', closeModal);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();
@@ -30,8 +22,9 @@ const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
return (
<div className='modal fixed top-0 left-0 bg-white/[.7] w-full h-screen z-50' onClick={closeOnBGClick}>
<div
className={`modal__content 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`}>
className={`modal__content max-w-[340px] absolute 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
${verticalCenter ? ' top-1/2 translate-y-[-50%]' : 'top-1/4'}`}>
{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

View File

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

View File

@@ -9,6 +9,7 @@ type SelectFieldProps = {
defaultLabel: string,
options: SelectionOption[],
selected: string[],
label?: string,
multiple?: boolean,
updateField: Function,
minWidth?: number,
@@ -28,9 +29,12 @@ const SelectField = (props: SelectFieldProps) => {
maxHeight = 96,
rounded = 'rounded-3xl',
flags = false,
label = '',
emptyMsg = '' } = props;
const [showOptions, setShowOptions] = useState(false);
const [showOptions, setShowOptions] = useState<boolean>(false);
const [filterInput, setFilterInput] = useState<string>('');
const [filterdOptions, setFilterdOptions] = useState<SelectionOption[]>([]);
const selectedLabels = useMemo(() => {
return options.reduce((acc:string[], item:SelectionOption) :string[] => {
@@ -51,13 +55,26 @@ const SelectField = (props: SelectFieldProps) => {
if (!multiple) { setShowOptions(false); }
};
const filterOptions = (event:React.FormEvent<HTMLInputElement>) => {
setFilterInput(event.currentTarget.value);
const filteredItems:SelectionOption[] = [];
const userVal = event.currentTarget.value.toLowerCase();
options.forEach((option:SelectionOption) => {
if (flags ? option.label.toLowerCase().startsWith(userVal) : option.label.toLowerCase().includes(userVal)) {
filteredItems.push(option);
}
});
setFilterdOptions(filteredItems);
};
return (
<div className="select font-semibold text-gray-500">
<div className="select font-semibold text-gray-500 relative flex justify-between items-center">
{label && <label className='mb-2 font-semibold inline-block text-sm text-gray-700 capitalize'>{label}</label>}
<div
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[180px] min-w-[${minWidth}px]
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[210px] min-w-[${minWidth}px]
${showOptions ? 'border-indigo-200' : ''}`}
onClick={() => setShowOptions(!showOptions)}>
<span className={`w-[${minWidth - 30}px] inline-block truncate mr-2`}>
<span className={'w-full inline-block truncate mr-2 capitalize'}>
{selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel}
</span>
{multiple && selected.length > 2
@@ -66,15 +83,26 @@ const SelectField = (props: SelectFieldProps) => {
</div>
{showOptions && (
<div
className={`select_list mt-1 border absolute min-w-[${minWidth}px]
className={`select_list mt-1 border absolute min-w-[${minWidth}px] top-[30px] right-0 w-[210px]
${rounded === 'rounded-3xl' ? 'rounded-lg' : rounded} max-h-${maxHeight} bg-white z-50 overflow-y-auto styled-scrollbar`}>
{options.length > 20 && (
<div className=''>
<input
className=' border-b-[1px] p-3 w-full focus:outline-0 focus:border-blue-100'
type="text"
placeholder='Search..'
onChange={filterOptions}
value={filterInput}
/>
</div>
)}
<ul>
{options.map((opt) => {
{(options.length > 20 && filterdOptions.length > 0 && filterInput ? filterdOptions : 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]
className={`select-none cursor-pointer px-3 py-2 hover:bg-[#FCFCFF] capitalize
${itemActive ? ' bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''} `}
onClick={() => selectItem(opt)}
>

View File

@@ -1,10 +1,11 @@
/* eslint-disable @next/next/no-img-element */
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Icon from './Icon';
type SidebarProps = {
domains: Domain[],
domains: DomainType[],
showAddModal: Function
}
@@ -23,13 +24,14 @@ const Sidebar = ({ domains, showAddModal } : SidebarProps) => {
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>
rounded-r-none ${((`/domain/${d.slug}` === router.asPath || `/domain/console/${d.slug}` === router.asPath
|| `/domain/insight/${d.slug}` === router.asPath)
? 'bg-white text-zinc-800 border border-r-0' : 'text-zinc-500')}`}>
<img
className={' inline-block mr-1'}
src={`https://www.google.com/s2/favicons?domain=${d.domain}&sz=16`} alt={d.domain}
/>
{d.domain}
{/* <span>0</span> */}
</a>
</Link>
</li>)

View File

@@ -0,0 +1,32 @@
type ToggleFieldProps = {
label: string;
value: string;
onChange: (bool:boolean) => void ;
classNames?: string;
}
const ToggleField = ({ label = '', value = '', onChange, classNames = '' }: ToggleFieldProps) => {
return (
<div className={`field--toggle w-full relative ${classNames}`}>
<label className="relative inline-flex items-center cursor-pointer w-full justify-between">
<span className="text-sm font-medium text-gray-900 dark:text-gray-300 w-56">{label}</span>
<input
type="checkbox"
value={value}
checked={!!value}
className="sr-only peer"
onChange={() => onChange(!value)}
/>
<div className="relative rounded-3xl w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800rounded-full peer dark:bg-gray-700
peer-checked:after:translate-x-full peer-checked:after:border-white after:content-['']
after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300
after:border after:rounded-full after:h-4 after:w-4
after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
);
};
export default ToggleField;

View File

@@ -1,3 +1,4 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
@@ -11,6 +12,7 @@ type TopbarProps = {
const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
const [showMobileMenu, setShowMobileMenu] = useState<boolean>(false);
const router = useRouter();
const isDomainsPage = router.pathname === '/domains';
const logoutUser = async () => {
try {
@@ -28,12 +30,21 @@ const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
};
return (
<div className="topbar flex w-full max-w-7xl mx-auto justify-between lg:justify-end bg-white lg:bg-transparent">
<div className={`topbar flex w-full mx-auto justify-between
${isDomainsPage ? 'max-w-5xl lg:justify-between' : 'max-w-7xl lg:justify-end'} bg-white lg:bg-transparent`}>
<h3 className="p-4 text-base font-bold text-blue-700 lg:hidden">
<h3 className={`p-4 text-base font-bold text-blue-700 ${isDomainsPage ? 'lg:pl-0' : 'lg:hidden'}`}>
<span className=' relative top-[3px] mr-1'><Icon type="logo" size={24} color="#364AFF" /></span> SerpBear
<button className='px-3 py-1 font-bold text-blue-700 lg:hidden ml-3 text-lg' onClick={() => showAddModal()}>+</button>
</h3>
{!isDomainsPage && (
<Link href={'/domains'} passHref={true}>
<a className=' right-14 top-2 px-2 py-1 cursor-pointer bg-[#ecf2ff] hover:bg-indigo-100 transition-all
absolute lg:top-3 lg:right-auto lg:left-8 lg:px-3 lg:py-2 rounded-full'>
<Icon type="caret-left" size={16} title="Go Back" />
</a>
</Link>
)}
<div className="topbar__right">
<button className={' lg:hidden p-3'} onClick={() => setShowMobileMenu(!showMobileMenu)}>
<Icon type="hamburger" size={24} />

View File

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

View File

@@ -1,31 +1,40 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import Link from 'next/link';
import { useRefreshKeywords } from '../../services/keywords';
import Icon from '../common/Icon';
import SelectField from '../common/SelectField';
type DomainHeaderProps = {
domain: Domain,
domains: Domain[],
domain: DomainType,
domains: DomainType[],
showAddModal: Function,
showSettingsModal: Function,
exportCsv:Function
exportCsv:Function,
scFilter?: string
setScFilter?: Function
}
const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, domains }: DomainHeaderProps) => {
const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, domains, scFilter = 'thirtyDays', setScFilter }: DomainHeaderProps) => {
const router = useRouter();
const [showOptions, setShowOptions] = useState<boolean>(false);
const [ShowSCDates, setShowSCDates] = useState<boolean>(false);
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
const isConsole = router.pathname === '/domain/console/[slug]';
const isInsight = router.pathname === '/domain/insight/[slug]';
const daysName = (dayKey:string) => dayKey.replace('three', '3').replace('seven', '7').replace('thirty', '30').replace('Days', ' Days');
const buttonStyle = 'leading-6 inline-block px-2 py-2 text-gray-500 hover:text-gray-700';
const buttonLabelStyle = 'ml-2 text-sm not-italic lg:invisible lg:opacity-0';
const tabStyle = 'rounded rounded-b-none cursor-pointer border-[#e9ebff] border-b-0';
const scDataFilterStlye = 'px-3 py-2 block w-full';
return (
<div className='domain_kewywords_head flex w-full justify-between'>
<div className='domain_kewywords_head w-full '>
<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'>
<div className='domain_selector 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]}
@@ -36,46 +45,99 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
/>
</div>
</div>
<div className='flex my-3'>
<button className={`${buttonStyle} lg:hidden`} onClick={() => setShowOptions(!showOptions)}>
<div className='flex w-full justify-between'>
<ul className=' flex items-end text-sm relative top-[2px]'>
<li className={`${tabStyle} ${router.pathname === '/domain/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}>
<Link href={`/domain/${domain.slug}`} passHref={true}>
<a className='px-4 py-2 inline-block'><Icon type="tracking" color='#999' classes='hidden lg:inline-block' />
<span className='text-xs lg:text-sm lg:ml-2'>Tracking</span>
</a>
</Link>
</li>
<li className={`${tabStyle} ${router.pathname === '/domain/console/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}>
<Link href={`/domain/console/${domain.slug}`} passHref={true}>
<a className='px-4 py-2 inline-block'><Icon type="google" size={13} classes='hidden lg:inline-block' />
<span className='text-xs lg:text-sm lg:ml-2'>Discover</span>
<Icon type='help' size={14} color="#aaa" classes="ml-2 hidden lg:inline-block" title='Discover Keywords you already Rank For' />
</a>
</Link>
</li>
<li className={`${tabStyle} ${router.pathname === '/domain/insight/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}>
<Link href={`/domain/insight/${domain.slug}`} passHref={true}>
<a className='px-4 py-2 inline-block'><Icon type="google" size={13} classes='hidden lg:inline-block' />
<span className='text-xs lg:text-sm lg:ml-2'>Insight</span>
<Icon type='help' size={14} color="#aaa" classes="ml-2 hidden lg:inline-block" title='Insight for Google Search Console Data' />
</a>
</Link>
</li>
</ul>
<div className={'flex mt-3 mb-0 lg:mb-3'}>
{!isInsight && <button className={`${buttonStyle} lg:hidden`} onClick={() => setShowOptions(!showOptions)}>
<Icon type='dots' size={20} />
</button>
}
{isInsight && <button className={`${buttonStyle} lg:hidden invisible`}>x</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>
{!isInsight && (
<button
className={`domheader_action_button relative ${buttonStyle}`}
aria-pressed="false"
onClick={() => exportCsv()}>
<Icon type='download' size={20} /><i className={`${buttonLabelStyle}`}>Export as csv</i>
</button>
)}
{!isConsole && !isInsight && (
<button
className={`domheader_action_button relative ${buttonStyle} lg:ml-3`}
aria-pressed="false"
onClick={() => refreshMutate({ ids: [], domain: domain.domain })}>
<Icon type='reload' size={14} /><i className={`${buttonLabelStyle}`}>Reload All Serps</i>
</button>
)}
<button
data-testid="show_domain_settings"
className={`${buttonStyle} lg:ml-3`}
className={`domheader_action_button relative ${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>
<i className={`${buttonLabelStyle}`}>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>
{!isConsole && !isInsight && (
<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>
)}
{isConsole && (
<div className='text-xs pl-4 ml-2 border-l border-gray-200 relative'>
{/* <span className='hidden lg:inline-block'>Data From Last: </span> */}
<span className='block cursor-pointer py-3' onClick={() => setShowSCDates(!ShowSCDates)}>
<Icon type='date' size={13} classes="mr-1" /> {daysName(scFilter)}
</span>
{ShowSCDates && (
<div className='absolute w-24 z-50 mt-0 right-0 bg-white border border-gray-200 rounded text-center'>
{['threeDays', 'sevenDays', 'thirtyDays'].map((itemKey) => {
return <button
key={itemKey}
className={`${scDataFilterStlye} ${scFilter === itemKey ? ' bg-indigo-100 text-indigo-600' : ''}`}
onClick={() => { setShowSCDates(false); if (setScFilter) setScFilter(itemKey); }}
>Last {daysName(itemKey)}
</button>;
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,89 @@
/* eslint-disable @next/next/no-img-element */
// import { useRouter } from 'next/router';
// import { useState } from 'react';
import TimeAgo from 'react-timeago';
import dayjs from 'dayjs';
import Link from 'next/link';
import Icon from '../common/Icon';
type DomainItemProps = {
domain: DomainType,
selected: boolean,
isConsoleIntegrated: boolean,
thumb: string,
updateThumb: Function,
}
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb, updateThumb }: DomainItemProps) => {
const { keywordsUpdated, slug, keywordCount = 0, avgPosition = 0, scVisits = 0, scImpressions = 0, scPosition = 0 } = domain;
// const router = useRouter();
return (
<div className={`domItem bg-white border rounded w-full text-sm mb-10 hover:border-indigo-200 ${selected ? '' : ''}`}>
<Link href={`/domain/${slug}`} passHref={true}>
<a className='flex flex-col lg:flex-row'>
<div className={`flex-1 p-6 flex ${!isConsoleIntegrated ? 'basis-1/3' : ''}`}>
<div className="group domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded
border border-gray-200 overflow-hidden flex justify-center relative">
<button
className=' absolute right-1 top-0 text-gray-400 p-1 transition-all
invisible opacity-0 group-hover:visible group-hover:opacity-100 hover:text-gray-600 z-10'
title='Reload Website Screenshot'
onClick={(e) => { e.preventDefault(); e.stopPropagation(); updateThumb(domain.domain); }}
>
<Icon type="reload" size={12} />
</button>
<img
className={`self-center ${!thumb ? 'max-w-[50px]' : ''}`}
src={thumb || `https://www.google.com/s2/favicons?domain=${domain.domain}&sz=128`} alt={domain.domain}
/>
</div>
<div className="domain_details flex-1">
<h3 className='font-semibold text-base mb-2 max-w-[200px] text-ellipsis overflow-hidden' title={domain.domain}>{domain.domain}</h3>
{keywordsUpdated && (
<span className=' text-gray-600 text-xs'>
Updated <TimeAgo title={dayjs(keywordsUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={keywordsUpdated} />
</span>
)}
</div>
</div>
<div className='flex-1 flex flex-col p-4'>
<div className=' bg-indigo-50 p-1 px-2 text-xs rounded-full absolute ml-3 mt-[-8px]'>
<Icon type="tracking" size={13} color="#364aff" /> Tracker
</div>
<div className='dom_stats flex flex-1 font-semibold text-2xl p-4 pt-5 rounded border border-[#E9EBFF] text-center'>
<div className="flex-1 relative">
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Keywords</span>{keywordCount}
</div>
<div className="flex-1 relative">
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Avg position</span>{avgPosition}
</div>
</div>
</div>
{isConsoleIntegrated && (
<div className='flex-1 flex-col p-4 lg:basis-56'>
<div className=' bg-indigo-50 p-1 px-2 text-xs rounded-full absolute ml-3 mt-[-8px]'>
<Icon type="google" size={13} /> Search Console (7d)
</div>
<div className='dom_sc_stats flex flex-1 h-full font-semibold text-2xl p-4 pt-5 rounded border border-[#E9EBFF] text-center'>
<div className="flex-1 relative">
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Visits</span>
{new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(scVisits).replace('T', 'K')}
</div>
<div className="flex-1 relative">
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Impressions</span>
{new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(scImpressions).replace('T', 'K')}
</div>
<div className="flex-1 relative">
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Avg position</span>
{scPosition}
</div>
</div>
</div>
)}
</a>
</Link>
</div>
);
};
export default DomainItem;

View File

@@ -1,12 +1,13 @@
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
import { useDeleteDomain, useUpdateDomain } from '../../services/domains';
import { useDeleteDomain, useFetchDomain, useUpdateDomain } from '../../services/domains';
import InputField from '../common/InputField';
import SelectField from '../common/SelectField';
type DomainSettingsProps = {
domain:Domain|false,
domains: Domain[],
domain:DomainType|false,
closeModal: Function
}
@@ -15,37 +16,33 @@ type DomainSettingsError = {
msg: string,
}
const DomainSettings = ({ domain, domains, closeModal }: DomainSettingsProps) => {
const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
const router = useRouter();
const [currentTab, setCurrentTab] = useState<'notification'|'searchconsole'>('notification');
const [showRemoveDomain, setShowRemoveDomain] = useState<boolean>(false);
const [settingsError, setSettingsError] = useState<DomainSettingsError>({ type: '', msg: '' });
const [domainSettings, setDomainSettings] = useState<DomainSettings>({ notification_interval: 'never', notification_emails: '' });
const [domainSettings, setDomainSettings] = useState<DomainSettings>(() => ({
notification_interval: domain && domain.notification_interval ? domain.notification_interval : 'never',
notification_emails: domain && domain.notification_emails ? domain.notification_emails : '',
search_console: domain && domain.search_console ? JSON.parse(domain.search_console) : {
property_type: 'domain', url: '', client_email: '', private_key: '',
},
}));
const { mutate: updateMutate } = useUpdateDomain(() => closeModal(false));
const { mutate: deleteMutate } = useDeleteDomain(() => {
closeModal(false);
const fitleredDomains = domain && domains.filter((d:Domain) => d.domain !== domain.domain);
if (fitleredDomains && fitleredDomains[0] && fitleredDomains[0].slug) {
router.push(`/domain/${fitleredDomains[0].slug}`);
}
const { mutate: updateMutate, error: domainUpdateError, isLoading: isUpdating } = useUpdateDomain(() => closeModal(false));
const { mutate: deleteMutate } = useDeleteDomain(() => { closeModal(false); router.push('/domains'); });
// Get the Full Domain Data along with the Search Console API Data.
useFetchDomain(router, domain && domain.domain ? domain.domain : '', (domainObj:DomainType) => {
const currentSearchConsoleSettings = domainObj.search_console && JSON.parse(domainObj.search_console);
setDomainSettings({ ...domainSettings, search_console: currentSearchConsoleSettings || domainSettings.search_console });
});
useEffect(() => {
if (domain) {
setDomainSettings({ notification_interval: domain.notification_interval, notification_emails: domain.notification_emails });
}
}, [domain]);
const updateNotiEmails = (event:React.FormEvent<HTMLInputElement>) => {
setDomainSettings({ ...domainSettings, notification_emails: event.currentTarget.value });
};
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);
const invalidEmails = notification_emails.find((x) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,15})+$/.test(x) === false);
console.log('invalidEmails: ', invalidEmails);
if (invalidEmails) {
error = { type: 'email', msg: 'Invalid Email' };
@@ -62,24 +59,103 @@ const DomainSettings = ({ domain, domains, closeModal }: DomainSettingsProps) =>
}
};
const tabStyle = `inline-block px-4 py-2 rounded-md mr-3 cursor-pointer text-sm select-none z-10
text-gray-600 border border-b-0 relative top-[1px] rounded-b-none`;
return (
<div>
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]">
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]" verticalCenter={currentTab === 'searchconsole'} >
<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 className=' mt-3 mb-5 border border-slate-200 px-2 py-4 pb-0
relative left-[-20px] w-[calc(100%+40px)] border-l-0 border-r-0 bg-[#f8f9ff]'>
<ul>
<li
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'} `}
onClick={() => setCurrentTab('notification')}>
<Icon type='email' /> Notification
</li>
<li
className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'}`}
onClick={() => setCurrentTab('searchconsole')}>
<Icon type='google' /> Search Console
</li>
</ul>
</div>
<div>
{currentTab === 'notification' && (
<div className="mb-4 flex justify-between items-center w-full">
<InputField
label='Notification Emails'
onChange={(emails:string) => setDomainSettings({ ...domainSettings, notification_emails: emails })}
value={domainSettings.notification_emails || ''}
placeholder='Your Emails'
/>
</div>
)}
{currentTab === 'searchconsole' && (
<>
<div className="mb-4 flex justify-between items-center w-full">
<label className='mb-2 font-semibold inline-block text-sm text-gray-700 capitalize'>Property Type</label>
<SelectField
options={[{ label: 'Domain', value: 'domain' }, { label: 'URL', value: 'url' }]}
selected={[domainSettings.search_console?.property_type || 'domain']}
defaultLabel="Select Search Console Property Type"
updateField={(updated:['domain'|'url']) => setDomainSettings({
...domainSettings,
search_console: { ...(domainSettings.search_console as DomainSearchConsole), property_type: updated[0] || 'domain' },
})}
multiple={false}
rounded={'rounded'}
/>
</div>
{domainSettings?.search_console?.property_type === 'url' && (
<div className="mb-4 flex justify-between items-center w-full">
<InputField
label='Property URL (Required)'
onChange={(url:string) => setDomainSettings({
...domainSettings,
search_console: { ...(domainSettings.search_console as DomainSearchConsole), url },
})}
value={domainSettings?.search_console?.url || ''}
placeholder='Search Console Property URL. eg: https://mywebsite.com/'
/>
</div>
)}
<div className="mb-4 flex justify-between items-center w-full">
<InputField
label='Search Console Client Email'
onChange={(client_email:string) => setDomainSettings({
...domainSettings,
search_console: { ...(domainSettings.search_console as DomainSearchConsole), client_email },
})}
value={domainSettings?.search_console?.client_email || ''}
placeholder='myapp@appspot.gserviceaccount.com'
/>
</div>
<div className="mb-4 flex flex-col justify-between items-center w-full">
<label className='mb-2 font-semibold block text-sm text-gray-700 capitalize w-full'>Search Console Private Key</label>
<textarea
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
focus:outline-none h-[100px] focus:border-blue-200`}
value={domainSettings?.search_console?.private_key || ''}
placeholder={'-----BEGIN PRIVATE KEY-----/ssssaswdkihad....'}
onChange={(event) => setDomainSettings({
...domainSettings,
search_console: { ...(domainSettings.search_console as DomainSearchConsole), private_key: event.target.value },
})}
/>
</div>
</>
)}
</div>
{!isUpdating && (domainUpdateError as Error)?.message && (
<div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{(domainUpdateError as Error).message}</div>
)}
{!isUpdating && settingsError?.msg && (
<div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{settingsError.msg}</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"
@@ -87,9 +163,9 @@ const DomainSettings = ({ domain, domains, closeModal }: DomainSettingsProps) =>
<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
className={`text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white ${isUpdating ? 'cursor-not-allowed' : ''}`}
onClick={() => !isUpdating && updateDomain()}>
{isUpdating && <Icon type='loading' />} Update Settings
</button>
</div>
</Modal>

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { Toaster } from 'react-hot-toast';
import { sortInsightItems } from '../../utils/insight';
import SelectField from '../common/SelectField';
import InsightItem from './InsightItem';
import InsightStats from './InsightStats';
type SCInsightProps = {
domain: DomainType | null,
insight: InsightDataType,
isLoading: boolean,
isConsoleIntegrated: boolean,
}
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true, domain }: SCInsightProps) => {
const [activeTab, setActiveTab] = useState<string>('stats');
const insightItems = insight[activeTab as keyof InsightDataType];
const startDate = insight && insight.stats && insight.stats.length > 0 ? new Date(insight.stats[0].date) : null;
const endDate = insight && insight.stats && insight.stats.length > 0 ? new Date(insight.stats[insight.stats.length - 1].date) : null;
const switchTab = (tab: string) => {
// window.insightTab = tab;
setActiveTab(tab);
};
const renderTableHeader = () => {
const headerNames: {[key:string]: string[]} = {
stats: ['Date', 'Avg Position', 'Visits', 'Impressions', 'CTR'],
keywords: ['Keyword', 'Avg Position', 'Visits ↑', 'Impressions', 'CTR', 'Countries'],
countries: ['Country', 'Avg Position', 'Visits ↑', 'Impressions', 'CTR', 'Keywords'],
pages: ['Page', 'Avg Position', 'Visits ↑', 'Impressions', 'CTR', 'Countries', 'Keywords'],
};
return (
<div className={`domKeywords_head 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 '>{headerNames[activeTab][0]}</span>
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>{headerNames[activeTab][1]}</span>
<span className='domKeywords_head_imp flex-1 text-center'>{headerNames[activeTab][2]}</span>
<span className='domKeywords_head_visits flex-1 text-center'>{headerNames[activeTab][3]}</span>
<span className='domKeywords_head_ctr flex-1 text-center'>{headerNames[activeTab][4]}</span>
{headerNames[activeTab][5] && <span className='domKeywords_head_ctr flex-1 text-center'>{headerNames[activeTab][5]}</span>}
{headerNames[activeTab][6] && <span className='domKeywords_head_ctr flex-1 text-center'>{headerNames[activeTab][6]}</span>}
</div>
);
};
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';
return (
<div>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'>
<div className='domKeywords_filters py-4 px-6 flex flex-col justify-between
text-sm text-gray-500 font-semibold border-b-[1px] lg:border-0 lg:flex-row'>
<div>
<ul className='text-xs hidden lg:flex'>
{['stats', 'keywords', 'countries', 'pages'].map((tabItem) => {
const tabInsightItem = insight[tabItem as keyof InsightDataType];
return <li
key={`tab-${tabItem}`}
className={`${deviceTabStyle} ${activeTab === tabItem ? ' bg-[#F8F9FF] text-gray-700' : ''}`}
onClick={() => switchTab(tabItem)}>
<i className='hidden not-italic lg:inline-block ml-1 capitalize'>{tabItem}</i>
{tabItem !== 'stats' && (
<span className={`${deviceTabCountStyle}`}>
{tabInsightItem && tabInsightItem.length ? tabInsightItem.length : 0}
</span>
)}
</li>;
})}
</ul>
<div className='insight_selector lg:hidden'>
<SelectField
options={['stats', 'keywords', 'countries', 'pages'].map((d) => { return { label: d, value: d }; })}
selected={[activeTab]}
defaultLabel="Select Tab"
updateField={(updatedTab:[string]) => switchTab(updatedTab[0])}
multiple={false}
rounded={'rounded'}
/>
</div>
</div>
{isConsoleIntegrated && (<div className='py-2 text-xs text-center mt-2 lg:text-sm lg:mt-0'>
{startDate && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(startDate))}
<span className='px-2 inline-block'>-</span>
{endDate && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(endDate))}
<span className='ml-2'>(Last 30 Days)</span>
</div>
)}
</div>
{isConsoleIntegrated && activeTab === 'stats' && (
<InsightStats
stats={insight?.stats ? insight.stats : []}
totalKeywords={insight?.keywords?.length || 0}
totalCountries={insight?.countries?.length || 0}
totalPages={insight?.pages?.length || 0}
/>
)}
<div className='domkeywordsTable domkeywordsTable--sckeywords styled-scrollbar w-full overflow-auto min-h-[60vh]'>
<div className=' lg:min-w-[800px]'>
{renderTableHeader()}
<div className='domKeywords_keywords border-gray-200 min-h-[55vh] relative'>
{['keywords', 'pages', 'countries', 'stats'].includes(activeTab) && insight && insightItems
&& (activeTab === 'stats' ? [...insightItems].reverse() : sortInsightItems(insightItems)).map(
(item:SCInsightItem, index: number) => {
const insightItemCount = insight ? insightItems : [];
const lastItem = !!(insightItemCount && (index === insightItemCount.length));
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} domain={domain?.domain || ''} />;
},
)
}
{isConsoleIntegrated && isLoading && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>Loading Insight...</p>
)}
{!isConsoleIntegrated && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>
Google Search Console has not been Integrated yet. Please follow <a className='text-indigo-600 underline' href='https://docs.serpbear.com/miscellaneous/integrate-google-search-console' target="_blank" rel='noreferrer'>These Steps</a> to integrate Google Search Data for this Domain.
</p>
)}
</div>
</div>
</div>
</div>
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};
export default SCInsight;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import countries from '../../utils/countries';
import Icon from '../common/Icon';
import { formattedNum } from '../../utils/client/helpers';
type InsightItemProps = {
item: SCInsightItem,
lastItem: boolean,
type: string,
domain: string
}
const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
const { clicks, impressions, ctr, position, country = 'zzz', keyword, page, keywords = 0, countries: cntrs = 0, date } = item;
let firstItem = keyword;
if (type === 'pages') { firstItem = page; } if (type === 'stats') {
firstItem = date && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(date));
}
if (type === 'countries') { firstItem = countries[country] && countries[country][0]; }
return (
<div
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 ${lastItem ? 'border-b-0' : ''}`}>
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold'>
{type === 'countries' && <span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} />}
{type === 'pages' && domain ? <a href={`https://${domain}${page}`} target='_blank' rel="noreferrer">{firstItem}</a> : firstItem}
</div>
<div className='keyword_pos text-center inline-block mr-3 lg:mr-0 lg:flex-1'>
<span className='mr-1 lg:hidden'>
<Icon type="tracking" size={14} color="#999" />
</span>
{Math.round(position)}
</div>
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-14 p-2 text-base mt-[-55px] rounded right-5 lg:relative
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
{formattedNum(clicks)}
<span className='block text-xs text-gray-500 lg:hidden'>Visits</span>
</div>
<div className='keyword_imp text-center inline-block mr-3 lg:mr-0 lg:flex-1'>
<span className='mr-1 lg:hidden'>
<Icon type="eye" size={14} color="#999" />
</span>
{formattedNum(impressions)}
</div>
<div className='keyword_ctr text-center inline-block mt-4 relative mr-3 lg:mr-0 lg:flex-1 lg:m-0 '>
<span className='mr-1 lg:hidden'>
<Icon type="target" size={14} color="#999" />
</span>
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(ctr)}%
</div>
{(type === 'pages' || type === 'keywords') && (
<div className='keyword_imp text-center hidden lg:inline-block lg:flex-1'>{formattedNum(cntrs)}</div>
)}
{(type === 'countries' || type === 'pages') && (
<div className='keyword_imp text-center hidden lg:inline-block lg:flex-1'>{formattedNum(keywords)}</div>
)}
</div>
);
};
export default InsightItem;

View File

@@ -0,0 +1,118 @@
import React, { useMemo } from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { formattedNum } from '../../utils/client/helpers';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
type InsightStatsProps = {
stats: SearchAnalyticsStat[],
totalKeywords: number,
totalCountries: number,
totalPages: number,
}
const InsightStats = ({ stats = [], totalKeywords = 0, totalPages = 0 }:InsightStatsProps) => {
const totalStat = useMemo(() => {
return stats.reduce((acc, item) => {
return {
impressions: item.impressions + acc.impressions,
clicks: item.clicks + acc.clicks,
ctr: item.ctr + acc.ctr,
position: item.position + acc.position,
};
}, { impressions: 0, clicks: 0, ctr: 0, position: 0 });
}, [stats]);
const chartData = useMemo(() => {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const chartSeries: {[key:string]: number[]} = { clicks: [], impressions: [], position: [], ctr: [] };
stats.forEach((item) => {
chartSeries.clicks.push(item.clicks);
chartSeries.impressions.push(item.impressions);
chartSeries.position.push(item.position);
chartSeries.ctr.push(item.ctr);
});
return {
labels: stats && stats.length > 0 ? stats.map((item) => `${new Date(item.date).getDate()}-${months[new Date(item.date).getMonth()]}`) : [],
series: chartSeries };
}, [stats]);
const renderChart = () => {
// Doc: https://www.chartjs.org/docs/latest/samples/line/multi-axis.html
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
interaction: {
mode: 'index' as const,
intersect: false,
},
scales: {
x: {
grid: {
drawOnChartArea: false,
},
},
y1: {
display: true,
position: 'right' as const,
grid: {
drawOnChartArea: false,
},
},
},
plugins: {
legend: {
display: false,
},
},
};
const { clicks, impressions } = chartData.series || {};
const dataSet = [
{ label: 'Visits', data: clicks, borderColor: 'rgb(117, 50, 205)', backgroundColor: 'rgba(117, 50, 205, 0.5)', yAxisID: 'y' },
{ label: 'Impressions', data: impressions, borderColor: 'rgb(31, 205, 176)', backgroundColor: 'rgba(31, 205, 176, 0.5)', yAxisID: 'y1' },
];
return <Line datasetIdKey={'xxx'} options={chartOptions} data={{ labels: chartData.labels, datasets: dataSet }} />;
};
return (
<div className='p-6 lg:border-t lg:border-gray-200'>
<div className=' flex font-bold flex-wrap lg:flex-nowrap'>
<div
className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-violet-700 mr-5'
title={`${formattedNum(totalStat.clicks || 0)} Visits`}>
<span className=' block text-sm font-normal text-gray-500'>Visits</span>
{new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(totalStat.clicks || 0).replace('T', 'K')}
</div>
<div
className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-[#1fcdb0] lg:mr-5'
title={`${formattedNum(totalStat.impressions || 0)} Impressions`}>
<span className=' block text-sm font-normal text-gray-500'>Impressions</span>
{new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(totalStat.impressions || 0).replace('T', 'K')}
</div>
<div className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-gray-500 font-semibold mr-5'>
<span className=' block text-sm font-normal text-gray-500'>Avg Position</span>
{(totalStat.position ? Math.round(totalStat.position / stats.length) : 0)}
</div>
<div className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-gray-500 font-semibold lg:mr-5'>
<span className=' block text-sm font-normal text-gray-500'>Avg CTR</span>
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(totalStat.ctr || 0)}%
</div>
<div className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-gray-500 font-semibold mr-5'>
<span className=' block text-sm font-normal text-gray-500'>Keywords</span>
{formattedNum(totalKeywords)}
</div>
<div className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-gray-500 font-semibold'>
<span className=' block text-sm font-normal text-gray-500'>Pages</span>
{formattedNum(totalPages)}
</div>
</div>
<div className='h-80'>
{renderChart()}
</div>
</div>
);
};
export default InsightStats;

View File

@@ -7,6 +7,8 @@ import { useAddKeywords } from '../../services/keywords';
type AddKeywordsProps = {
keywords: KeywordType[],
scraperName: string,
allowsCity: boolean,
closeModal: Function,
domain: string
}
@@ -17,24 +19,30 @@ type KeywordsInput = {
country: string,
domain: string,
tags: string,
city?:string,
}
const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
const AddKeywords = ({ closeModal, domain, keywords, scraperName = '', allowsCity = false }: AddKeywordsProps) => {
const [error, setError] = useState<string>('');
const [newKeywordsData, setNewKeywordsData] = useState<KeywordsInput>({ keywords: '', device: 'desktop', country: 'US', domain, tags: '' });
const defCountry = localStorage.getItem('default_country') || 'US';
const [newKeywordsData, setNewKeywordsData] = useState<KeywordsInput>({ keywords: '', device: 'desktop', country: defCountry, 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}`));
const keywordsArray = [...new Set(newKeywordsData.keywords.split('\n').map((item) => item.trim()).filter((item) => !!item))];
const currentKeywords = keywords.map((k) => `${k.keyword}-${k.device}-${k.country}${k.city ? `-${k.city}` : ''}`);
const keywordExist = keywordsArray.filter((k) => currentKeywords.includes(
`${k}-${newKeywordsData.device}-${newKeywordsData.country}${newKeywordsData.city ? `-${newKeywordsData.city}` : ''}`,
));
if (keywordExist.length > 0) {
setError(`Keywords ${keywordExist.join(',')} already Exist`);
setTimeout(() => { setError(''); }, 3000);
} else {
addMutate(newKeywordsData);
const { device, country, domain: kDomain, tags, city } = newKeywordsData;
const newKeywordsArray = keywordsArray.map((nItem) => ({ keyword: nItem, device, country, domain: kDomain, tags, city }));
addMutate(newKeywordsArray);
}
} else {
setError('Please Insert a Keyword');
@@ -49,7 +57,7 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
<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...'
placeholder="Type or Paste Keywords here. Insert Each keyword in a New line."
value={newKeywordsData.keywords}
onChange={(e) => setNewKeywordsData({ ...newKeywordsData, keywords: e.target.value })}>
</textarea>
@@ -62,7 +70,10 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
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] })}
updateField={(updated:string[]) => {
setNewKeywordsData({ ...newKeywordsData, country: updated[0] });
localStorage.setItem('default_country', updated[0]);
}}
rounded='rounded'
maxHeight={48}
flags={true}
@@ -79,17 +90,28 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
><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'
placeholder='Insert Tags (Optional)'
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 className='relative mt-2'>
<input
className={`w-full border rounded border-gray-200 py-2 px-4 pl-8
outline-none focus:border-indigo-300 ${!allowsCity ? ' cursor-not-allowed' : ''} `}
disabled={!allowsCity}
title={!allowsCity ? `Your scraper ${scraperName} doesn't have city level scraping feature.` : ''}
placeholder={`City (Optional)${!allowsCity ? `. Not avaialable for ${scraperName}.` : ''}`}
value={newKeywordsData.city}
onChange={(e) => setNewKeywordsData({ ...newKeywordsData, city: e.target.value })}
/>
<span className='absolute text-gray-400 top-2 left-2'><Icon type="city" 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'>

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useUpdateKeywordTags } from '../../services/keywords';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
type AddTagsProps = {
keywords: KeywordType[],
closeModal: Function
}
const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => {
const [tagInput, setTagInput] = useState('');
const [inputError, setInputError] = useState('');
const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); });
const addTag = () => {
if (keywords.length === 0) { return; }
if (!tagInput) {
setInputError('Please Insert a Tag!');
setTimeout(() => { setInputError(''); }, 3000);
return;
}
const tagsArray = tagInput.split(',').map((t) => t.trim());
const tagsPayload:any = {};
keywords.forEach((keyword:KeywordType) => {
tagsPayload[keyword.ID] = [...keyword.tags, ...tagsArray];
});
updateMutate({ tags: tagsPayload });
};
return (
<Modal closeModal={() => { closeModal(false); }} title={`Add New Tags to ${keywords.length} Selected Keyword`}>
<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. eg: tag1, tag2'
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-2 px-4 bg-indigo-600 text-white font-semibold text-sm"
onClick={addTag}>
Apply
</button>
</div>
</Modal>
);
};
export default AddTags;

View File

@@ -4,24 +4,43 @@ import dayjs from 'dayjs';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import ChartSlim from '../common/ChartSlim';
import { generateTheChartData } from '../common/generateChartData';
import KeywordPosition from './KeywordPosition';
import { generateTheChartData } from '../../utils/client/generateChartData';
type KeywordProps = {
keywordData: KeywordType,
selected: boolean,
index: number,
refreshkeyword: Function,
favoriteKeyword: Function,
removeKeyword: Function,
selectKeyword: Function,
manageTags: Function,
showKeywordDetails: Function,
lastItem?:boolean
lastItem?:boolean,
showSCData: boolean,
scDataType: string,
style: Object
}
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,
refreshkeyword,
favoriteKeyword,
removeKeyword,
selectKeyword,
selected,
showKeywordDetails,
manageTags,
lastItem,
showSCData = true,
style,
index,
scDataType = 'threeDays',
} = props;
const {
keyword, domain, ID, city, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = false,
} = keywordData;
const [showOptions, setShowOptions] = useState(false);
const [showPositionError, setPositionError] = useState(false);
@@ -42,28 +61,36 @@ const Keyword = (props: KeywordProps) => {
const historySorted = historyArray.sort((a, b) => a.date - b.date);
const previousPos = historySorted[historySorted.length - 2].position;
status = previousPos === 0 ? position : previousPos - position;
if (position === 0 && previousPos > 0) {
status = previousPos - 100;
}
}
return status;
}, [history, position]);
const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
const bestPosition: false | {position: number, date: string} = useMemo(() => {
let bestPos;
if (Object.keys(history).length > 0) {
const historyArray = Object.keys(history).map((itemID) => ({ date: itemID, position: history[itemID] }))
.sort((a, b) => a.position - b.position);
if (historyArray[0]) {
bestPos = { ...historyArray[0] };
}
}
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 bestPos || false;
}, [history]);
const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
return (
<div
key={keyword}
key={keyword + ID}
style={style}
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'>
<div className=' w-3/4 font-semibold cursor-pointer lg:flex-1 lg:basis-20 lg:w-auto lg:flex lg:items-center'>
<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'}`}
@@ -72,39 +99,79 @@ const Keyword = (props: KeywordProps) => {
<Icon type="check" size={10} />
</button>
<a
className='py-2 hover:text-blue-600'
className='py-2 hover:text-blue-600 lg:flex lg:items-center lg:w-full'
onClick={() => showKeywordDetails()}>
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />{keyword}
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />
<span className=' text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-30px)]'>{keyword}{city ? ` (${city})` : ''}</span>
</a>
{sticky && <button className='ml-2 relative top-[2px]' title='Favorite'><Icon type="star-filled" size={16} color="#fbd346" /></button>}
{lastUpdateError !== 'false'
{lastUpdateError && lastUpdateError.date
&& <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()}
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-24 lg:grow-0 lg:right-0 text-center font-semibold`}>
<KeywordPosition position={position} updating={updating} />
{!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>
<div
title={bestPosition && bestPosition.date
? new Date(bestPosition.date).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' }) : ''
}
className={`keyword_best hidden bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative lg:block
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-16 lg:grow-0 lg:right-0 text-center font-semibold`}>
{bestPosition ? bestPosition.position || '-' : (position || '-')}
</div>
{chartData.labels.length > 0 && (
<div className='lg:flex-1 hidden lg:block'>
<div
className='hidden basis-32 grow-0 cursor-pointer lg:block'
onClick={() => showKeywordDetails()}>
<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>
<a href={url} target="_blank" rel="noreferrer"><span className='mr-3 lg:hidden'>
<Icon type="link-alt" size={14} color="#999" /></span>{turncatedURL || '-'}
</a>
</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'>
{showSCData && (
<div className='keyword_sc_data min-w-[170px] text-xs mt-4 pt-2 border-t border-gray-100 top-[6px]
relative flex justify-between text-center lg:flex-1 lg:text-sm lg:m-0 lg:mt-0 lg:border-t-0 lg:pt-0 lg:top-0'>
<span className='min-w-[40px]'>
<span className='lg:hidden'>SC Position: </span>
<KeywordPosition
position={keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0}
type='sc'
/>
</span>
<span className='min-w-[40px]'>
<span className='lg:hidden'>Impressions: </span>{keywordData?.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0}
</span>
<span className='min-w-[40px]'>
<span className='lg:hidden'>Visits: </span>{keywordData?.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0}
</span>
{/* <span>{keywordData?.scData?.ctr[scDataType] || '0.00%'}</span> */}
</div>
)}
<div className='absolute right-4 mt-[-10px] top-2 lg:flex-1 lg:basis-5 lg:grow-0 lg:shrink-0 lg:relative lg:right-[-10px]'>
<button
className={`keyword_dots rounded px-1 text-indigo-300 hover:bg-indigo-50 ${showOptions ? 'bg-indigo-50 text-indigo-600 ' : ''}`}
onClick={() => setShowOptions(!showOptions)}>
@@ -133,16 +200,22 @@ const Keyword = (props: KeywordProps) => {
</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'>
{lastUpdateError && lastUpdateError.date && showPositionError && (
<div className={`absolute p-2 bg-white z-30 border border-red-200 rounded w-[220px] left-4 shadow-sm text-xs
${index > 2 ? 'lg:bottom-12 mt-[-70px]' : ' top-12'}`}>
Error Updating Keyword position (Tried <TimeAgo
title={dayjs(lastUpdateError).format('DD-MMM-YYYY, hh:mm:ss A')}
date={lastUpdateError} />)
title={dayjs(lastUpdateError.date).format('DD-MMM-YYYY, hh:mm:ss A')}
date={lastUpdateError.date} />)
<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 className=' border-t-[1px] border-red-100 mt-2 pt-1'>
{lastUpdateError.scraper && <strong className='capitalize'>{lastUpdateError.scraper}: </strong>}{lastUpdateError.error}
</div>
</div>
}
)}
</div>
);
};

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import countries from '../../utils/countries';
@@ -9,14 +9,12 @@ type KeywordFilterProps = {
setDevice: Function,
filterParams: KeywordFilters,
filterKeywords: Function,
keywords: KeywordType[],
keywords: KeywordType[] | SearchAnalyticsItem[],
updateSort: Function,
sortBy: string
}
type KeywordCountState = {
desktop: number,
mobile: number
sortBy: string,
integratedConsole?: boolean,
isConsole?: boolean,
SCcountries?: string[];
}
const KeywordFilters = (props: KeywordFilterProps) => {
@@ -25,24 +23,29 @@ const KeywordFilters = (props: KeywordFilterProps) => {
setDevice,
filterKeywords,
allTags = [],
keywords,
keywords = [],
updateSort,
sortBy,
filterParams } = props;
const [keywordCounts, setKeywordCounts] = useState<KeywordCountState>({ desktop: 0, mobile: 0 });
filterParams,
isConsole = false,
integratedConsole = false,
SCcountries = [],
} = props;
const [sortOptions, showSortOptions] = useState(false);
const [filterOptions, showFilterOptions] = useState(false);
useEffect(() => {
const keyWordCount = { desktop: 0, mobile: 0 };
keywords.forEach((k) => {
if (k.device === 'desktop') {
keyWordCount.desktop += 1;
} else {
keyWordCount.mobile += 1;
}
});
setKeywordCounts(keyWordCount);
const keywordCounts = useMemo(() => {
const counts = { desktop: 0, mobile: 0 };
if (keywords && keywords.length > 0) {
keywords.forEach((k) => {
if (k.device === 'desktop') {
counts.desktop += 1;
} else {
counts.mobile += 1;
}
});
}
return counts;
}, [keywords]);
const filterCountry = (cntrs:string[]) => filterKeywords({ ...filterParams, countries: cntrs });
@@ -55,12 +58,16 @@ const KeywordFilters = (props: KeywordFilterProps) => {
};
const countryOptions = useMemo(() => {
const optionObject = Object.keys(countries).map((countryISO:string) => ({
label: countries[countryISO][0],
value: countryISO,
}));
const optionObject:{label:string, value:string}[] = [];
Object.keys(countries).forEach((countryISO:string) => {
if (!isConsole || (isConsole && SCcountries.includes(countryISO))) {
optionObject.push({ label: countries[countryISO][0], value: countryISO });
}
});
return optionObject;
}, []);
}, [SCcountries, isConsole]);
const sortOptionChoices: SelectionOption[] = [
{ value: 'pos_asc', label: 'Top Position' },
@@ -70,6 +77,17 @@ const KeywordFilters = (props: KeywordFilterProps) => {
{ value: 'alpha_asc', label: 'Alphabetically(A-Z)' },
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
];
if (integratedConsole) {
sortOptionChoices.push({ value: 'imp_desc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` });
sortOptionChoices.push({ value: 'imp_asc', label: 'Least Viewed' });
sortOptionChoices.push({ value: 'visits_desc', label: 'Most Visited' });
sortOptionChoices.push({ value: 'visits_asc', label: 'Least Visited' });
}
if (isConsole) {
sortOptionChoices.splice(2, 2);
sortOptionChoices.push({ value: 'ctr_asc', label: 'Highest CTR' });
sortOptionChoices.push({ value: 'ctr_desc', label: 'Lowest CTR' });
}
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' : ''}`;
};
@@ -119,15 +137,17 @@ const KeywordFilters = (props: KeywordFilterProps) => {
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>
{!isConsole && (
<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"
@@ -150,7 +170,8 @@ const KeywordFilters = (props: KeywordFilterProps) => {
{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'>
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg
max-h-96 bg-white z-[9999] w-44 overflow-y-auto styled-scrollbar'>
{sortOptionChoices.map((sortOption) => {
return <li
key={sortOption.value}

View File

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

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useUpdateKeywordTags } from '../../services/keywords';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
import AddTags from './AddTags';
type keywordTagManagerProps = {
keyword: KeywordType|undefined,
@@ -10,9 +11,8 @@ type keywordTagManagerProps = {
}
const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
const [tagInput, setTagInput] = useState('');
const [inputError, setInputError] = useState('');
const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); });
const [showAddTag, setShowAddTag] = useState<boolean>(false);
const { mutate: updateMutate } = useUpdateKeywordTags(() => { });
const removeTag = (tag:String) => {
if (!keyword) { return; }
@@ -20,24 +20,6 @@ const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
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 ">
@@ -53,31 +35,27 @@ const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
</button>
</li>;
})}
<li className='inline-block py-1 px-1'>
<button
title='Add New Tag'
className="cursor-pointer rounded p-1 px-3 bg-indigo-600 text-white font-semibold text-sm"
onClick={() => setShowAddTag(true)}>+</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 className="text-center w-full text-gray-500">
No Tags Added to this Keyword. <button className=' text-indigo-600' onClick={() => setShowAddTag(true)}>+ Add Tag</button>
</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>
{showAddTag && keyword && (
<AddTags
keywords={[keyword]}
closeModal={() => setShowAddTag(false)}
/>
)}
</Modal>
);
};

View File

@@ -1,8 +1,7 @@
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import { CSSTransition } from 'react-transition-group';
import AddKeywords from './AddKeywords';
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/sortFilter';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/client/sortFilter';
import Icon from '../common/Icon';
import Keyword from './Keyword';
import KeywordDetails from './KeywordDetails';
@@ -10,33 +9,54 @@ import KeywordFilters from './KeywordFilter';
import Modal from '../common/Modal';
import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords';
import KeywordTagManager from './KeywordTagManager';
import AddTags from './AddTags';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
type KeywordsTableProps = {
domain: Domain | null,
domain: DomainType | null,
keywords: KeywordType[],
isLoading: boolean,
showAddModal: boolean,
setShowAddModal: Function
setShowAddModal: Function,
isConsoleIntegrated: boolean,
}
const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal = false, setShowAddModal }: KeywordsTableProps) => {
const KeywordsTable = (props: KeywordsTableProps) => {
const { keywords = [], isLoading = true, isConsoleIntegrated = false } = props;
const showSCData = isConsoleIntegrated;
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 [showAddTags, setShowAddTags] = useState<boolean>(false);
const [SCListHeight, setSCListHeight] = useState(500);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('date_asc');
const [scDataType, setScDataType] = useState<string>('threeDays');
const [showScDataTypes, setShowScDataTypes] = useState<boolean>(false);
const { mutate: deleteMutate } = useDeleteKeywords(() => {});
const { mutate: favoriteMutate } = useFavKeywords(() => {});
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
const [isMobile] = useIsMobile();
useWindowResize(() => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400)));
const scDataObject:{ [k:string] : string} = {
threeDays: 'Last Three Days',
sevenDays: 'Last Seven Days',
thirtyDays: 'Last Thirty Days',
avgThreeDays: 'Last Three Days Avg',
avgSevenDays: 'Last Seven Days Avg',
avgThirtyDays: 'Last Thirty Days Avg',
};
const processedKeywords: {[key:string] : KeywordType[]} = useMemo(() => {
const procKeywords = keywords.filter((x) => x.device === device);
const filteredKeywords = filterKeywords(procKeywords, filterParams);
const sortedKeywords = sortKeywords(filteredKeywords, sortBy);
const sortedKeywords = sortKeywords(filteredKeywords, sortBy, scDataType);
return keywordsByDevice(sortedKeywords, device);
}, [keywords, device, sortBy, filterParams]);
}, [keywords, device, sortBy, filterParams, scDataType]);
const allDomainTags: string[] = useMemo(() => {
const allTags = keywords.reduce((acc: string[], keyword) => [...acc, ...keyword.tags], []);
@@ -51,12 +71,33 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
}
setSelectedKeywords(updatedSelectd);
};
const Row = ({ data, index, style }:ListChildComponentProps) => {
const keyword = data[index];
return (
<Keyword
key={keyword.ID}
style={style}
index={index}
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)}
showSCData={showSCData}
scDataType={scDataType}
/>
);
};
const selectedAllItems = selectedKeywords.length === processedKeywords[device].length;
return (
<div>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border'>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'>
{selectedKeywords.length > 0 && (
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
<ul className=''>
@@ -65,7 +106,7 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
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
<span className=' bg-indigo-100 text-blue-700 px-1 rounded'><Icon type="reload" size={11} /></span> Refresh Keywords
</a>
</li>
<li className='inline-block mr-4'>
@@ -73,7 +114,14 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
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>
<span className=' bg-red-100 text-red-600 px-1 rounded'><Icon type="trash" size={14} /></span> Remove Keywords</a>
</li>
<li className='inline-block mr-4'>
<a
className='block px-2 py-2 cursor-pointer hover:text-indigo-600'
onClick={() => setShowAddTags(true)}
>
<span className=' bg-green-100 text-green-500 px-1 rounded'><Icon type="tags" size={14} /></span> Tag Keywords</a>
</li>
</ul>
</div>
@@ -88,13 +136,15 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
keywords={keywords}
device={device}
setDevice={setDevice}
integratedConsole={isConsoleIntegrated}
/>
)}
<div className='styled-scrollbar w-full overflow-auto min-h-[60vh] '>
<div className={`domkeywordsTable domkeywordsTable--keywords ${showSCData ? 'domkeywordsTable--hasSC' : ''}
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 '>
<span className='domKeywords_head_keyword flex-1 basis-[4rem] 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
@@ -106,30 +156,64 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
)}
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_position flex-1 basis-24 grow-0 text-center'>Position</span>
<span className='domKeywords_head_best flex-1 basis-16 grow-0 text-center'>Best</span>
<span className='domKeywords_head_history flex-1 basis-32 grow-0 '>History (7d)</span>
<span className='domKeywords_head_url flex-1'>URL</span>
<span className='domKeywords_head_updated flex-1'>Updated</span>
{showSCData && (
<div className='domKeywords_head_sc flex-1 min-w-[170px] mr-7 text-center'>
{/* Search Console */}
<div>
<div
className=' w-48 select-none cursor-pointer absolute bg-white rounded-full
px-2 py-[2px] mt-[-22px] ml-3 border border-gray-200 z-40'
onClick={() => setShowScDataTypes(!showScDataTypes)}>
<Icon type="google" size={13} /> {scDataObject[scDataType]}
<Icon classes="ml-2" type={showScDataTypes ? 'caret-up' : 'caret-down'} size={10} />
</div>
{showScDataTypes && (
<div className='absolute bg-white border border-gray-200 z-50 w-44 rounded mt-2 ml-5 text-gray-500'>
{Object.keys(scDataObject).map((itemKey) => {
return <span
className={`block p-2 cursor-pointer hover:bg-indigo-50 hover:text-indigo-600
${scDataType === itemKey ? 'bg-indigo-100 text-indigo-600' : ''}`}
key={itemKey}
onClick={() => { setScDataType(itemKey); setShowScDataTypes(false); }}>
{scDataObject[itemKey]}
</span>;
})}
</div>
)}
</div>
<div className='relative top-2 flex justify-between'>
<span className='min-w-[40px]'>Pos</span>
<span className='min-w-[40px]'>Imp</span>
<span className='min-w-[40px]'>Visits</span>
{/* <span>CTR</span> */}
</div>
</div>
)}
</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)}
/>)}
<div className='domKeywords_keywords border-gray-200 min-h-[55vh] relative'>
{processedKeywords[device] && processedKeywords[device].length > 0 && (
<List
innerElementType="div"
itemData={processedKeywords[device]}
itemCount={processedKeywords[device].length}
itemSize={isMobile ? 146 : 57}
height={SCListHeight}
width={'100%'}
className={'styled-scrollbar'}
>
{Row}
</List>
)}
{!isLoading && processedKeywords[device].length === 0 && (
<p className=' p-9 mt-[10%] text-center text-gray-500'>No Keywords Added for this Device Type.</p>
<p className=' p-9 pt-[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>
<p className=' p-9 pt-[10%] text-center text-gray-500'>Loading Keywords...</p>
)}
</div>
</div>
@@ -157,13 +241,6 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
</div>
</Modal>
)}
<CSSTransition in={showAddModal} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddKeywords
domain={domain?.domain || ''}
keywords={keywords}
closeModal={() => setShowAddModal(false)}
/>
</CSSTransition>
{showTagManager && (
<KeywordTagManager
allTags={allDomainTags}
@@ -171,6 +248,12 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
closeModal={() => setShowTagManager(null)}
/>
)}
{showAddTags && (
<AddTags
keywords={keywords.filter((k) => selectedKeywords.includes(k.ID))}
closeModal={() => setShowAddTags(false)}
/>
)}
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);

View File

@@ -0,0 +1,72 @@
import React from 'react';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import KeywordPosition from './KeywordPosition';
import { formattedNum } from '../../utils/client/helpers';
type SCKeywordProps = {
keywordData: SearchAnalyticsItem,
selected: boolean,
selectKeyword: Function,
lastItem?:boolean,
isTracked: boolean,
style: Object
}
const SCKeyword = (props: SCKeywordProps) => {
const { keywordData, selected, lastItem, selectKeyword, style, isTracked = false } = props;
const { keyword, uid, position, country, impressions, ctr, clicks } = keywordData;
return (
<div
key={keyword}
style={style}
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
${isTracked || selected ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}
${isTracked ? 'bg-gray-400 border-gray-400 cursor-default' : ''}`}
onClick={() => !isTracked && selectKeyword(uid)}
>
<Icon type="check" size={10} title={isTracked ? 'Already in Tracker' : ''} />
</button>
<a className='py-2 hover:text-blue-600'>
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country] && countries[country][0]} />{keyword}
</a>
</div>
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-15 p-2 text-base mt-[-20px] rounded right-5 lg:relative
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
<KeywordPosition position={position} />
<span className='block text-xs text-gray-500 lg:hidden'>Position</span>
</div>
<div className='keyword_imp text-center inline-block lg:flex-1 '>
<span className='mr-3 lg:hidden'>
<Icon type="eye" size={14} color="#999" />
</span>
{formattedNum(impressions)}
</div>
<div className={'keyword_visits text-center inline-block mt-4 mr-5 ml-5 lg:flex-1 lg:m-0 max-w-[70px] lg:max-w-none lg:pr-5'}>
<span className='mr-3 lg:hidden'>
<Icon type="cursor" size={14} color="#999" />
</span>
{formattedNum(clicks)}
</div>
<div className='keyword_ctr text-center inline-block mt-4 relative lg:flex-1 lg:m-0 '>
<span className='mr-3 lg:hidden'>
<Icon type="target" size={14} color="#999" />
</span>
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(ctr)}%
</div>
</div>
);
};
export default SCKeyword;

View File

@@ -0,0 +1,224 @@
import { useRouter } from 'next/router';
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { useAddKeywords, useFetchKeywords } from '../../services/keywords';
import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/client/SCsortFilter';
import Icon from '../common/Icon';
import KeywordFilters from './KeywordFilter';
import SCKeyword from './SCKeyword';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
import { formattedNum } from '../../utils/client/helpers';
type SCKeywordsTableProps = {
domain: DomainType | null,
keywords: SearchAnalyticsItem[],
isLoading: boolean,
isConsoleIntegrated: boolean,
}
type SCCountryDataType = {
keywords: number,
impressions: number,
visits: number
}
const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleIntegrated = true }: SCKeywordsTableProps) => {
const router = useRouter();
const [device, setDevice] = useState<string>('desktop');
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('imp_desc');
const [SCListHeight, setSCListHeight] = useState(500);
const { keywordsData } = useFetchKeywords(router, domain?.domain || '');
const addedkeywords: string[] = keywordsData?.keywords?.map((key: KeywordType) => `${key.keyword}:${key.country}:${key.device}`) || [];
const { mutate: addKeywords } = useAddKeywords(() => { if (domain && domain.slug) router.push(`/domain/${domain.slug}`); });
const [isMobile] = useIsMobile();
useWindowResize(() => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400)));
const finalKeywords: {[key:string] : SCKeywordType[] } = useMemo(() => {
const procKeywords = keywords.filter((x) => x.device === device);
const filteredKeywords = SCfilterKeywords(procKeywords, filterParams);
const sortedKeywords = SCsortKeywords(filteredKeywords, sortBy);
return SCkeywordsByDevice(sortedKeywords, device);
}, [keywords, device, filterParams, sortBy]);
const SCCountryData: {[key:string] : SCCountryDataType } = useMemo(() => {
const countryData:{[key:string] : SCCountryDataType } = {};
Object.keys(finalKeywords).forEach((dateKey) => {
finalKeywords[dateKey].forEach((keyword) => {
const kCountry = keyword.country;
if (!countryData[kCountry]) { countryData[kCountry] = { keywords: 0, impressions: 0, visits: 0 }; }
countryData[kCountry].keywords += 1;
countryData[kCountry].visits += (keyword.clicks || 0);
countryData[kCountry].impressions += (keyword.impressions || 0);
});
});
return countryData;
}, [finalKeywords]);
const viewSummary: {[key:string] : number } = useMemo(() => {
const keyCount = finalKeywords[device].length;
const kwSummary = { position: 0, impressions: 0, visits: 0, ctr: 0 };
finalKeywords[device].forEach((k) => {
kwSummary.position += k.position;
kwSummary.impressions += k.impressions;
kwSummary.visits += k.clicks;
kwSummary.ctr += k.ctr;
});
return {
...kwSummary,
position: Math.round(kwSummary.position / keyCount),
ctr: kwSummary.ctr / keyCount,
};
}, [finalKeywords, device]);
const selectKeyword = (keywordID: string) => {
console.log('Select Keyword: ', keywordID);
let updatedSelectd = [...selectedKeywords, keywordID];
if (selectedKeywords.includes(keywordID)) {
updatedSelectd = selectedKeywords.filter((keyID) => keyID !== keywordID);
}
setSelectedKeywords(updatedSelectd);
};
const addSCKeywordsToTracker = () => {
const selectedkeywords:KeywordAddPayload[] = [];
keywords.forEach((kitem:SCKeywordType) => {
if (selectedKeywords.includes(kitem.uid)) {
const { keyword, country } = kitem;
selectedkeywords.push({ keyword, device, country, domain: domain?.domain || '', tags: '' });
}
});
addKeywords(selectedkeywords);
setSelectedKeywords([]);
};
const selectedAllItems = selectedKeywords.length === finalKeywords[device].length;
const Row = ({ data, index, style }:ListChildComponentProps) => {
const keyword = data[index];
return (
<SCKeyword
key={keyword.uid}
style={style}
selected={selectedKeywords.includes(keyword.uid)}
selectKeyword={selectKeyword}
keywordData={keyword}
isTracked={addedkeywords.includes(`${keyword.keyword}:${keyword.country}:${keyword.device}`)}
lastItem={index === (finalKeywords[device].length - 1)}
/>
);
};
return (
<div>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'>
{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={() => addSCKeywordsToTracker()}
>
<span className=' bg-indigo-100 text-blue-700 px-1 rounded font-black'>+</span> Add Keywords to Tracker
</a>
</li>
</ul>
</div>
)}
{selectedKeywords.length === 0 && (
<KeywordFilters
allTags={[]}
filterParams={filterParams}
filterKeywords={(params:KeywordFilters) => setFilterParams(params)}
updateSort={(sorted:string) => setSortBy(sorted)}
sortBy={sortBy}
keywords={keywords}
device={device}
setDevice={setDevice}
isConsole={true}
integratedConsole={isConsoleIntegrated}
SCcountries={Object.keys(SCCountryData)}
/>
)}
<div className='domkeywordsTable domkeywordsTable--sckeywords 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 '>
{finalKeywords[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 ? [] : finalKeywords[device].map((k: SearchAnalyticsItem) => k.uid))}
>
<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_imp flex-1 text-center'>Impressions</span>
<span className='domKeywords_head_visits flex-1 text-center'>Visits</span>
<span className='domKeywords_head_ctr flex-1 text-center'>CTR</span>
</div>
<div className='domKeywords_keywords border-gray-200 min-h-[55vh] relative' data-domain={domain?.domain}>
{!isLoading && finalKeywords[device] && finalKeywords[device].length > 0 && (
<List
innerElementType="div"
itemData={finalKeywords[device]}
itemCount={finalKeywords[device].length}
itemSize={isMobile ? 100 : 57}
height={SCListHeight}
width={'100%'}
className={'styled-scrollbar'}
>
{Row}
</List>
)}
{!isLoading && finalKeywords[device] && finalKeywords[device].length > 0 && (
<div className={`domKeywords_head 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 font-semibold'>
{finalKeywords[device].length} {device} Keywords
</span>
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>{viewSummary.position}</span>
<span className='domKeywords_head_imp flex-1 text-center'>
{formattedNum(viewSummary.impressions)}
</span>
<span className='domKeywords_head_visits flex-1 text-center'>
{formattedNum(viewSummary.visits)}
</span>
<span className='domKeywords_head_ctr flex-1 text-center'>
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(viewSummary.ctr)}%
</span>
</div>
)}
{isConsoleIntegrated && !isLoading && finalKeywords[device].length === 0 && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>
Could Not fetch Keyword Data for this Domain from Google Search Console.
</p>
)}
{isConsoleIntegrated && isLoading && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>Loading Keywords...</p>
)}
{!isConsoleIntegrated && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>
Google Search Console has not been Integrated yet. Please follow <a className='text-indigo-600 underline' href='https://docs.serpbear.com/miscellaneous/integrate-google-search-console' target="_blank" rel='noreferrer'>These Steps</a> to integrate Google Search Data for this Domain.
</p>
)}
</div>
</div>
</div>
</div>
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};
export default SCKeywordsTable;

View File

@@ -0,0 +1,105 @@
import React from 'react';
import SelectField from '../common/SelectField';
import SecretField from '../common/SecretField';
import InputField from '../common/InputField';
type NotificationSettingsProps = {
settings: SettingsType,
settingsError: null | {
type: string,
msg: string
},
updateSettings: Function,
}
const NotificationSettings = ({ settings, settingsError, updateSettings }:NotificationSettingsProps) => {
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__input mb-5">
<SelectField
label='Notification Frequency'
multiple={false}
selected={[settings.notification_interval]}
options={[
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Never', value: 'never' },
]}
defaultLabel={'Notification Settings'}
updateField={(updated:string[]) => updated[0] && updateSettings('notification_interval', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={220}
/>
</div>
{settings.notification_interval !== 'never' && (
<>
<div className="settings__section__input mb-5">
<InputField
label='Notification Emails'
hasError={settingsError?.type === 'no_email'}
value={settings?.notification_email}
placeholder={'test@gmail.com, test2@test.com'}
onChange={(value:string) => updateSettings('notification_email', value)}
/>
</div>
<div className="settings__section__input mb-5">
<InputField
label='SMTP Server'
hasError={settingsError?.type === 'no_smtp_server'}
value={settings?.smtp_server || ''}
placeholder={'test@gmail.com, test2@test.com'}
onChange={(value:string) => updateSettings('smtp_server', value)}
/>
</div>
<div className="settings__section__input mb-5">
<InputField
label='SMTP Port'
hasError={settingsError?.type === 'no_smtp_port'}
value={settings?.smtp_port || ''}
placeholder={'2234'}
onChange={(value:string) => updateSettings('smtp_port', value)}
/>
</div>
<div className="settings__section__input mb-5">
<InputField
label='SMTP Username'
hasError={settingsError?.type === 'no_smtp_port'}
value={settings?.smtp_username || ''}
onChange={(value:string) => updateSettings('smtp_username', value)}
/>
</div>
<div className="settings__section__input mb-5">
<SecretField
label='SMTP Password'
value={settings?.smtp_password || ''}
onChange={(value:string) => updateSettings('smtp_password', value)}
/>
</div>
<div className="settings__section__input mb-5">
<InputField
label='From Email Address'
hasError={settingsError?.type === 'no_smtp_from'}
value={settings?.notification_email_from || ''}
placeholder="no-reply@mydomain.com"
onChange={(value:string) => updateSettings('notification_email_from', value)}
/>
</div>
</>
)}
</div>
{settingsError?.msg && (
<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>
);
};
export default NotificationSettings;

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { useClearFailedQueue } from '../../services/settings';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import SecretField from '../common/SecretField';
import ToggleField from '../common/ToggleField';
type ScraperSettingsProps = {
settings: SettingsType,
settingsError: null | {
type: string,
msg: string
},
updateSettings: Function,
}
const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSettingsProps) => {
const { mutate: clearFailedMutate, isLoading: clearingQueue } = useClearFailedQueue(() => {});
const scrapingOptions: SelectionOption[] = [
{ label: 'Daily', value: 'daily' },
{ label: 'Every Other Day', value: 'other_day' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Never', value: 'never' },
];
const delayOptions: SelectionOption[] = [
{ label: 'No Delay', value: '0' },
{ label: '5 Seconds', value: '5000' },
{ label: '10 Seconds', value: '10000' },
{ label: '30 Seconds', value: '30000' },
{ label: '1 Minutes', value: '60000' },
{ label: '2 Minutes', value: '120000' },
{ label: '5 Minutes', value: '300000' },
{ label: '10 Minutes', value: '600000' },
{ label: '15 Minutes', value: '900000' },
{ label: '30 Minutes', value: '1800000' },
];
const allScrapers: SelectionOption[] = settings.available_scapers ? settings.available_scapers : [];
const scraperOptions: SelectionOption[] = [{ label: 'None', value: 'none' }, ...allScrapers];
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__select mb-5">
<SelectField
label='Scraping Method'
options={scraperOptions}
selected={[settings.scraper_type || 'none']}
defaultLabel="Select Scraper"
updateField={(updatedTime:[string]) => updateSettings('scraper_type', updatedTime[0])}
multiple={false}
rounded={'rounded'}
minWidth={220}
/>
</div>
{settings.scraper_type !== 'none' && settings.scraper_type !== 'proxy' && (
<SecretField
label='Scraper API Key or Token'
placeholder={'API Key/Token'}
value={settings?.scaping_api || ''}
hasError={settingsError?.type === 'no_api_key'}
onChange={(value:string) => updateSettings('scaping_api', value)}
/>
)}
{settings.scraper_type === 'proxy' && (
<div className="settings__section__input mb-5">
<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?.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>
)}
{settings.scraper_type !== 'none' && (
<div className="settings__section__input mb-5">
<SelectField
label='Scraping Frequency'
multiple={false}
selected={[settings?.scrape_interval || 'daily']}
options={scrapingOptions}
defaultLabel={'Notification Settings'}
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_interval', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={220}
/>
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
</div>
)}
<div className="settings__section__input mb-5">
<SelectField
label='keyword Scrape Delay'
multiple={false}
selected={[settings?.scrape_delay || '0']}
options={delayOptions}
defaultLabel={'Delay Settings'}
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_delay', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={220}
/>
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
</div>
<div className="settings__section__input mb-5">
<ToggleField
label='Auto Retry Failed Keyword Scrape'
value={settings?.scrape_retry ? 'true' : '' }
onChange={(val) => updateSettings('scrape_retry', val)}
/>
</div>
{settings?.scrape_retry && (settings.failed_queue?.length || 0) > 0 && (
<div className="settings__section__input mb-5">
<label className={labelStyle}>Clear Failed Retry Queue</label>
<button
onClick={() => clearFailedMutate()}
className=' py-3 px-5 w-full rounded cursor-pointer bg-gray-100 text-gray-800
font-semibold text-sm hover:bg-gray-200'>
{clearingQueue && <Icon type="loading" size={14} />} Clear Failed Queue
({settings.failed_queue?.length || 0} Keywords)
</button>
</div>
)}
</div>
</div>
);
};
export default ScraperSettings;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import ToggleField from '../common/ToggleField';
import InputField from '../common/InputField';
type SearchConsoleSettingsProps = {
settings: SettingsType,
settingsError: null | {
type: string,
msg: string
},
updateSettings: Function,
}
const SearchConsoleSettings = ({ settings, settingsError, updateSettings }:SearchConsoleSettingsProps) => {
return (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
{/* <div className="settings__section__input mb-5">
<ToggleField
label='Enable Goolge Search Console'
value={settings?.scrape_retry ? 'true' : '' }
onChange={(val) => updateSettings('scrape_retry', val)}
/>
</div> */}
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<InputField
label='Search Console Client Email'
onChange={(client_email:string) => updateSettings('search_console_client_email', client_email)}
value={settings.search_console_client_email}
placeholder='myapp@appspot.gserviceaccount.com'
/>
</div>
<div className="settings__section__input mb-4 flex flex-col justify-between items-center w-full">
<label className='mb-2 font-semibold block text-sm text-gray-700 capitalize w-full'>Search Console Private Key</label>
<textarea
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
focus:outline-none h-[100px] focus:border-blue-200`}
value={settings.search_console_private_key}
placeholder={'-----BEGIN PRIVATE KEY-----/ssssaswdkihad....'}
onChange={(event) => updateSettings('search_console_private_key', event.target.value)}
/>
</div>
</div>
</div>
);
};
export default SearchConsoleSettings;

View File

@@ -1,8 +1,11 @@
import React, { useEffect, useState } from 'react';
// import { useQuery } from 'react-query';
import useUpdateSettings, { useFetchSettings } from '../../services/settings';
import { Toaster } from 'react-hot-toast';
import { useFetchSettings, useUpdateSettings } from '../../services/settings';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import NotificationSettings from './NotificationSettings';
import ScraperSettings from './ScraperSettings';
import useOnKey from '../../hooks/useOnKey';
import SearchConsoleSettings from './SearchConsoleSettings';
type SettingsProps = {
closeSettings: Function,
@@ -14,8 +17,10 @@ type SettingsError = {
msg: string
}
const defaultSettings = {
const defaultSettings: SettingsType = {
scraper_type: 'none',
scrape_delay: 'none',
scrape_retry: false,
notification_interval: 'daily',
notification_email: '',
smtp_server: '',
@@ -23,6 +28,9 @@ const defaultSettings = {
smtp_username: '',
smtp_password: '',
notification_email_from: '',
search_console: true,
search_console_client_email: '',
search_console_private_key: '',
};
const Settings = ({ closeSettings }:SettingsProps) => {
@@ -31,6 +39,7 @@ const Settings = ({ closeSettings }:SettingsProps) => {
const [settingsError, setSettingsError] = useState<SettingsError|null>(null);
const { mutate: updateMutate, isLoading: isUpdating } = useUpdateSettings(() => console.log(''));
const { data: appSettings, isLoading } = useFetchSettings();
useOnKey('Escape', closeSettings);
useEffect(() => {
if (appSettings && appSettings.settings) {
@@ -38,48 +47,32 @@ const Settings = ({ closeSettings }:SettingsProps) => {
}
}, [appSettings]);
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
console.log(event.key);
closeSettings();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeSettings]);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (e.target === e.currentTarget) { closeSettings(); }
};
const updateSettings = (key: string, value:string|number) => {
const updateSettings = (key: string, value:string|number|boolean) => {
setSettings({ ...settings, [key]: value });
};
const performUpdate = () => {
let error: null|SettingsError = null;
if (settings.notification_interval !== 'never') {
const { notification_interval, notification_email, notification_email_from, scraper_type, smtp_port, smtp_server, scaping_api } = settings;
if (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)) {
if (notification_email && (!smtp_port || !smtp_server || !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'; }
if (!smtp_port) { type = 'no_smtp_port'; }
if (!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) {
if (scraper_type !== 'proxy' && scraper_type !== 'none' && !scaping_api) {
error = { type: 'no_api_key', msg: 'Insert a Valid API Key or Token for the Scraper Service.' };
}
@@ -92,185 +85,51 @@ const Settings = ({ closeSettings }:SettingsProps) => {
}
};
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
const tabStyle = `inline-block px-3 py-2 rounded-md cursor-pointer text-xs lg:text-sm lg:mr-3 lg:px-4 select-none z-10
text-gray-600 border border-b-0 relative top-[1px] rounded-b-none`;
const tabStyleActive = 'bg-white text-blue-600 border-slate-200';
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} >
<div className="absolute w-full max-w-md 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'>
<div className='settings__header px-5 py-4 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'
className=' absolute top-2 right-2 p-2 px- 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 '>
<div className='border border-slate-200 px-3 py-4 pb-0 border-l-0 border-r-0 bg-[#f8f9ff]'>
<ul>
<li
className={`${tabStyle} ${currentTab === 'scraper' ? ' bg-blue-50 text-blue-600' : ''}`}
className={`${tabStyle} ${currentTab === 'scraper' ? tabStyleActive : 'border-transparent '}`}
onClick={() => setCurrentTab('scraper')}>
Scraper
<Icon type='scraper' /> Scraper
</li>
<li
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-blue-50 text-blue-600' : ''}`}
className={`${tabStyle} ${currentTab === 'notification' ? tabStyleActive : 'border-transparent'}`}
onClick={() => setCurrentTab('notification')}>
Notification
<Icon type='email' /> Notification
</li>
<li
className={`${tabStyle} ${currentTab === 'searchconsole' ? tabStyleActive : 'border-transparent'}`}
onClick={() => setCurrentTab('searchconsole')}>
<Icon type='google' size={14} /> Search Console
</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 === 'scraper' && settings && (
<ScraperSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
{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>
)}
{currentTab === 'notification' && settings && (
<NotificationSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
{currentTab === 'searchconsole' && settings && (
<SearchConsoleSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
<div className=' border-t-[1px] border-gray-200 p-2 px-3'>
<button
onClick={() => performUpdate()}
@@ -279,6 +138,7 @@ const Settings = ({ closeSettings }:SettingsProps) => {
</button>
</div>
</div>
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};

123
cron.js
View File

@@ -1,7 +1,7 @@
const Cryptr = require('cryptr');
const { promises } = require('fs');
const { readFile } = require('fs');
const cron = require('node-cron');
const Cron = require('croner');
require('dotenv').config({ path: './.env.local' });
const getAppSettings = async () => {
@@ -35,8 +35,7 @@ const getAppSettings = async () => {
}
return decryptedSettings;
} catch (error) {
console.log(error);
// console.log('CRON ERROR: Reading Settings File. ', error);
await promises.writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(defaultSettings), { encoding: 'utf-8' });
return defaultSettings;
}
@@ -50,8 +49,14 @@ const generateCronTime = (interval) => {
if (interval === 'daily') {
cronTime = '0 0 0 * * *';
}
if (interval === 'other_day') {
cronTime = '0 0 2-30/2 * *';
}
if (interval === 'daily_morning') {
cronTime = '0 0 3 * * *';
}
if (interval === 'weekly') {
cronTime = '0 0 0 */7 * *';
cronTime = '0 0 * * 1';
}
if (interval === 'monthly') {
cronTime = '0 0 1 * *'; // Run every first day of the month at 00:00(midnight)
@@ -61,37 +66,65 @@ const generateCronTime = (interval) => {
};
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 });
getAppSettings().then((settings) => {
// RUN SERP Scraping CRON (EveryDay at Midnight) 0 0 0 * *
const scrape_interval = settings.scrape_interval || 'daily';
if (scrape_interval !== 'never') {
const scrapeCronTime = generateCronTime(scrape_interval);
Cron(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 SERP Scraper Cron Request..');
console.log(err);
});
}, { scheduled: true });
}
// RUN Email Notification CRON
const notif_interval = (!settings.notification_interval || settings.notification_interval === 'never') ? false : settings.notification_interval;
if (notif_interval) {
const cronTime = generateCronTime(notif_interval === 'daily' ? 'daily_morning' : notif_interval);
if (cronTime) {
Cron(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 Email Notification Request..');
console.log(err);
});
}, { scheduled: true });
}
}
});
// Run Failed scraping CRON (Every Hour)
const failedCronTime = generateCronTime('hourly');
cron.schedule(failedCronTime, () => {
Cron(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);
});
try {
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 failed_queue Cron Request..');
console.log(fetchErr);
});
}
} catch (error) {
console.log('ERROR Reading Failed Scrapes Queue File..', error);
}
} else {
console.log('ERROR Reading Failed Scrapes Queue File..', err);
@@ -99,26 +132,20 @@ const runAppCronJobs = () => {
});
}, { 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 });
}
}
});
// Run Google Search Console Scraper Daily
if (process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) {
const searchConsoleCRONTime = generateCronTime('daily');
Cron(searchConsoleCRONTime, () => {
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/searchconsole`, fetchOpts)
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => {
console.log('ERROR Making Google Search Console Scraper Cron Request..');
console.log(err);
});
}, { scheduled: true });
}
};
runAppCronJobs();

14
database/config.js Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
production: {
username: process.env.USER_NAME ? process.env.USER_NAME : process.env.USER,
password: process.env.PASSWORD,
database: 'sequelize',
host: 'database',
port: 3306,
dialect: 'sqlite',
storage: './data/database.sqlite',
dialectOptions: {
bigNumberStrings: true,
},
},
};

View File

@@ -6,7 +6,7 @@ import Keyword from './models/keyword';
const connection = new Sequelize({
dialect: 'sqlite',
host: '0.0.0.0',
username: process.env.USER,
username: process.env.USER_NAME ? process.env.USER_NAME : process.env.USER,
password: process.env.PASSWORD,
database: 'sequelize',
dialectModule: sqlite3,

View File

@@ -0,0 +1,45 @@
// Migration: Adds city, latlong and settings keyword to keyword table.
// CLI Migration
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(async (t) => {
try {
const keywordTableDefinition = await queryInterface.describeTable('keyword');
if (keywordTableDefinition) {
if (!keywordTableDefinition.city) {
await queryInterface.addColumn('keyword', 'city', { type: Sequelize.DataTypes.STRING }, { transaction: t });
}
if (!keywordTableDefinition.latlong) {
await queryInterface.addColumn('keyword', 'latlong', { type: Sequelize.DataTypes.STRING }, { transaction: t });
}
if (!keywordTableDefinition.settings) {
await queryInterface.addColumn('keyword', 'settings', { type: Sequelize.DataTypes.STRING }, { transaction: t });
}
}
} catch (error) {
console.log('error :', error);
}
});
},
down: (queryInterface) => {
return queryInterface.sequelize.transaction(async (t) => {
try {
const keywordTableDefinition = await queryInterface.describeTable('keyword');
if (keywordTableDefinition) {
if (keywordTableDefinition.city) {
await queryInterface.removeColumn('keyword', 'city', { transaction: t });
}
if (keywordTableDefinition.latlong) {
await queryInterface.removeColumn('keyword', 'latlong', { transaction: t });
}
if (keywordTableDefinition.latlong) {
await queryInterface.removeColumn('keyword', 'settings', { transaction: t });
}
}
} catch (error) {
console.log('error :', error);
}
});
},
};

View File

@@ -0,0 +1,29 @@
// Migration: Adds search_console field to domain table to assign search console property type, url and api.
// CLI Migration
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(async (t) => {
try {
const domainTableDefinition = await queryInterface.describeTable('domain');
if (domainTableDefinition && !domainTableDefinition.search_console) {
await queryInterface.addColumn('domain', 'search_console', { type: Sequelize.DataTypes.STRING }, { transaction: t });
}
} catch (error) {
console.log('error :', error);
}
});
},
down: (queryInterface) => {
return queryInterface.sequelize.transaction(async (t) => {
try {
const domainTableDefinition = await queryInterface.describeTable('domain');
if (domainTableDefinition && domainTableDefinition.search_console) {
await queryInterface.removeColumn('domain', 'search_console', { transaction: t });
}
} catch (error) {
console.log('error :', error);
}
});
},
};

View File

@@ -38,6 +38,9 @@ class Domain extends Model {
@Column({ type: DataType.STRING, allowNull: true, defaultValue: '' })
notification_emails!: string;
@Column({ type: DataType.STRING, allowNull: true })
search_console!: string;
}
export default Domain;

View File

@@ -19,7 +19,13 @@ class Keyword extends Model {
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'US' })
country!: string;
@Column({ type: DataType.STRING, allowNull: false })
@Column({ type: DataType.STRING, allowNull: true, defaultValue: '' })
city!: string;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: '' })
latlong!: string;
@Column({ type: DataType.STRING, allowNull: false, defaultValue: '{}' })
domain!: string;
// @ForeignKey(() => Domain)
@@ -58,6 +64,9 @@ class Keyword extends Model {
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'false' })
lastUpdateError!: string;
@Column({ type: DataType.STRING, allowNull: true })
settings!: string;
}
export default Keyword;

View File

@@ -259,13 +259,26 @@
color: #fff;
border-radius: 4px 4px 0 0;
}
.mainhead td {
.mainhead td, .subhead td {
padding: 15px;
}
.mainhead a{
color: white;
text-decoration: none;
}
.subhead {
background: #dee3ff;
color: #344dd7;
border-radius: 4px 4px 0 0;
}
.subhead a {
color: #344dd7;
text-decoration: none;
}
.console_table{
margin-top: 40px;
margin-bottom: 20px;
}
.keyword_table td {
padding: 10px 0;
}
@@ -277,13 +290,16 @@
.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){
.keyword_table th:nth-child(2), .keyword_table th:nth-child(3), .keyword td:nth-child(2), .keyword td:nth-child(3), .keyword_table--sc th:nth-child(4), .keyword_table--sc td:nth-child(4){
text-align: center;
}
.keyword td:nth-child(3){
font-size: 12px;
color: #888;
}
.keyword_table--sc td:nth-child(3){
color:inherit;
}
.keyword svg {
width: 15px;
}
@@ -304,6 +320,9 @@
vertical-align: middle;
opacity: 0.6;
}
.google_icon{
max-width: 13px;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@@ -437,6 +456,9 @@
<!-- END MAIN CONTENT AREA -->
</table>
{{SCStatsTable}}
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->

3
entrypoint.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
npx sequelize-cli db:migrate --env production
exec "$@"

12
hooks/useIsMobile.tsx Normal file
View File

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

17
hooks/useOnKey.tsx Normal file
View File

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

13
hooks/useWindowResize.tsx Normal file
View File

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

View File

@@ -1,11 +1,26 @@
// eslint-disable-next-line no-unused-vars
import 'isomorphic-fetch';
import './styles/globals.css';
import '@testing-library/jest-dom';
import { enableFetchMocks } from 'jest-fetch-mock';
// Optional: configure or set up a testing framework before each test.
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
// Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';
window.matchMedia = (query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
});
global.ResizeObserver = require('resize-observer-polyfill');
// Enable Fetch Mocking
enableFetchMocks();

View File

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

16074
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "serpbear",
"version": "0.1.2",
"version": "1.0.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -9,13 +9,16 @@
"cron": "node cron.js",
"start:all": "concurrently npm:start npm:cron",
"lint": "next lint",
"lint:css": "stylelint styles/*.css",
"test": "jest --watch --verbose",
"test:ci": "jest --ci",
"test:cv": "jest --coverage --coverageDirectory='coverage'",
"db:migrate": "sequelize-cli db:migrate --env production",
"db:revert": "sequelize-cli db:migrate:undo --env production",
"release": "standard-version"
},
"dependencies": {
"@testing-library/react": "^13.4.0",
"@googleapis/searchconsole": "^1.0.0",
"@types/react-transition-group": "^4.4.5",
"axios": "^1.1.3",
"axios-retry": "^3.3.1",
@@ -23,16 +26,16 @@
"cheerio": "^1.0.0-rc.12",
"concurrently": "^7.6.0",
"cookies": "^0.8.0",
"croner": "^5.3.5",
"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",
"jsonwebtoken": "^9.0.2",
"msw": "^0.49.0",
"next": "12.3.1",
"node-cron": "^3.0.2",
"nodemailer": "^6.8.0",
"next": "^12.3.4",
"nodemailer": "^6.9.9",
"react": "18.2.0",
"react-chartjs-2": "^4.3.1",
"react-dom": "18.2.0",
@@ -40,33 +43,42 @@
"react-query": "^3.39.2",
"react-timeago": "^7.1.0",
"react-transition-group": "^4.4.5",
"react-window": "^1.8.8",
"reflect-metadata": "^0.1.13",
"sequelize": "^6.25.2",
"sequelize-typescript": "^2.1.5",
"sqlite3": "^5.1.2"
"sequelize": "^6.34.0",
"sequelize-typescript": "^2.1.6",
"sqlite3": "^5.1.6",
"umzug": "^3.6.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.0.0",
"@types/cookies": "^0.7.7",
"@types/cryptr": "^4.0.1",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^29.5.8",
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "18.11.0",
"@types/nodemailer": "^6.4.6",
"@types/react": "18.0.21",
"@types/react-dom": "18.0.6",
"@types/react-timeago": "^4.1.3",
"@types/react-window": "^1.8.5",
"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",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"next-router-mock": "^0.9.10",
"postcss": "^8.4.31",
"prettier": "^2.7.1",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.55.0",
"sequelize-cli": "^6.6.2",
"standard-version": "^9.5.0",
"stylelint-config-standard": "^29.0.0",
"tailwindcss": "^3.1.8",
"typescript": "4.8.4"
}

View File

@@ -5,7 +5,13 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
function MyApp({ Component, pageProps }: AppProps) {
const [queryClient] = React.useState(() => new QueryClient());
const [queryClient] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
}));
return <QueryClientProvider client={queryClient}>
<Component {...pageProps} />
<ReactQueryDevtools initialIsOpen={false} />

29
pages/api/clearfailed.ts Normal file
View File

@@ -0,0 +1,29 @@
import { writeFile } from 'fs/promises';
import type { NextApiRequest, NextApiResponse } from 'next';
import verifyUser from '../../utils/verifyUser';
type SettingsGetResponse = {
cleared?: boolean,
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 === 'PUT') {
return clearFailedQueue(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const clearFailedQueue = async (req: NextApiRequest, res: NextApiResponse<SettingsGetResponse>) => {
try {
await writeFile(`${process.cwd()}/data/failed_queue.json`, JSON.stringify([]), { encoding: 'utf-8' });
return res.status(200).json({ cleared: true });
} catch (error) {
console.log('[ERROR] Cleraring Failed Queue File.', error);
return res.status(200).json({ error: 'Error Cleraring Failed Queue!' });
}
};

View File

@@ -3,7 +3,7 @@ import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser';
import { refreshAndUpdateKeywords } from './refresh';
import refreshAndUpdateKeywords from '../../utils/refresh';
type CRONRefreshRes = {
started: boolean
@@ -35,7 +35,7 @@ const cronRefreshkeywords = async (req: NextApiRequest, res: NextApiResponse<CRO
return res.status(200).json({ started: true });
} catch (error) {
console.log('ERROR cronRefreshkeywords: ', error);
console.log('[ERROR] CRON Refreshing Keywords: ', error);
return res.status(400).json({ started: false, error: 'CRON Error refreshing keywords!' });
}
};

53
pages/api/dbmigrate.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Sequelize } from 'sequelize';
import { Umzug, SequelizeStorage } from 'umzug';
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import verifyUser from '../../utils/verifyUser';
type MigrationGetResponse = {
hasMigrations: boolean,
}
type MigrationPostResponse = {
migrated: boolean,
erroor?: string
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const authorized = verifyUser(req, res);
if (authorized === 'authorized' && req.method === 'GET') {
await db.sync();
return getMigrationStatus(req, res);
}
if (authorized === 'authorized' && req.method === 'POST') {
return migrateDatabase(req, res);
}
return res.status(401).json({ error: authorized });
}
const getMigrationStatus = async (req: NextApiRequest, res: NextApiResponse<MigrationGetResponse>) => {
const sequelize = new Sequelize({ dialect: 'sqlite', storage: './data/database.sqlite', logging: false });
const umzug = new Umzug({
migrations: { glob: 'database/migrations/*.js' },
context: sequelize.getQueryInterface(),
storage: new SequelizeStorage({ sequelize }),
logger: undefined,
});
const migrations = await umzug.pending();
// console.log('migrations :', migrations);
// const migrationsExceuted = await umzug.executed();
return res.status(200).json({ hasMigrations: migrations.length > 0 });
};
const migrateDatabase = async (req: NextApiRequest, res: NextApiResponse<MigrationPostResponse>) => {
const sequelize = new Sequelize({ dialect: 'sqlite', storage: './data/database.sqlite', logging: false });
const umzug = new Umzug({
migrations: { glob: 'database/migrations/*.js' },
context: sequelize.getQueryInterface(),
storage: new SequelizeStorage({ sequelize }),
logger: undefined,
});
const migrations = await umzug.up();
console.log('[Updated] migrations :', migrations);
return res.status(200).json({ migrated: true });
};

48
pages/api/domain.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr';
import db from '../../database/database';
import Domain from '../../database/models/domain';
import verifyUser from '../../utils/verifyUser';
type DomainGetResponse = {
domain?: DomainType | 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 getDomain(req, res);
}
return res.status(401).json({ error: authorized });
}
const getDomain = async (req: NextApiRequest, res: NextApiResponse<DomainGetResponse>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') {
return res.status(400).json({ error: 'Domain Name is Required!' });
}
try {
const query = { domain: req.query.domain as string };
const foundDomain:Domain| null = await Domain.findOne({ where: query });
const parsedDomain = foundDomain?.get({ plain: true }) || false;
if (parsedDomain && parsedDomain.search_console) {
try {
const cryptr = new Cryptr(process.env.SECRET as string);
const scData = JSON.parse(parsedDomain.search_console);
scData.client_email = scData.client_email ? cryptr.decrypt(scData.client_email) : '';
scData.private_key = scData.private_key ? cryptr.decrypt(scData.private_key) : '';
parsedDomain.search_console = JSON.stringify(scData);
} catch (error) {
console.log('[Error] Parsing Search Console Keys.');
}
}
return res.status(200).json({ domain: parsedDomain });
} catch (error) {
console.log('[ERROR] Getting Domain: ', error);
return res.status(400).json({ error: 'Error Loading Domain' });
}
};

View File

@@ -1,22 +1,26 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr';
import db from '../../database/database';
import Domain from '../../database/models/domain';
import Keyword from '../../database/models/keyword';
import getdomainStats from '../../utils/domains';
import verifyUser from '../../utils/verifyUser';
import { checkSerchConsoleIntegration, removeLocalSCData } from '../../utils/searchConsole';
type DomainsGetRes = {
domains: Domain[]
domains: DomainType[]
error?: string|null,
}
type DomainsAddResponse = {
domain: Domain|null,
domains: DomainType[]|null,
error?: string|null,
}
type DomainsDeleteRes = {
domainRemoved: number,
keywordsRemoved: number,
SCDataRemoved: boolean,
error?: string|null,
}
@@ -47,49 +51,62 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
export const getDomains = async (req: NextApiRequest, res: NextApiResponse<DomainsGetRes>) => {
const withStats = !!req?.query?.withstats;
try {
const allDomains: Domain[] = await Domain.findAll();
return res.status(200).json({ domains: allDomains });
const formattedDomains: DomainType[] = allDomains.map((el) => {
const domainItem:DomainType = el.get({ plain: true });
const scData = domainItem?.search_console ? JSON.parse(domainItem.search_console) : {};
const { client_email, private_key } = scData;
const searchConsoleData = scData ? { ...scData, client_email: client_email ? 'true' : '', private_key: private_key ? 'true' : '' } : {};
return { ...domainItem, search_console: JSON.stringify(searchConsoleData) };
});
const theDomains: DomainType[] = withStats ? await getdomainStats(formattedDomains) : allDomains;
return res.status(200).json({ domains: theDomains });
} 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().toJSON(),
added: new Date().toJSON(),
};
const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => {
const { domains } = req.body;
if (domains && Array.isArray(domains) && domains.length > 0) {
const domainsToAdd: any = [];
try {
const addedDomain = await Domain.create(domainData);
return res.status(201).json({ domain: addedDomain });
} catch (error) {
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' });
domains.forEach((domain: string) => {
domainsToAdd.push({
domain: domain.trim(),
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-').replaceAll('/', '-'),
lastUpdated: new Date().toJSON(),
added: new Date().toJSON(),
});
});
try {
const newDomains:Domain[] = await Domain.bulkCreate(domainsToAdd);
const formattedDomains = newDomains.map((el) => el.get({ plain: true }));
return res.status(201).json({ domains: formattedDomains });
} catch (error) {
console.log('[ERROR] Adding New Domain ', error);
return res.status(400).json({ domains: [], error: 'Error Adding Domain.' });
}
} else {
return res.status(400).json({ domains: [], error: 'Necessary data missing.' });
}
};
export const deleteDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsDeleteRes>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') {
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Domain is Required!' });
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Domain is Required!' });
}
try {
const { domain } = req.query || {};
const removedDomCount: number = await Domain.destroy({ where: { domain } });
const removedKeywordCount: number = await Keyword.destroy({ where: { domain } });
return res.status(200).json({
domainRemoved: removedDomCount,
keywordsRemoved: removedKeywordCount,
});
const SCDataRemoved = await removeLocalSCData(domain as string);
return res.status(200).json({ domainRemoved: removedDomCount, keywordsRemoved: removedKeywordCount, SCDataRemoved });
} catch (error) {
console.log('##### Delete Domain Error: ', error);
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Error Deleting Domain' });
console.log('[ERROR] Deleting Domain: ', req.query.domain, error);
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Error Deleting Domain' });
}
};
@@ -98,13 +115,28 @@ export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
return res.status(400).json({ domain: null, error: 'Domain is Required!' });
}
const { domain } = req.query || {};
const { notification_interval, notification_emails } = req.body;
const { notification_interval, notification_emails, search_console } = req.body as DomainSettings;
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } });
if (domainToUpdate) {
domainToUpdate.set({ notification_interval, notification_emails });
await domainToUpdate.save();
try {
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } });
// Validate Search Console API Data
if (domainToUpdate && search_console?.client_email && search_console?.private_key) {
const theDomainObj = domainToUpdate.get({ plain: true });
const isSearchConsoleAPIValid = await checkSerchConsoleIntegration({ ...theDomainObj, search_console: JSON.stringify(search_console) });
if (!isSearchConsoleAPIValid.isValid) {
return res.status(400).json({ domain: null, error: isSearchConsoleAPIValid.error });
}
const cryptr = new Cryptr(process.env.SECRET as string);
search_console.client_email = search_console.client_email ? cryptr.encrypt(search_console.client_email.trim()) : '';
search_console.private_key = search_console.private_key ? cryptr.encrypt(search_console.private_key.trim()) : '';
}
if (domainToUpdate) {
domainToUpdate.set({ notification_interval, notification_emails, search_console: JSON.stringify(search_console) });
await domainToUpdate.save();
}
return res.status(200).json({ domain: domainToUpdate });
} catch (error) {
console.log('[ERROR] Updating Domain: ', req.query.domain, error);
return res.status(400).json({ domain: null, error: 'Error Updating Domain. An Unknown Error Occured.' });
}
return res.status(200).json({ domain: domainToUpdate });
};

64
pages/api/insight.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import { getCountryInsight, getKeywordsInsight, getPagesInsight } from '../../utils/insight';
import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
import verifyUser from '../../utils/verifyUser';
import Domain from '../../database/models/domain';
type SCInsightRes = {
data: InsightDataType | 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 getDomainSearchConsoleInsight(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiResponse<SCInsightRes>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
const getInsightFromSCData = (localSCData: SCDomainDataType): InsightDataType => {
const { stats = [] } = localSCData;
const countries = getCountryInsight(localSCData);
const keywords = getKeywordsInsight(localSCData);
const pages = getPagesInsight(localSCData);
return { pages, keywords, countries, stats };
};
// First try and read the Local SC Domain Data file.
const localSCData = await readLocalSCData(domainname);
if (localSCData) {
const oldFetchedDate = localSCData.lastFetched;
const fetchTimeDiff = new Date().getTime() - (oldFetchedDate ? new Date(oldFetchedDate as string).getTime() : 0);
if (localSCData.stats && localSCData.stats.length && fetchTimeDiff <= 86400000) {
const response = getInsightFromSCData(localSCData);
return res.status(200).json({ data: response });
}
}
// If the Local SC Domain Data file does not exist, fetch from Googel Search Console.
try {
const query = { domain: domainname };
const foundDomain:Domain| null = await Domain.findOne({ where: query });
const domainObj: DomainType = foundDomain && foundDomain.get({ plain: true });
const scDomainAPI = await getSearchConsoleApiInfo(domainObj);
if (!(scDomainAPI.client_email && scDomainAPI.private_key)) {
return res.status(200).json({ data: null, error: 'Google Search Console is not Integrated.' });
}
const scData = await fetchDomainSCData(domainObj, scDomainAPI);
const response = getInsightFromSCData(scData);
return res.status(200).json({ data: response });
} catch (error) {
console.log('[ERROR] Getting Domain Insight: ', domainname, error);
return res.status(400).json({ data: null, error: 'Error Fetching Stats from Google Search Console.' });
}
};

View File

@@ -22,7 +22,6 @@ const getKeyword = async (req: NextApiRequest, res: NextApiResponse<KeywordGetRe
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) };
@@ -31,7 +30,7 @@ const getKeyword = async (req: NextApiRequest, res: NextApiResponse<KeywordGetRe
const keywords = pareseKeyword && pareseKeyword[0] ? pareseKeyword[0] : null;
return res.status(200).json({ keyword: keywords });
} catch (error) {
console.log(error);
console.log('[ERROR] Getting Keyword: ', error);
return res.status(400).json({ error: 'Error Loading Keyword' });
}
};

View File

@@ -2,10 +2,11 @@ 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';
import { integrateKeywordSCData, readLocalSCData } from '../../utils/searchConsole';
import refreshAndUpdateKeywords from '../../utils/refresh';
type KeywordsGetResponse = {
keywords?: KeywordType[],
@@ -44,11 +45,14 @@ const getKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
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('-', '.');
const domain = (req.query.domain as string);
const integratedSC = process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL;
const domainSCData = integratedSC ? await readLocalSCData(domain) : false;
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 processedKeywords = keywords.map((keyword) => {
const historyArray = Object.keys(keyword.history).map((dateKey:string) => ({
date: new Date(dateKey).getTime(),
dateRaw: dateKey,
@@ -57,29 +61,32 @@ const getKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
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 };
const keywordWithSlimHistory = { ...keyword, lastResult: [], history: lastWeekHistory };
const finalKeyword = domainSCData ? integrateKeywordSCData(keywordWithSlimHistory, domainSCData) : keywordWithSlimHistory;
return finalKeyword;
});
console.log('getKeywords: ', keywords.length);
return res.status(200).json({ keywords: slimKeywords });
return res.status(200).json({ keywords: processedKeywords });
} catch (error) {
console.log(error);
console.log('[ERROR] Getting Domain Keywords for ', domain, 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 { keywords } = req.body;
if (keywords && Array.isArray(keywords) && keywords.length > 0) {
// const keywordsArray = keywords.replaceAll('\n', ',').split(',').map((item:string) => item.trim());
const keywordsToAdd: any = []; // QuickFIX for bug: https://github.com/sequelize/sequelize-typescript/issues/936
keywordsArray.forEach((keyword: string) => {
keywords.forEach((kwrd: KeywordAddPayload) => {
const { keyword, device, country, domain, tags, city } = kwrd;
const tagsArray = tags ? tags.split(',').map((item:string) => item.trim()) : [];
const newKeyword = {
keyword,
device,
domain,
country,
city,
position: 0,
updating: true,
history: JSON.stringify({}),
@@ -100,6 +107,7 @@ const addKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
refreshAndUpdateKeywords(newKeywords, settings); // Queue the SERP Scraping Process
return res.status(201).json({ keywords: keywordsParsed });
} catch (error) {
console.log('[ERROR] Adding New Keywords ', error);
return res.status(400).json({ error: 'Could Not Add New Keyword!' });
}
} else {
@@ -119,6 +127,7 @@ const deleteKeywords = async (req: NextApiRequest, res: NextApiResponse<Keywords
const removedKeywordCount: number = await Keyword.destroy(removeQuery);
return res.status(200).json({ keywordsRemoved: removedKeywordCount });
} catch (error) {
console.log('[ERROR] Removing Keyword. ', error);
return res.status(400).json({ error: 'Could Not Remove Keyword!' });
}
};
@@ -145,17 +154,20 @@ const updateKeywords = async (req: NextApiRequest, res: NextApiResponse<Keywords
}
if (tags) {
const tagsKeywordIDs = Object.keys(tags);
const multipleKeywords = tagsKeywordIDs.length > 1;
for (const keywordID of tagsKeywordIDs) {
const response = await Keyword.findOne({ where: { ID: keywordID } });
if (response) {
await response.update({ tags: JSON.stringify(tags[keywordID]) });
const selectedKeyword = await Keyword.findOne({ where: { ID: keywordID } });
const currentTags = selectedKeyword && selectedKeyword.tags ? JSON.parse(selectedKeyword.tags) : [];
const mergedTags = Array.from(new Set([...currentTags, ...tags[keywordID]]));
if (selectedKeyword) {
await selectedKeyword.update({ tags: JSON.stringify(multipleKeywords ? mergedTags : tags[keywordID]) });
}
}
return res.status(200).json({ keywords });
}
return res.status(400).json({ error: 'Invalid Payload!' });
} catch (error) {
console.log('ERROR updateKeywords: ', error);
console.log('[ERROR] Updating Keyword. ', error);
return res.status(200).json({ error: 'Error Updating keywords!' });
}
};

View File

@@ -18,10 +18,11 @@ const loginUser = async (req: NextApiRequest, res: NextApiResponse<loginResponse
if (!req.body.username || !req.body.password) {
return res.status(401).json({ error: 'Username Password Missing' });
}
const userName = process.env.USER_NAME ? process.env.USER_NAME : process.env.USER;
if (req.body.username === process.env.USER
if (req.body.username === userName
&& req.body.password === process.env.PASSWORD && process.env.SECRET) {
const token = jwt.sign({ user: process.env.USER }, process.env.SECRET);
const token = jwt.sign({ user: userName }, process.env.SECRET);
const cookies = new Cookies(req, res);
const expireDate = new Date();
const sessDuration = process.env.SESSION_DURATION;
@@ -30,7 +31,7 @@ const loginUser = async (req: NextApiRequest, res: NextApiResponse<loginResponse
return res.status(200).json({ success: true, error: null });
}
const error = req.body.username !== process.env.USER ? 'Incorrect Username' : 'Incorrect Password';
const error = req.body.username !== userName ? 'Incorrect Username' : 'Incorrect Password';
return res.status(401).json({ success: false, error });
};

View File

@@ -21,44 +21,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const notify = async (req: NextApiRequest, res: NextApiResponse<NotifyResponse>) => {
const reqDomain = req?.query?.domain as string || '';
try {
const settings = await getAppSettings();
const {
smtp_server = '',
smtp_port = '',
smtp_username = '',
smtp_password = '',
notification_email = '',
notification_email_from = '',
} = settings;
const { smtp_server = '', smtp_port = '', notification_email = '' } = settings;
if (!smtp_server || !smtp_port || !smtp_username || !smtp_password || !notification_email) {
if (!smtp_server || !smtp_port || !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));
if (reqDomain) {
const theDomain = await Domain.findOne({ where: { domain: reqDomain } });
if (theDomain) {
await sendNotificationEmail(theDomain, settings);
}
} else {
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) {
await sendNotificationEmail(domain, settings);
}
}
}
}
@@ -69,3 +53,35 @@ const notify = async (req: NextApiRequest, res: NextApiResponse<NotifyResponse>)
return res.status(401).json({ success: false, error: 'Error Sending Notification Email.' });
}
};
const sendNotificationEmail = async (domain: Domain, settings: SettingsType) => {
const {
smtp_server = '',
smtp_port = '',
smtp_username = '',
smtp_password = '',
notification_email = '',
notification_email_from = '',
} = settings;
const fromEmail = `SerpBear <${notification_email_from || 'no-reply@serpbear.com'}>`;
const mailerSettings:any = { host: smtp_server, port: parseInt(smtp_port, 10) };
if (smtp_username || smtp_password) {
mailerSettings.auth = {};
if (smtp_username) mailerSettings.auth.user = smtp_username;
if (smtp_password) mailerSettings.auth.pass = smtp_password;
}
const transporter = nodeMailer.createTransport(mailerSettings);
const domainName = domain.domain;
const query = { where: { domain: domainName } };
const domainKeywords:Keyword[] = await Keyword.findAll(query);
const keywordsArray = domainKeywords.map((el) => el.get({ plain: true }));
const keywords: KeywordType[] = parseKeywords(keywordsArray);
const emailHTML = await generateEmail(domainName, keywords);
await transporter.sendMail({
from: fromEmail,
to: domain.notification_emails || notification_email,
subject: `[${domainName}] Keyword Positions Update`,
html: emailHTML,
}).catch((err:any) => console.log('[ERROR] Sending Notification Email for', domainName, err?.response || err));
};

View File

@@ -2,11 +2,10 @@ 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 refreshAndUpdateKeywords 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[]
@@ -63,54 +62,3 @@ const refresTheKeywords = async (req: NextApiRequest, res: NextApiResponse<Keywo
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;
};

View File

@@ -0,0 +1,67 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import Domain from '../../database/models/domain';
import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
import verifyUser from '../../utils/verifyUser';
type searchConsoleRes = {
data: SCDomainDataType|null
error?: string|null,
}
type searchConsoleCRONRes = {
status: string,
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 getDomainSearchConsoleData(req, res);
}
if (req.method === 'POST') {
return cronRefreshSearchConsoleData(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const getDomainSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleRes>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
const localSCData = await readLocalSCData(domainname);
if (localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length) {
return res.status(200).json({ data: localSCData });
}
try {
const query = { domain: domainname };
const foundDomain:Domain| null = await Domain.findOne({ where: query });
const domainObj: DomainType = foundDomain && foundDomain.get({ plain: true });
const scDomainAPI = await getSearchConsoleApiInfo(domainObj);
if (!(scDomainAPI.client_email && scDomainAPI.private_key)) {
return res.status(200).json({ data: null, error: 'Google Search Console is not Integrated.' });
}
const scData = await fetchDomainSCData(domainObj, scDomainAPI);
return res.status(200).json({ data: scData });
} catch (error) {
console.log('[ERROR] Getting Search Console Data for: ', domainname, error);
return res.status(400).json({ data: null, error: 'Error Fetching Data from Google Search Console.' });
}
};
const cronRefreshSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleCRONRes>) => {
try {
const allDomainsRaw = await Domain.findAll();
const Domains: DomainType[] = allDomainsRaw.map((el) => el.get({ plain: true }));
for (const domain of Domains) {
await fetchDomainSCData(domain);
}
return res.status(200).json({ status: 'completed' });
} catch (error) {
console.log('[ERROR] CRON Updating Search Console Data. ', error);
return res.status(400).json({ status: 'failed', error: 'Error Fetching Data from Google Search Console.' });
}
};

View File

@@ -1,7 +1,9 @@
import { writeFile, readFile } from 'fs/promises';
import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr';
import { writeFile, readFile } from 'fs/promises';
import getConfig from 'next/config';
import verifyUser from '../../utils/verifyUser';
import allScrapers from '../../scrapers/index';
type SettingsGetResponse = {
settings?: object | null,
@@ -25,7 +27,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const getSettings = async (req: NextApiRequest, res: NextApiResponse<SettingsGetResponse>) => {
const settings = await getAppSettings();
if (settings) {
return res.status(200).json({ settings });
const { publicRuntimeConfig } = getConfig();
const version = publicRuntimeConfig?.version;
return res.status(200).json({ settings: { ...settings, version } });
}
return res.status(400).json({ error: 'Error Loading Settings!' });
};
@@ -38,21 +42,26 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
}
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 };
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api.trim()) : '';
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password.trim()) : '';
const search_console_client_email = settings.search_console_client_email ? cryptr.encrypt(settings.search_console_client_email.trim()) : '';
const search_console_private_key = settings.search_console_private_key ? cryptr.encrypt(settings.search_console_private_key.trim()) : '';
const securedSettings = { ...settings, scaping_api, smtp_password, search_console_client_email, search_console_private_key };
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);
console.log('[ERROR] Updating App Settings. ', error);
return res.status(200).json({ error: 'Error Updating Settings!' });
}
};
export const getAppSettings = async () : Promise<SettingsType> => {
const screenshotAPIKey = process.env.SCREENSHOT_API || '69408-serpbear';
try {
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
const failedQueueRaw = await readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' });
const failedQueue: string[] = failedQueueRaw ? JSON.parse(failedQueueRaw) : [];
const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
let decryptedSettings = settings;
@@ -60,15 +69,28 @@ export const getAppSettings = async () : Promise<SettingsType> => {
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 };
const search_console_client_email = settings.search_console_client_email ? cryptr.decrypt(settings.search_console_client_email) : '';
const search_console_private_key = settings.search_console_private_key ? cryptr.decrypt(settings.search_console_private_key) : '';
decryptedSettings = {
...settings,
scaping_api,
smtp_password,
search_console_client_email,
search_console_private_key,
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL)
|| !!(search_console_client_email && search_console_private_key),
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id, allowsCity: !!scraper.allowsCity })),
failed_queue: failedQueue,
screenshot_key: screenshotAPIKey,
};
} catch (error) {
console.log('Error Decrypting Settings API Keys!');
}
return decryptedSettings;
} catch (error) {
console.log(error);
const settings = {
console.log('[ERROR] Getting App Settings. ', error);
const settings: SettingsType = {
scraper_type: 'none',
notification_interval: 'never',
notification_email: '',
@@ -77,8 +99,18 @@ export const getAppSettings = async () : Promise<SettingsType> => {
smtp_port: '',
smtp_username: '',
smtp_password: '',
scrape_retry: false,
screenshot_key: screenshotAPIKey,
search_console: true,
search_console_client_email: '',
search_console_private_key: '',
};
const otherSettings = {
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
failed_queue: [],
};
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(settings), { encoding: 'utf-8' });
return settings;
await writeFile(`${process.cwd()}/data/failed_queue.json`, JSON.stringify([]), { encoding: 'utf-8' });
return { ...settings, ...otherSettings };
}
};

View File

@@ -1,9 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { 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 { CSSTransition } from 'react-transition-group';
import Sidebar from '../../../components/common/Sidebar';
import TopBar from '../../../components/common/TopBar';
@@ -11,47 +9,46 @@ import DomainHeader from '../../../components/domains/DomainHeader';
import KeywordsTable from '../../../components/keywords/KeywordsTable';
import AddDomain from '../../../components/domains/AddDomain';
import DomainSettings from '../../../components/domains/DomainSettings';
import exportCSV from '../../../utils/exportcsv';
import exportCSV from '../../../utils/client/exportcsv';
import Settings from '../../../components/settings/Settings';
import { useFetchDomains } from '../../../services/domains';
import { useFetchKeywords } from '../../../services/keywords';
import { useFetchSettings } from '../../../services/settings';
import AddKeywords from '../../../components/keywords/AddKeywords';
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: appSettingsData, isLoading: isAppSettingsLoading } = useFetchSettings();
const { data: domainsData } = useFetchDomains(router);
const { keywordsData, keywordsLoading } = useFetchKeywords(router, setKeywordSPollInterval, keywordSPollInterval);
const appSettings: SettingsType = appSettingsData?.settings || {};
const { scraper_type = '', available_scapers = [] } = appSettings;
const activeScraper = useMemo(() => available_scapers.find((scraper) => scraper.value === scraper_type), [scraper_type, available_scapers]);
const theDomains: Domain[] = (domainsData && domainsData.domains) || [];
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
const activDomain: Domain|null = useMemo(() => {
let active:Domain|null = null;
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:Domain) => x.slug === router.query.slug);
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
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]);
const domainHasScAPI = useMemo(() => {
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
return !!(doaminSc?.client_email && doaminSc?.private_key);
}, [activDomain]);
// console.log('Domains Data:', router, activDomain, theKeywords);
const { keywordsData, keywordsLoading } = useFetchKeywords(router, activDomain?.domain || '', setKeywordSPollInterval, keywordSPollInterval);
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
return (
<div className="Domain ">
{noScrapprtError && (
{((!scraper_type || (scraper_type === 'none')) && !isAppSettingsLoading) && (
<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>
@@ -64,7 +61,7 @@ const SingleDomain: NextPage = () => {
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => setShowAddDomain(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">
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full">
{activDomain && activDomain.domain
&& <DomainHeader
domain={activDomain}
@@ -72,31 +69,41 @@ const SingleDomain: NextPage = () => {
showAddModal={setShowAddKeywords}
showSettingsModal={setShowDomainSettings}
exportCsv={() => exportCSV(theKeywords, activDomain.domain)}
/>}
/>
}
<KeywordsTable
isLoading={keywordsLoading}
domain={activDomain}
keywords={theKeywords}
showAddModal={showAddKeywords}
setShowAddModal={setShowAddKeywords}
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || domainHasScAPI }
/>
</div>
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<DomainSettings
domain={showDomainSettings && theDomains && activDomain && activDomain.domain ? activDomain : false}
domains={theDomains}
closeModal={setShowDomainSettings}
/>
</CSSTransition>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition>
<CSSTransition in={showAddKeywords} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddKeywords
domain={activDomain?.domain || ''}
scraperName={activeScraper?.label || ''}
keywords={theKeywords}
allowsCity={!!activeScraper?.allowsCity}
closeModal={() => setShowAddKeywords(false)}
/>
</CSSTransition>
</div>
);
};

View File

@@ -0,0 +1,95 @@
import React, { 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 { CSSTransition } from 'react-transition-group';
import Sidebar from '../../../../components/common/Sidebar';
import TopBar from '../../../../components/common/TopBar';
import DomainHeader from '../../../../components/domains/DomainHeader';
import AddDomain from '../../../../components/domains/AddDomain';
import DomainSettings from '../../../../components/domains/DomainSettings';
import exportCSV from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains';
import { useFetchSCKeywords } from '../../../../services/searchConsole';
import SCKeywordsTable from '../../../../components/keywords/SCKeywordsTable';
import { useFetchSettings } from '../../../../services/settings';
const DiscoverPage: NextPage = () => {
const router = useRouter();
const [showDomainSettings, setShowDomainSettings] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showAddDomain, setShowAddDomain] = useState(false);
const [scDateFilter, setSCDateFilter] = useState('thirtyDays');
const { data: appSettings } = useFetchSettings();
const { data: domainsData } = useFetchDomains(router);
const scConnected = !!(appSettings && appSettings?.settings?.search_console_integrated);
const { data: keywordsData, isLoading: keywordsLoading, isFetching } = useFetchSCKeywords(router, !!(domainsData?.domains?.length) && scConnected);
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
const theKeywords: SearchAnalyticsItem[] = keywordsData?.data && keywordsData.data[scDateFilter] ? keywordsData.data[scDateFilter] : [];
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
return active;
}, [router.query.slug, domainsData]);
const domainHasScAPI = useMemo(() => {
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
return !!(doaminSc?.client_email && doaminSc?.private_key);
}, [activDomain]);
return (
<div className="Domain ">
{activDomain && activDomain.domain
&& <Head>
<title>{`${activDomain.domain} - SerpBear` } </title>
</Head>
}
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => setShowAddDomain(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-8 w-full">
{activDomain && activDomain.domain
&& <DomainHeader
domain={activDomain}
domains={theDomains}
showAddModal={() => console.log('XXXXX')}
showSettingsModal={setShowDomainSettings}
exportCsv={() => exportCSV(theKeywords, activDomain.domain, scDateFilter)}
scFilter={scDateFilter}
setScFilter={(item:string) => setSCDateFilter(item)}
/>
}
<SCKeywordsTable
isLoading={keywordsLoading || isFetching}
domain={activDomain}
keywords={theKeywords}
isConsoleIntegrated={scConnected || domainHasScAPI}
/>
</div>
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<DomainSettings
domain={showDomainSettings && theDomains && activDomain && activDomain.domain ? activDomain : false}
closeModal={setShowDomainSettings}
/>
</CSSTransition>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition>
</div>
);
};
export default DiscoverPage;

View File

@@ -0,0 +1,95 @@
import React, { 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 { CSSTransition } from 'react-transition-group';
import Sidebar from '../../../../components/common/Sidebar';
import TopBar from '../../../../components/common/TopBar';
import DomainHeader from '../../../../components/domains/DomainHeader';
import AddDomain from '../../../../components/domains/AddDomain';
import DomainSettings from '../../../../components/domains/DomainSettings';
import exportCSV from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains';
import { useFetchSCInsight } from '../../../../services/searchConsole';
import SCInsight from '../../../../components/insight/Insight';
import { useFetchSettings } from '../../../../services/settings';
const InsightPage: NextPage = () => {
const router = useRouter();
const [showDomainSettings, setShowDomainSettings] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showAddDomain, setShowAddDomain] = useState(false);
const [scDateFilter, setSCDateFilter] = useState('thirtyDays');
const { data: appSettings } = useFetchSettings();
const { data: domainsData } = useFetchDomains(router);
const scConnected = !!(appSettings && appSettings?.settings?.search_console_integrated);
const { data: insightData } = useFetchSCInsight(router, !!(domainsData?.domains?.length) && scConnected);
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
const theInsight: InsightDataType = insightData && insightData.data ? insightData.data : {};
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
return active;
}, [router.query.slug, domainsData]);
const domainHasScAPI = useMemo(() => {
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
return !!(doaminSc?.client_email && doaminSc?.private_key);
}, [activDomain]);
return (
<div className="Domain ">
{activDomain && activDomain.domain
&& <Head>
<title>{`${activDomain.domain} - SerpBear` } </title>
</Head>
}
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => setShowAddDomain(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-8 w-full">
{activDomain && activDomain.domain
&& <DomainHeader
domain={activDomain}
domains={theDomains}
showAddModal={() => console.log('XXXXX')}
showSettingsModal={setShowDomainSettings}
exportCsv={() => exportCSV([], activDomain.domain, scDateFilter)}
scFilter={scDateFilter}
setScFilter={(item:string) => setSCDateFilter(item)}
/>
}
<SCInsight
isLoading={false}
domain={activDomain}
insight={theInsight}
isConsoleIntegrated={scConnected || domainHasScAPI}
/>
</div>
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<DomainSettings
domain={showDomainSettings && theDomains && activDomain && activDomain.domain ? activDomain : false}
closeModal={setShowDomainSettings}
/>
</CSSTransition>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition>
</div>
);
};
export default InsightPage;

150
pages/domains/index.tsx Normal file
View File

@@ -0,0 +1,150 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { NextPage } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { CSSTransition } from 'react-transition-group';
import toast, { Toaster } from 'react-hot-toast';
import TopBar from '../../components/common/TopBar';
import AddDomain from '../../components/domains/AddDomain';
import Settings from '../../components/settings/Settings';
import { useCheckMigrationStatus, useFetchSettings } from '../../services/settings';
import { fetchDomainScreenshot, useFetchDomains } from '../../services/domains';
import DomainItem from '../../components/domains/DomainItem';
import Icon from '../../components/common/Icon';
type thumbImages = { [domain:string] : string }
const Domains: NextPage = () => {
const router = useRouter();
// const [noScrapprtError, setNoScrapprtError] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showAddDomain, setShowAddDomain] = useState(false);
const [domainThumbs, setDomainThumbs] = useState<thumbImages>({});
const { data: appSettingsData, isLoading: isAppSettingsLoading } = useFetchSettings();
const { data: domainsData, isLoading } = useFetchDomains(router, true);
const { data: migrationStatus } = useCheckMigrationStatus();
// const { mutate: updateDatabaseMutate, isLoading: isUpdatingDB } = useMigrateDatabase((res:Object) => { window.location.reload(); });
const appSettings:SettingsType = appSettingsData?.settings || {};
const { scraper_type = '' } = appSettings;
const totalKeywords = useMemo(() => {
let keywords = 0;
if (domainsData?.domains) {
domainsData.domains.forEach(async (domain:DomainType) => {
keywords += domain?.keywordCount || 0;
});
}
return keywords;
}, [domainsData]);
const domainSCAPiObj = useMemo(() => {
const domainsSCAPI:{ [ID:string] : boolean } = {};
if (domainsData?.domains) {
domainsData.domains.forEach(async (domain:DomainType) => {
const doaminSc = domain?.search_console ? JSON.parse(domain.search_console) : {};
domainsSCAPI[domain.ID] = doaminSc.client_email && doaminSc.private_key;
});
}
return domainsSCAPI;
}, [domainsData]);
useEffect(() => {
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings.screenshot_key) {
domainsData.domains.forEach(async (domain:DomainType) => {
if (domain.domain) {
const domainThumb = await fetchDomainScreenshot(domain.domain, appSettings.screenshot_key || '');
if (domainThumb) {
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domainThumb }));
}
}
});
}
}, [domainsData, appSettings.screenshot_key]);
const manuallyUpdateThumb = async (domain: string) => {
if (domain && appSettings.screenshot_key) {
const domainThumb = await fetchDomainScreenshot(domain, appSettings.screenshot_key, true);
if (domainThumb) {
toast(`${domain} Screenshot Updated Successfully!`, { icon: '✔️' });
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain]: domainThumb }));
} else {
toast(`Failed to Fetch ${domain} Screenshot!`, { icon: '⚠️' });
}
}
};
return (
<div data-testid="domains" className="Domain flex flex-col min-h-screen">
{((!scraper_type || (scraper_type === 'none')) && !isAppSettingsLoading) && (
<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>
)}
{migrationStatus?.hasMigrations && (
<div className=' p-3 bg-black text-white text-sm text-center'>
You need to Update your database. Stop Serpbear and run this command to update your database:
<code className=' bg-gray-700 px-2 py-0 ml-1'>npm run db:migrate</code>
</div>
)}
<Head>
<title>Domains - SerpBear</title>
</Head>
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => setShowAddDomain(true)} />
<div className="flex flex-col w-full max-w-5xl mx-auto p-6 lg:mt-24 lg:p-0">
<div className='flex justify-between mb-2 items-center'>
<div className=' text-sm text-gray-600'>
{domainsData?.domains?.length || 0} Domains <span className=' text-gray-300 ml-1 mr-1'>|</span> {totalKeywords} keywords
</div>
<div>
<button
data-testid="addDomainButton"
className={'ml-2 inline-block py-2 text-blue-700 font-bold text-sm'}
onClick={() => setShowAddDomain(true)}>
<span
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 Domain</i>
</button>
</div>
</div>
<div className='flex w-full flex-col mb-8'>
{domainsData?.domains && domainsData.domains.map((domain:DomainType) => {
return <DomainItem
key={domain.ID}
domain={domain}
selected={false}
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || !!domainSCAPiObj[domain.ID] }
thumb={domainThumbs[domain.domain]}
updateThumb={manuallyUpdateThumb}
// isConsoleIntegrated={false}
/>;
})}
{isLoading && (
<div className='noDomains mt-4 p-5 py-12 rounded border text-center bg-white text-sm'>
<Icon type="loading" /> Loading Domains...
</div>
)}
{!isLoading && domainsData && domainsData.domains && domainsData.domains.length === 0 && (
<div className='noDomains mt-4 p-5 py-12 rounded border text-center bg-white text-sm'>
No Domains Found. Add a Domain to get started!
</div>
)}
</div>
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition>
<footer className='text-center flex flex-1 justify-center pb-5 items-end'>
<span className='text-gray-500 text-xs'><a href='https://github.com/towfiqi/serpbear' target="_blank" rel='noreferrer'>SerpBear v{appSettings.version || '0.0.0'}</a></span>
</footer>
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};
export default Domains;

View File

@@ -1,40 +1,14 @@
import type { NextPage } from 'next';
import { useEffect, useState } from 'react';
import { useEffect } 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(`${window.location.origin}/api/domains`)
.then((result) => {
if (result.status === 401) {
router.push('/login');
}
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);
});
if (router) router.push('/domains');
}, [router]);
return (
@@ -49,37 +23,8 @@ const Home: NextPage = () => {
<Icon type='loading' size={36} color="#999" />
</main>
<Toaster position='bottom-center' containerClassName="react_toaster" />
{!loading && domains.length === 0 && <AddDomain closeModal={() => console.log('Cannot Close Modal!')} />}
</div>
);
};
// 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;

View File

@@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
}
};

21
scrapers/index.ts Normal file
View File

@@ -0,0 +1,21 @@
import scrapingAnt from './services/scrapingant';
import scrapingRobot from './services/scrapingrobot';
import serpapi from './services/serpapi';
import serply from './services/serply';
import spaceserp from './services/spaceserp';
import proxy from './services/proxy';
import searchapi from './services/searchapi';
import valueSerp from './services/valueserp';
import serper from './services/serper';
export default [
scrapingRobot,
scrapingAnt,
serpapi,
serply,
spaceserp,
proxy,
searchapi,
valueSerp,
serper,
];

View File

@@ -0,0 +1,35 @@
import cheerio from 'cheerio';
const proxy:ScraperSettings = {
id: 'proxy',
name: 'Proxy',
website: '',
resultObjectKey: 'data',
headers: () => {
return { Accept: 'gzip,deflate,compress;' };
},
scrapeURL: (keyword) => {
return `https://www.google.com/search?num=100&q=${encodeURI(keyword.keyword)}`;
},
serpExtractor: (content) => {
const extractedResult = [];
const $ = cheerio.load(content);
let lastPosition = 0;
const mainContent = $('body').find('#main');
const children = $(mainContent).find('h3');
for (let index = 0; index < children.length; index += 1) {
const title = $(children[index]).text();
const url = $(children[index]).closest('a').attr('href');
const cleanedURL = url ? url.replaceAll(/^.+?(?=https:|$)/g, '').replaceAll(/(&).*/g, '') : '';
if (title && url) {
lastPosition += 1;
extractedResult.push({ title, url: cleanedURL, position: lastPosition });
}
}
return extractedResult;
},
};
export default proxy;

View File

@@ -0,0 +1,20 @@
const scrapingAnt:ScraperSettings = {
id: 'scrapingant',
name: 'ScrapingAnt',
website: 'scrapingant.com',
headers: (keyword) => {
// eslint-disable-next-line max-len
const mobileAgent = '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';
return keyword && keyword.device === 'mobile' ? { 'Ant-User-Agent': mobileAgent } : {};
},
scrapeURL: (keyword, settings, countryData) => {
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 = countryData[country][2];
const url = encodeURI(`https://www.google.com/search?num=100&hl=${lang}&q=${keyword.keyword}`);
return `https://api.scrapingant.com/v2/extended?url=${url}&x-api-key=${settings.scaping_api}&proxy_country=${country}&browser=false`;
},
resultObjectKey: 'result',
};
export default scrapingAnt;

View File

@@ -0,0 +1,15 @@
const scrapingRobot:ScraperSettings = {
id: 'scrapingrobot',
name: 'Scraping Robot',
website: 'scrapingrobot.com',
scrapeURL: (keyword, settings, countryData) => {
const country = keyword.country || 'US';
const device = keyword.device === 'mobile' ? '&mobile=true' : '';
const lang = countryData[country][2];
const url = encodeURI(`https://www.google.com/search?num=100&hl=${lang}&q=${keyword.keyword}`);
return `https://api.scrapingrobot.com/?token=${settings.scaping_api}&proxyCountry=${country}&render=false${device}&url=${url}`;
},
resultObjectKey: 'result',
};
export default scrapingRobot;

View File

@@ -0,0 +1,44 @@
import countries from '../../utils/countries';
interface SearchApiResult {
title: string,
link: string,
position: number,
}
const searchapi:ScraperSettings = {
id: 'searchapi',
name: 'SearchApi.io',
website: 'searchapi.io',
allowsCity: true,
headers: (keyword, settings) => {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${settings.scaping_api}`,
};
},
scrapeURL: (keyword) => {
const country = keyword.country || 'US';
const countryName = countries[country][0];
const location = keyword.city && countryName ? `&location=${encodeURI(`${keyword.city},${countryName}`)}` : '';
return `https://www.searchapi.io/api/v1/search?engine=google&q=${encodeURI(keyword.keyword)}&num=100&gl=${country}&device=${keyword.device}${location}`;
},
resultObjectKey: 'organic_results',
serpExtractor: (content) => {
const extractedResult = [];
const results: SearchApiResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SearchApiResult[];
for (const { link, title, position } of results) {
if (title && link) {
extractedResult.push({
title,
url: link,
position,
});
}
}
return extractedResult;
},
};
export default searchapi;

View File

@@ -0,0 +1,43 @@
import countries from '../../utils/countries';
interface SerpApiResult {
title: string,
link: string,
position: number,
}
const serpapi:ScraperSettings = {
id: 'serpapi',
name: 'SerpApi.com',
website: 'serpapi.com',
allowsCity: true,
headers: (keyword, settings) => {
return {
'Content-Type': 'application/json',
'X-API-Key': settings.scaping_api,
};
},
scrapeURL: (keyword, settings) => {
const countryName = countries[keyword.country || 'US'][0];
const location = keyword.city && keyword.country ? `&location=${encodeURI(`${keyword.city},${countryName}`)}` : '';
return `https://serpapi.com/search?q=${encodeURI(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}${location}&api_key=${settings.scaping_api}`;
},
resultObjectKey: 'organic_results',
serpExtractor: (content) => {
const extractedResult = [];
const results: SerpApiResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SerpApiResult[];
for (const { link, title, position } of results) {
if (title && link) {
extractedResult.push({
title,
url: link,
position,
});
}
}
return extractedResult;
},
};
export default serpapi;

View File

@@ -0,0 +1,35 @@
interface SerperResult {
title: string,
link: string,
position: number,
}
const serper:ScraperSettings = {
id: 'serper',
name: 'Serper.dev',
website: 'serper.dev',
allowsCity: true,
scrapeURL: (keyword, settings, countryData) => {
const country = keyword.country || 'US';
const lang = countryData[country][2];
return `https://google.serper.dev/search?q=${encodeURI(keyword.keyword)}&gl=${country}&hl=${lang}&num=100&apiKey=${settings.scaping_api}`;
},
resultObjectKey: 'organic',
serpExtractor: (content) => {
const extractedResult = [];
const results: SerperResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SerperResult[];
for (const { link, title, position } of results) {
if (title && link) {
extractedResult.push({
title,
url: link,
position,
});
}
}
return extractedResult;
},
};
export default serper;

View File

@@ -0,0 +1,42 @@
interface SerplyResult {
title: string,
link: string,
realPosition: number,
}
const scraperCountries = ['US', 'CA', 'IE', 'GB', 'FR', 'DE', 'SE', 'IN', 'JP', 'KR', 'SG', 'AU', 'BR'];
const serply:ScraperSettings = {
id: 'serply',
name: 'Serply',
website: 'serply.io',
headers: (keyword, settings) => {
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
return {
'Content-Type': 'application/json',
'X-User-Agent': keyword.device === 'mobile' ? 'mobile' : 'desktop',
'X-Api-Key': settings.scaping_api,
'X-Proxy-Location': country,
};
},
scrapeURL: (keyword) => {
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
return `https://api.serply.io/v1/search/q=${encodeURI(keyword.keyword)}&num=100&hl=${country}`;
},
resultObjectKey: 'result',
serpExtractor: (content) => {
const extractedResult = [];
const results: SerplyResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SerplyResult[];
for (const result of results) {
if (result.title && result.link) {
extractedResult.push({
title: result.title,
url: result.link,
position: result.realPosition,
});
}
}
return extractedResult;
},
};
export default serply;

View File

@@ -0,0 +1,40 @@
import countries from '../../utils/countries';
interface SpaceSerpResult {
title: string,
link: string,
domain: string,
position: number
}
const spaceSerp:ScraperSettings = {
id: 'spaceSerp',
name: 'Space Serp',
website: 'spaceserp.com',
allowsCity: true,
scrapeURL: (keyword, settings, countryData) => {
const country = keyword.country || 'US';
const countryName = countries[country][0];
const location = keyword.city ? `&location=${encodeURI(`${keyword.city},${countryName}`)}` : '';
const device = keyword.device === 'mobile' ? '&device=mobile' : '';
const lang = countryData[country][2];
return `https://api.spaceserp.com/google/search?apiKey=${settings.scaping_api}&q=${encodeURI(keyword.keyword)}&pageSize=100&gl=${country}&hl=${lang}${location}${device}&resultBlocks=`;
},
resultObjectKey: 'organic_results',
serpExtractor: (content) => {
const extractedResult = [];
const results: SpaceSerpResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SpaceSerpResult[];
for (const result of results) {
if (result.title && result.link) {
extractedResult.push({
title: result.title,
url: result.link,
position: result.position,
});
}
}
return extractedResult;
},
};
export default spaceSerp;

View File

@@ -0,0 +1,41 @@
import countries from '../../utils/countries';
interface ValueSerpResult {
title: string,
link: string,
position: number,
domain: string,
}
const valueSerp:ScraperSettings = {
id: 'valueserp',
name: 'Value Serp',
website: 'valueserp.com',
allowsCity: true,
scrapeURL: (keyword, settings, countryData) => {
const country = keyword.country || 'US';
const countryName = countries[country][0];
const location = keyword.city ? `&location=${encodeURI(`${keyword.city},${countryName}`)}` : '';
const device = keyword.device === 'mobile' ? '&device=mobile' : '';
const lang = countryData[country][2];
console.log(`https://api.valueserp.com/search?api_key=${settings.scaping_api}&q=${encodeURI(keyword.keyword)}&gl=${country}&hl=${lang}${device}${location}&num=100&output=json&include_answer_box=false&include_advertiser_info=false`);
return `https://api.valueserp.com/search?api_key=${settings.scaping_api}&q=${encodeURI(keyword.keyword)}&gl=${country}&hl=${lang}${device}${location}&num=100&output=json&include_answer_box=false&include_advertiser_info=false`;
},
resultObjectKey: 'organic_results',
serpExtractor: (content) => {
const extractedResult = [];
const results: ValueSerpResult[] = (typeof content === 'string') ? JSON.parse(content) : content as ValueSerpResult[];
for (const result of results) {
if (result.title && result.link) {
extractedResult.push({
title: result.title,
url: result.link,
position: result.position,
});
}
}
return extractedResult;
},
};
export default valueSerp;

View File

@@ -4,11 +4,11 @@ import { useMutation, useQuery, useQueryClient } from 'react-query';
type UpdatePayload = {
domainSettings: DomainSettings,
domain: Domain
domain: DomainType
}
export async function fetchDomains(router: NextRouter) {
const res = await fetch(`${window.location.origin}/api/domains`, { method: 'GET' });
export async function fetchDomains(router: NextRouter, withStats:boolean): Promise<{domains: DomainType[]}> {
const res = await fetch(`${window.location.origin}/api/domains${withStats ? '?withstats=true' : ''}`, { method: 'GET' });
if (res.status >= 400 && res.status < 600) {
if (res.status === 401) {
console.log('Unauthorized!!');
@@ -19,16 +19,67 @@ export async function fetchDomains(router: NextRouter) {
return res.json();
}
export function useFetchDomains(router: NextRouter) {
return useQuery('domains', () => fetchDomains(router));
export async function fetchDomain(router: NextRouter, domainName: string): Promise<{domain: DomainType}> {
if (!domainName) { throw new Error('No Domain Name Provided!'); }
const res = await fetch(`${window.location.origin}/api/domain?domain=${domainName}`, { 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 async function fetchDomainScreenshot(domain: string, screenshot_key:string, forceFetch = false): Promise<string | false> {
const domainThumbsRaw = localStorage.getItem('domainThumbs');
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
if (!domThumbs[domain] || forceFetch) {
try {
const screenshotURL = `https://image.thum.io/get/auth/${screenshot_key}/maxAge/96/width/200/https://${domain}`;
const domainImageRes = await fetch(screenshotURL);
const domainImageBlob = domainImageRes.status === 200 ? await domainImageRes.blob() : false;
if (domainImageBlob) {
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = resolve;
reader.onerror = reject;
reader.readAsDataURL(domainImageBlob);
});
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain]: imageBase }));
return imageBase;
}
return false;
} catch (error) {
return false;
}
} else if (domThumbs[domain]) {
return domThumbs[domain];
}
return false;
}
export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
return useQuery('domains', () => fetchDomains(router, withStats));
}
export function useFetchDomain(router: NextRouter, domainName:string, onSuccess: Function) {
return useQuery('domain', () => fetchDomain(router, domainName), {
onSuccess: async (data) => {
console.log('Domain Loaded!!!', data.domain);
onSuccess(data.domain);
} });
}
export function useAddDomain(onSuccess:Function) {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation(async (domainName:string) => {
return useMutation(async (domains:string[]) => {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domain: domainName }) };
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domains }) };
const res = await fetch(`${window.location.origin}/api/domains`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
@@ -37,11 +88,12 @@ export function useAddDomain(onSuccess:Function) {
}, {
onSuccess: async (data) => {
console.log('Domain Added!!!', data);
const newDomain:Domain = data.domain;
toast(`${newDomain.domain} Added Successfully!`, { icon: '✔️' });
const newDomain:DomainType[] = data.domains;
const singleDomain = newDomain.length === 1;
toast(`${singleDomain ? newDomain[0].domain : `${newDomain.length} domains`} Added Successfully!`, { icon: '✔️' });
onSuccess(false);
if (newDomain && newDomain.slug) {
router.push(`/domain/${data.domain.slug}`);
if (singleDomain) {
router.push(`/domain/${newDomain[0].slug}`);
}
queryClient.invalidateQueries(['domains']);
},
@@ -58,10 +110,11 @@ export function useUpdateDomain(onSuccess:Function) {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify(domainSettings) };
const res = await fetch(`${window.location.origin}/api/domains?domain=${domain.domain}`, fetchOpts);
const responseObj = await res.json();
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
throw new Error(responseObj?.error || 'Bad response from server');
}
return res.json();
return responseObj;
}, {
onSuccess: async () => {
console.log('Settings Updated!!!');
@@ -69,8 +122,8 @@ export function useUpdateDomain(onSuccess:Function) {
onSuccess();
queryClient.invalidateQueries(['domains']);
},
onError: () => {
console.log('Error Updating Domain Settings!!!');
onError: (error) => {
console.log('Error Updating Domain Settings!!!', error);
toast('Error Updating Domain Settings', { icon: '⚠️' });
},
});
@@ -78,7 +131,7 @@ export function useUpdateDomain(onSuccess:Function) {
export function useDeleteDomain(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async (domain:Domain) => {
return useMutation(async (domain:DomainType) => {
const res = await fetch(`${window.location.origin}/api/domains?domain=${domain.domain}`, { method: 'DELETE' });
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');

Some files were not shown because too many files have changed in this diff Show More