138 Commits
v0.2.6 ... main

Author SHA1 Message Date
towfiqi
54522b2261 chore(release): 2.0.7 2025-02-23 22:36:38 +06:00
towfiqi
495d872bb9 chore: code styling/formatting. 2025-02-23 22:35:53 +06:00
towfiqi
07eb4bd94f chore: Updated ReadMe. 2025-02-23 22:33:42 +06:00
towfiqi
36ed4cf800 fix: Resolves AdWords integration issue. 2025-02-23 22:27:59 +06:00
towfiqi
c8601ebb84 Merge branch 'main' of https://github.com/towfiqi/serpbear 2025-02-23 22:26:55 +06:00
Towfiq I.
56d8b660c5 Merge pull request #273 from phoehnel/dev/thorw-error-on-empty-results
Add error message, if returned search HTML does not contain required elements
2025-02-23 22:23:04 +06:00
Pascal Höhnel
c34c8260c7 improve error message in UI on proxy use 2025-01-17 09:36:41 +01:00
Pascal Höhnel
cab8f518bb also add result-check to proxy 2025-01-17 09:10:43 +01:00
Pascal Höhnel
6e47a6fba7 add error message, if returned HTML does not contain required elements 2025-01-17 08:24:03 +01:00
towfiqi
bf911b4e45 fix: resolves broken CDN images. 2024-12-10 20:18:06 +06:00
towfiqi
d7279512cf chore(release): 2.0.6 2024-11-15 10:43:12 +06:00
towfiqi
4fef1a9abc fix: Ensures Docker build uses matching npm package versions from package.json 2024-11-14 23:11:09 +06:00
towfiqi
aeed1f8559 fix: Resolves broken Docker build due to croner package version mismatch.
closes #247
2024-11-14 23:08:54 +06:00
towfiqi
12eac2b012 fix: Resolves Google Ads search volume data loading issue. 2024-11-14 18:31:40 +06:00
towfiqi
649f412303 fix: Resolves broken Proxy Scraper functionality.
closes #248
2024-11-14 18:30:56 +06:00
towfiqi
a2edabbdf9 chore: Upgrades vulnerable dependecies. 2024-11-14 18:28:43 +06:00
towfiqi
3690e97fe7 chore(release): 2.0.5 2024-11-12 16:29:07 +06:00
towfiqi
17fb2c40cc fix: Resolves "Add Domain" UI confusion. 2024-11-12 16:27:32 +06:00
towfiqi
faa3519a29 fix: Fixes misaligned Keywords table UI content. 2024-11-12 16:23:44 +06:00
towfiqi
bc02c929ba fix: Resolves broken Scrapingrobot scraper on new installs.
closes #243
2024-11-12 16:20:48 +06:00
towfiqi
d9d7c6347e fix: Fixes broken scrape result issue for keywords with special characters.
closes #221
2024-11-12 09:18:40 +06:00
towfiqi
c5714c00ae chore(release): 2.0.4 2024-11-10 12:54:43 +06:00
towfiqi
1bef7587cc fix: Fixes Docker build issue. 2024-11-10 12:54:32 +06:00
towfiqi
5507cac07f chore(release): 2.0.3 2024-11-10 10:31:27 +06:00
towfiqi
a74338fe15 feat: Displays Best position on mobile layout as well. 2024-11-09 21:44:04 +06:00
towfiqi
4c2f900d85 feat: Displays keyword's best position in email notification.
closes 202
2024-11-09 21:24:27 +06:00
towfiqi
040dab1517 fix: Fixes missing Search Console data in Email notification when its integrated through App settings. 2024-11-09 20:56:43 +06:00
towfiqi
29c455ea56 chore: Adds descriptive errorlogging for Adwords integration. 2024-11-09 20:33:29 +06:00
towfiqi
42c5e2be07 feat: Makes Content width a little wider. 2024-11-09 20:32:28 +06:00
towfiqi
d3e3760527 feat: Adds the ability to show hide columns in tracked keywords table.
closes #224
2024-11-09 20:31:02 +06:00
towfiqi
7597210ca2 fix: Resolves incorrect search trend graph in Ideas section.
closes #219
2024-11-08 13:06:53 +06:00
towfiqi
4b8730e416 chore: removes unwanted file from merged PR 2024-11-08 12:30:08 +06:00
towfiqi
34dce13143 style: updates merged PR formatting 2024-11-08 12:29:24 +06:00
towfiqi
3786438662 feat: Ability to keywords for both mobile and desktop at once.
closes #60, #66, #199
2024-11-08 12:28:13 +06:00
towfiqi
f48288473e fix: Fixes missing keyword city value in exported csv file.
closes #194
2024-11-08 10:25:16 +06:00
towfiqi
01b1b7b9e9 fix: Fixes incorrect position display in keyword detail view when position is above 100 2024-11-08 10:21:05 +06:00
towfiqi
a050536814 feat: Keywords Country filter now only shows relevant countries. 2024-11-08 10:14:12 +06:00
towfiqi
bc96dc7de5 fix: Resolves notification email's incorrect image size in some email clients.
closes #201
2024-11-08 09:56:54 +06:00
towfiqi
b35d333bfc feat: Adds ability to set Notification Email From name.
closes #222
2024-11-07 21:15:12 +06:00
towfiqi
a09eb62f5a feat: auto filter keywords if they already exist instead of throwing error.
closes #244
2024-11-07 21:04:57 +06:00
Towfiq I.
42a00dafad Merge pull request #237 from JvB94/FixUnrankedAgainIssues2
When a keyword position was ranking under 100 and then it goes over 100 the keyword position was not being updated.
2024-11-07 21:01:00 +06:00
Towfiq I.
432fc6161c Merge pull request #246 from lidarbtc/main
fix: Correct CTR calculation in InsightStats component
2024-11-07 20:13:46 +06:00
Towfiq I.
15a1224260 Merge pull request #235 from EpicKau/main
bugfix gsc data if specified in settings and not in .env
2024-11-07 19:51:29 +06:00
lidarbtc
232507e1ff fix: Correct CTR calculation in InsightStats component
The CTR calculation was incorrectly summing up individual CTR values,
resulting in inflated percentages. Now calculates CTR properly by
dividing total clicks by total impressions.

Previous Implementation:
- CTR was calculated by adding up individual CTR percentages
- This resulted in artificially high CTR values
- For example: [10%, 15%, 20%] => 45% (incorrect)

New Implementation:
- CTR is now calculated using (total clicks / total impressions) * 100
- This provides the actual click-through rate across all data
- For example: (50 clicks / 1000 impressions) * 100 = 5% (correct)

This fix ensures accurate CTR reporting in the analytics dashboard.

Changes:
- Removed CTR accumulation from reducer
- Added proper CTR calculation based on total clicks and impressions
- Maintains better statistical accuracy in reporting
2024-10-26 21:33:31 +09:00
Joni
55fa7c0148 FixUnrankedAgainIssues2 - Solve wrong position is shown when a result goes unranked again 2024-08-19 20:54:13 +02:00
Parsmedia-Alex
8152d81804 bugfix gsc data if specified in settings and not in .env 2024-07-10 18:45:44 +02:00
Towfiq I
748dc8fc61 Merge pull request #212 from AntoineKM/main
fix: update scraping robot typo in README
2024-06-30 09:36:43 +06:00
Antoine Kingue
c24b63009c fix: update scraping robot typo in README 2024-05-05 14:16:07 +02:00
Towfiq I
bf8fd5362b Merge pull request #184 from sachatrauwaen/main
Resolve issue of duplicate entries in Dicover tab
2024-04-24 20:03:59 +06:00
Towfiq I
0c3068dc80 Merge pull request #193 from abdulla783/patch-2
Remove test variable as not defined and not required at all
2024-04-02 23:41:29 +06:00
Sacha
fde2f728aa Merge branch 'towfiqi:main' into main 2024-03-28 20:42:51 +01:00
Sacha Trauwaen
40e027e1ec 1) wrap both theKeywordsCount and theKeywordsGrouped with useMemo
2) changing reduce and map together to make the code more readable.
3) ctr and position value rounded.
2024-03-28 20:41:50 +01:00
Abdulla Ansari
51da47f292 Remove test variable as not defined and not required at all
As per your request I have removed the test
2024-03-28 20:48:36 +05:30
Towfiq I
d58a716ec1 Merge pull request #192 from valka465/main
Integrate HasData to SerpBear
2024-03-28 19:24:31 +06:00
valka465
79fc6b935c HasData scraper added 2024-03-28 11:57:15 +03:00
valka465
3fc1024520 Update index.ts
HasData added
2024-03-28 11:54:38 +03:00
valka465
90f45fd1c9 Update README.md
HasData info added.
2024-03-28 11:49:06 +03:00
Sacha Trauwaen
3a05703921 fix double entrees in Discover Tab (Google Search Console) 2024-03-15 16:47:27 +01:00
Sacha Trauwaen
6aa8900577 update react dev dependencies 2024-03-15 16:44:25 +01:00
towfiqi
fda8692daf chore(release): 2.0.2 2024-03-13 23:57:07 +06:00
towfiqi
1d0a788810 fix: Resolves Broken Google Adwords Authentication in Docker containers.
closes #179
2024-03-13 23:53:23 +06:00
towfiqi
75453d81de chore(release): 2.0.1 2024-03-06 14:36:34 +06:00
towfiqi
d48ae76103 fix: Resolves broken doc links 2024-03-06 14:35:29 +06:00
towfiqi
4a87d229fe fix: Resolves keyword loading issue in Docker instances.
closes #178
2024-03-06 14:35:07 +06:00
towfiqi
ec3cc9e12b chore(release): 2.0.0 2024-03-05 20:32:59 +06:00
towfiqi
f4ba8b0545 chore: Updates the readme to address Keyword Research feature. 2024-03-04 22:46:28 +06:00
towfiqi
d3938007a9 chore: renames Google Adwords to Google Ads 2024-03-03 21:34:45 +06:00
towfiqi
252ae9aa84 fix: Resolves Domain keyword Ideas generation issue. 2024-03-03 19:40:16 +06:00
towfiqi
50160f5b23 fix: Resolves minor UI bugs.
- Fixes update time of the keywords table is not aligned correctly in desktop.
- Fixes Content layout shift caused by the Domain Header absence.
- Fixes Footer causingpage scrollbar to appear.
2024-03-02 21:23:25 +06:00
towfiqi
bb4a6844b5 feat: Adds the ability to view the changelog and displays the latest version number.
- Adds a new Footer component.
- Adds a new Changelog component that displays the changelog.
2024-03-02 20:48:22 +06:00
towfiqi
407ab8db83 feat: Adds ability to pick existing tags when applying keyword tags.
closes #171
2024-03-01 12:57:01 +06:00
towfiqi
2a1fc0e43d feat: Adds keyword search volume data feature for tracked keywords.
- Adds a volume field in the keyword table.
- Adds a button in the Adwords Integration screen to update all the tracked keywords.
- When a new keyword is added, the volume data is automatically fetched.
- Adds ability to sort keywords based on search volume.
2024-03-01 10:52:45 +06:00
towfiqi
4d15989b28 feat: Adds a Keyword Research Section.
- Adds a /research page to the app that lets users generate keyword ideas based on given keywords.
- Allows the ability to export keywords.
2024-02-29 11:37:45 +06:00
towfiqi
5650645b58 feat: Adds Google Adwords Integration to allow generating Keyword Ideas.
- Integrates Google Adwords API to generate keywords for a domain.
- Adds a New Adwords Integration ui inside the App settings > Integrations screen to integrate Google Adwords.
- Adds a New Ideas tab under each domain.
- Adds ability to automatically generate keyword ideas based on website content, currently tracked keywords, Currently Ranking keywords or custom keywords
- The Keyword Ideas are not saved in database, they are saved in a local file inside the data folder. File naming convention: IDEAS_domain.com.json
- The keywords can be marked as favorites, and each time a keyword is favorited, they are added in the IDEAS_domain.com.json file.
2024-02-28 19:19:23 +06:00
towfiqi
83c47452fc chore(release): 1.0.3 2024-02-22 19:15:43 +06:00
towfiqi
56ffbf59d1 fix: Resolves App not reloading on Scraper setup. 2024-02-22 19:15:08 +06:00
towfiqi
9a7a43f051 fix: Resolves scraper not able to scrape some keywords correctly. 2024-02-22 19:14:19 +06:00
towfiqi
724d3c8d43 fix: Resolves large keywords breaking the keywords table ui 2024-02-22 19:12:44 +06:00
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
134 changed files with 14476 additions and 13466 deletions

View File

@@ -12,6 +12,10 @@
"no-await-in-loop": "off", "no-await-in-loop": "off",
"arrow-body-style":"off", "arrow-body-style":"off",
"max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}], "max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}],
"import/no-extraneous-dependencies": "off",
"no-unused-vars": "off",
"implicit-arrow-linebreak": "off",
"function-paren-newline": "off",
"import/extensions": [ "import/extensions": [
"error", "error",
"ignorePackages", "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')
};

View File

@@ -2,6 +2,204 @@
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. 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.
### [2.0.7](https://github.com/towfiqi/serpbear/compare/v2.0.6...v2.0.7) (2025-02-23)
### Bug Fixes
* Resolves AdWords integration issue. ([36ed4cf](https://github.com/towfiqi/serpbear/commit/36ed4cf800c1fd0e3df4e807faaa1fdb863df5e4))
* resolves broken CDN images. ([bf911b4](https://github.com/towfiqi/serpbear/commit/bf911b4e45b9007a05ce6399838da3276161c61d))
### [2.0.6](https://github.com/towfiqi/serpbear/compare/v2.0.5...v2.0.6) (2024-11-15)
### Bug Fixes
* Ensures Docker build uses matching npm package versions from package.json ([4fef1a9](https://github.com/towfiqi/serpbear/commit/4fef1a9abc737da67ab1ea0c4efce8194890545e))
* Resolves broken Docker build due to croner package version mismatch. ([aeed1f8](https://github.com/towfiqi/serpbear/commit/aeed1f8559e044bf658d930a22fa91f38cfedc6b)), closes [#247](https://github.com/towfiqi/serpbear/issues/247)
* Resolves broken Proxy Scraper functionality. ([649f412](https://github.com/towfiqi/serpbear/commit/649f412303dd50127b3736740962863f735f76eb)), closes [#248](https://github.com/towfiqi/serpbear/issues/248)
* Resolves Google Ads search volume data loading issue. ([12eac2b](https://github.com/towfiqi/serpbear/commit/12eac2b01235e9eae06882d6a2c50c793b890661))
### [2.0.5](https://github.com/towfiqi/serpbear/compare/v2.0.4...v2.0.5) (2024-11-12)
### Bug Fixes
* Fixes broken scrape result issue for keywords with special characters. ([d9d7c63](https://github.com/towfiqi/serpbear/commit/d9d7c6347e99e35f1e48f20b1e8f999612d69a72)), closes [#221](https://github.com/towfiqi/serpbear/issues/221)
* Fixes misaligned Keywords table UI content. ([faa3519](https://github.com/towfiqi/serpbear/commit/faa3519a29fc61ef8bd2ce9275a6674f1c7946e0))
* Resolves "Add Domain" UI confusion. ([17fb2c4](https://github.com/towfiqi/serpbear/commit/17fb2c40cc6b57ab2fe6aeb940dececd1a83411f))
* Resolves broken Scrapingrobot scraper on new installs. ([bc02c92](https://github.com/towfiqi/serpbear/commit/bc02c929ba7efd6b4b6a09495af7310c155a26bd)), closes [#243](https://github.com/towfiqi/serpbear/issues/243)
### [2.0.4](https://github.com/towfiqi/serpbear/compare/v2.0.3...v2.0.4) (2024-11-10)
### Bug Fixes
* Fixes Docker build issue. ([1bef758](https://github.com/towfiqi/serpbear/commit/1bef7587cccada6df48cfa3d208bf123a5d00c30))
### [2.0.3](https://github.com/towfiqi/serpbear/compare/v2.0.2...v2.0.3) (2024-11-10)
### Features
* Ability to keywords for both mobile and desktop at once. ([3786438](https://github.com/towfiqi/serpbear/commit/3786438662f015613330912f668c161f007f3eef)), closes [#60](https://github.com/towfiqi/serpbear/issues/60) [#66](https://github.com/towfiqi/serpbear/issues/66) [#199](https://github.com/towfiqi/serpbear/issues/199)
* Adds ability to set Notification Email From name. ([b35d333](https://github.com/towfiqi/serpbear/commit/b35d333bfcdf0765bd105f74c8b324aec6d98b22)), closes [#222](https://github.com/towfiqi/serpbear/issues/222)
* Adds the ability to show hide columns in tracked keywords table. ([d3e3760](https://github.com/towfiqi/serpbear/commit/d3e37605279c48f7a56b2ef5125c32a00b948e05)), closes [#224](https://github.com/towfiqi/serpbear/issues/224)
* auto filter keywords if they already exist instead of throwing error. ([a09eb62](https://github.com/towfiqi/serpbear/commit/a09eb62f5a65f584c32ddb5c46018dc404d3e1f3)), closes [#244](https://github.com/towfiqi/serpbear/issues/244)
* Displays Best position on mobile layout as well. ([a74338f](https://github.com/towfiqi/serpbear/commit/a74338fe15bee0fbc65a5197ee56db5d6a629bc8))
* Displays keyword's best position in email notification. ([4c2f900](https://github.com/towfiqi/serpbear/commit/4c2f900d85e56b1994630942c9ea53d0fd0b4cdb))
* Keywords Country filter now only shows relevant countries. ([a050536](https://github.com/towfiqi/serpbear/commit/a0505368140076d6626647993a41a5f4ef9db019))
* Makes Content width a little wider. ([42c5e2b](https://github.com/towfiqi/serpbear/commit/42c5e2be0777a1bd72aa53bdf3c211075793a6e8))
### Bug Fixes
* Correct CTR calculation in InsightStats component ([232507e](https://github.com/towfiqi/serpbear/commit/232507e1ff7519cfc82f3818d3b757f2c427b52a))
* Fixes incorrect position display in keyword detail view when position is above 100 ([01b1b7b](https://github.com/towfiqi/serpbear/commit/01b1b7b9e9c603e470217ad476dd54359d7d35b8))
* Fixes missing keyword city value in exported csv file. ([f482884](https://github.com/towfiqi/serpbear/commit/f48288473e910c00e7c9178366a394e891bc47c7)), closes [#194](https://github.com/towfiqi/serpbear/issues/194)
* Fixes missing Search Console data in Email notification when its integrated through App settings. ([040dab1](https://github.com/towfiqi/serpbear/commit/040dab15177d81874a6eb89f913a450f8a6f212d))
* Resolves incorrect search trend graph in Ideas section. ([7597210](https://github.com/towfiqi/serpbear/commit/7597210ca2018b4abc82d441d2fc871e46295307)), closes [#219](https://github.com/towfiqi/serpbear/issues/219)
* Resolves notification email's incorrect image size in some email clients. ([bc96dc7](https://github.com/towfiqi/serpbear/commit/bc96dc7de50844fbda19a89961c36cc65ac0b97b)), closes [#201](https://github.com/towfiqi/serpbear/issues/201)
* update scraping robot typo in README ([c24b630](https://github.com/towfiqi/serpbear/commit/c24b63009c48c61218ea96511b11fe1a4f2ff239))
### [2.0.2](https://github.com/towfiqi/serpbear/compare/v2.0.1...v2.0.2) (2024-03-13)
### Bug Fixes
* Resolves Broken Google Adwords Authentication in Docker containers. ([1d0a788](https://github.com/towfiqi/serpbear/commit/1d0a78881039b3ebd4b1e00fb067016886d49c31)), closes [#179](https://github.com/towfiqi/serpbear/issues/179)
### [2.0.1](https://github.com/towfiqi/serpbear/compare/v2.0.0...v2.0.1) (2024-03-06)
### Bug Fixes
* Resolves broken doc links ([d48ae76](https://github.com/towfiqi/serpbear/commit/d48ae76103615ab82a32cbd8ffa62e27d17999de))
* Resolves keyword loading issue in Docker instances. ([4a87d22](https://github.com/towfiqi/serpbear/commit/4a87d229fee7aa5e0ca8b2042c168465c7c5d67f)), closes [#178](https://github.com/towfiqi/serpbear/issues/178)
## [2.0.0](https://github.com/towfiqi/serpbear/compare/v1.0.3...v2.0.0) (2024-03-05)
### Features
* Adds a Keyword Research Section. ([4d15989](https://github.com/towfiqi/serpbear/commit/4d15989b2832c3514fca18f3178a967c1fc9ad18))
* Adds ability to pick existing tags when applying keyword tags. ([407ab8d](https://github.com/towfiqi/serpbear/commit/407ab8db831b26a5d45ccd7455c39f82fef1e438)), closes [#171](https://github.com/towfiqi/serpbear/issues/171)
* Adds Google Adwords Integration to allow generating Keyword Ideas. ([5650645](https://github.com/towfiqi/serpbear/commit/5650645b58638e91bd5772415234fdda8cc8de1a))
* Adds keyword search volume data feature for tracked keywords. ([2a1fc0e](https://github.com/towfiqi/serpbear/commit/2a1fc0e43d31f215b815a9d8b312da080209ae3a))
* Adds the ability to view the changelog and displays the latest version number. ([bb4a684](https://github.com/towfiqi/serpbear/commit/bb4a6844b522067b69a007fda427d16d25bae3a3))
### Bug Fixes
* Resolves Domain keyword Ideas generation issue. ([252ae9a](https://github.com/towfiqi/serpbear/commit/252ae9aa84177909f5658d954ca1231c70cb9145))
* Resolves minor UI bugs. ([50160f5](https://github.com/towfiqi/serpbear/commit/50160f5b234fc783802efae34db4a8f959745998))
### [1.0.3](https://github.com/towfiqi/serpbear/compare/v1.0.2...v1.0.3) (2024-02-22)
### Bug Fixes
* Resolves App not reloading on Scraper setup. ([56ffbf5](https://github.com/towfiqi/serpbear/commit/56ffbf59d1e459a6c7229d141ccc6774dc8055d0))
* Resolves large keywords breaking the keywords table ui ([724d3c8](https://github.com/towfiqi/serpbear/commit/724d3c8d4309c7bd4f40e9db980ad54f99023d35))
* Resolves scraper not able to scrape some keywords correctly. ([9a7a43f](https://github.com/towfiqi/serpbear/commit/9a7a43f051387cacc13116c0a7c21716b54e539b))
### [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) ### [0.2.6](https://github.com/towfiqi/serpbear/compare/v0.2.5...v0.2.6) (2023-03-29)

View File

@@ -1,5 +1,6 @@
FROM node:lts-alpine AS deps FROM node:22.11.0-alpine3.20 AS deps
ENV NPM_VERSION=10.3.0
RUN npm install -g npm@"${NPM_VERSION}"
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
@@ -7,15 +8,22 @@ RUN npm install
COPY . . COPY . .
FROM node:lts-alpine AS builder FROM node:22.11.0-alpine3.20 AS builder
WORKDIR /app WORKDIR /app
ENV NPM_VERSION=10.3.0
RUN npm install -g npm@"${NPM_VERSION}"
COPY --from=deps /app ./ COPY --from=deps /app ./
RUN rm -rf /app/data
RUN rm -rf /app/__tests__
RUN rm -rf /app/__mocks__
RUN npm run build RUN npm run build
FROM node:lts-alpine AS runner FROM node:22.11.0-alpine3.20 AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV production ENV NPM_VERSION=10.3.0
RUN npm install -g npm@"${NPM_VERSION}"
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
RUN set -xe && mkdir -p /app/data && chown nextjs:nodejs /app/data RUN set -xe && mkdir -p /app/data && chown nextjs:nodejs /app/data
@@ -27,13 +35,17 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# setup the cron # setup the cron
COPY --from=builder --chown=nextjs:nodejs /app/cron.js ./ 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/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 rm package.json
RUN npm init -y RUN npm init -y
RUN npm i cryptr dotenv croner @googleapis/searchconsole RUN npm i cryptr@6.0.3 dotenv@16.0.3 croner@9.0.0 @googleapis/searchconsole@1.0.5 sequelize-cli@6.6.2 @isaacs/ttlcache@1.4.1
RUN npm i -g concurrently RUN npm i -g concurrently
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["concurrently","node server.js", "node cron.js"] CMD ["concurrently","node server.js", "node cron.js"]

View File

@@ -1,50 +1,60 @@
![SerpBear](https://i.imgur.com/0S2zIH3.png) ![SerpBear](https://i.imgur.com/0S2zIH3.png)
# SerpBear # 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) ![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) [![StandWithPalestine](https://raw.githubusercontent.com/Safouene1/support-palestine-banner/master/StandWithPalestine.svg)](https://www.youtube.com/watch?v=bjtDsd0g468&rco=1)
#### [Documentation](https://docs.serpbear.com/) | [Changelog](https://github.com/towfiqi/serpbear/blob/main/CHANGELOG.md) | [Docker Image](https://hub.docker.com/r/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. SerpBear is an Open Source Search Engine Position Tracking and Keyword Research App. It allows you to track your website's keyword positions in Google and get notified of their position change.
![Easy to Use Search Engine Rank Tracker](https://erevanto.sirv.com/Images/serpbear/serpbear_readme_v2.gif) ![Easy to Use Search Engine Rank Tracker](https://serpbear.b-cdn.net/serpbear_readme_v2.gif)
#### Features #### 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. - **Unlimited Keywords:** Add unlimited domains and unlimited keywords to track their SERP.
- **SERP API:** SerpBear comes with built-in API that you can use for your marketing & data reporting tools. - **Email Notification:** Get notified of your keyword position changes daily/weekly/monthly through email.
- **Google Search Console Integration:** Get the actual visit count, impressions & more for Each keyword. - **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. - **Keyword Research:** Ability to research keywords and auto-generate keyword ideas from your tracked website's content by integrating your Google Ads test account.
- **Zero Cost to RUN:** Run the App on mogenius.com or Fly.io for free. - **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 #### How it Works
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SerpApi or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword. Also, When you connect your Googel Search Console account, the app shows actual search visits for each tracked keywords. You can also discover new keywords, and find the most performing keywords, countries, pages.
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SearchApi, SerpApi, HasData or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword.
The Keyword Research and keyword generation feature works by integrating your Google Ads test accounts into SerpBear. You can also view the added keyword's monthly search volume data once you [integrate Google Ads](https://docs.serpbear.com/miscellaneous/integrate-google-ads).
When you [integrate Google Search Console](https://docs.serpbear.com/miscellaneous/integrate-google-search-console), the app shows actual search visits for each tracked keywords. You can also discover new keywords, and find the most performing keywords, countries, pages.you will be able to view the actual visits count from Google Search for the tracked keywords.
#### Getting Started #### 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 - **Step 1:** Deploy & Run the App.
- **Step 2:** Access your App and Login.
- **Step 3:** Add your First domain.
- **Step 4:** Get a free API key from ScrapingRobot or select a paid provider (see below) . 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.
|Service | Cost | SERP Lookup | API | #### SerpBear Integrates with popular SERP scraping services
|--|--|--|--|
| SerpBear | Free* | Unlimited* | Yes |
| ranktracker.com | $18/mo| 3,000/mo| No |
| SerpWatch.io | $29/mo | 7500/mo | Yes |
| Serpwatcher.com | $49/mo| 3000/mo | No |
| whatsmyserp.com | $49/mo| 30,000/mo| No |
| 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 |
(*) Free upto a limit. If you are using ScrapingAnt you can lookup 10,000 times per month for free. If you don't want to use proxies, you can use third party Scraping services to scrape Google Search results.
(**) Free up to 100 per month. Paid from 5,000 to 10,000,000+ per month.
**Stack**
- Next.js for Frontend & Backend. | Service | Cost | SERP Lookup | API |
- Sqlite for Database. | ----------------- | ------------- | -------------- | --- |
| scrapingrobot.com | Free | 5000/mo | Yes |
| 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 |
| serper.dev | Pay As You Go | $1.00/1000 req | No |
| hasdata.com | From $29/mo | From 10,000/mo | Yes |
**Tech Stack**
- Next.js for Frontend & Backend.
- Sqlite for Database.

View File

@@ -2,10 +2,11 @@ export const dummyDomain = {
ID: 1, ID: 1,
domain: 'compressimage.io', domain: 'compressimage.io',
slug: 'compressimage-io', slug: 'compressimage-io',
keywordCount: 0, keywordCount: 10,
avgPosition: 24,
lastUpdated: '2022-11-11T10:00:32.243', lastUpdated: '2022-11-11T10:00:32.243',
added: '2022-11-11T10:00:32.244', added: '2022-11-11T10:00:32.244',
tags: [], tags: '',
notification: true, notification: true,
notification_interval: 'daily', notification_interval: 'daily',
notification_emails: '', notification_emails: '',
@@ -21,6 +22,7 @@ export const dummyKeywords = [
lastUpdated: '2022-11-15T10:49:53.113', lastUpdated: '2022-11-15T10:49:53.113',
added: '2022-11-11T10:01:06.951', added: '2022-11-11T10:01:06.951',
position: 19, position: 19,
volume: 10000,
history: { history: {
'2022-11-11': 21, '2022-11-11': 21,
'2022-11-12': 24, '2022-11-12': 24,
@@ -33,7 +35,7 @@ export const dummyKeywords = [
lastResult: [], lastResult: [],
sticky: false, sticky: false,
updating: false, updating: false,
lastUpdateError: 'false', lastUpdateError: false as false,
}, },
{ {
ID: 2, ID: 2,
@@ -44,6 +46,7 @@ export const dummyKeywords = [
lastUpdated: '2022-11-15T10:49:53.119', lastUpdated: '2022-11-15T10:49:53.119',
added: '2022-11-15T10:01:06.951', added: '2022-11-15T10:01:06.951',
position: 29, position: 29,
volume: 1200,
history: { history: {
'2022-11-11': 33, '2022-11-11': 33,
'2022-11-12': 34, '2022-11-12': 34,
@@ -56,6 +59,24 @@ export const dummyKeywords = [
lastResult: [], lastResult: [],
sticky: false, sticky: false,
updating: 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: '',
notification_email_from_name: 'SerpBear',
smtp_server: '',
smtp_port: '',
smtp_username: '',
smtp_password: '',
scrape_retry: false,
search_console_integrated: false,
screenshot_key: '',
available_scapers: [],
failed_queue: [],
};

View File

@@ -1,22 +1,26 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { rest } from 'msw'; import { http } from 'msw';
import * as React from 'react'; import * as React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
export const handlers = [ export const handlers = [
rest.get( http.get(
'*/react-query', '*/react-query',
(req, res, ctx) => { ({ request, params }) => {
return res( return new Response(
ctx.status(200), JSON.stringify({
ctx.json({
name: 'mocked-react-query', name: 'mocked-react-query',
}), }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
); );
}, },
), ),
]; ];
const createTestQueryClient = () => new QueryClient({ const createTestQueryClient = () => new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {

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 { fireEvent, render, screen } from '@testing-library/react';
import Keyword from '../../components/keywords/Keyword'; 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(), refreshkeyword: jest.fn(),
favoriteKeyword: jest.fn(), favoriteKeyword: jest.fn(),
removeKeyword: jest.fn(), removeKeyword: jest.fn(),
@@ -10,35 +16,37 @@ const keywordFunctions = {
manageTags: jest.fn(), manageTags: jest.fn(),
showKeywordDetails: jest.fn(), showKeywordDetails: jest.fn(),
}; };
jest.mock('react-chartjs-2', () => ({
Line: () => null,
}));
describe('Keyword Component', () => { describe('Keyword Component', () => {
it('renders without crashing', async () => { it('renders without crashing', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />); render(<Keyword {...keywordProps} />);
expect(await screen.findByText('compress image')).toBeInTheDocument(); expect(await screen.findByText('compress image')).toBeInTheDocument();
}); });
it('Should Render Position Correctly', async () => { it('Should Render Position Correctly', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />); render(<Keyword {...keywordProps} />);
const positionElement = document.querySelector('.keyword_position'); const positionElement = document.querySelector('.keyword_position');
expect(positionElement?.childNodes[0].nodeValue).toBe('19'); expect(positionElement?.childNodes[0].nodeValue).toBe('19');
}); });
it('Should Display Position Change arrow', async () => { 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'); const positionElement = document.querySelector('.keyword_position i');
expect(positionElement?.textContent).toBe('▲ 1'); expect(positionElement?.textContent).toBe('▲ 1');
}); });
it('Should Display the SERP Page URL', async () => { 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'); const positionElement = document.querySelector('.keyword_url');
expect(positionElement?.textContent).toBe('/'); expect(positionElement?.textContent).toBe('/');
}); });
it('Should Display the Keyword Options on dots Click', async () => { it('Should Display the Keyword Options on dots Click', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />); const { container } = render(<Keyword {...keywordProps} />);
const button = document.querySelector('.keyword .keyword_dots'); const button = container.querySelector('.keyword_dots');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
expect(document.querySelector('.keyword_options')).toBeVisible(); expect(document.querySelector('.keyword_options')).toBeVisible();
}); });
// it('Should favorite Keywords', async () => { // it('Should favorite Keywords', async () => {
// render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />); // render(<Keyword {...keywordProps} />);
// const button = document.querySelector('.keyword .keyword_dots'); // const button = document.querySelector('.keyword .keyword_dots');
// if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); // if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
// const option = document.querySelector('.keyword .keyword_options li:nth-child(1) a'); // 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 { render, screen } from '@testing-library/react';
import TopBar from '../../components/common/TopBar'; import TopBar from '../../components/common/TopBar';
jest.mock('next/router', () => ({
useRouter: () => ({
pathname: '/',
}),
}));
describe('TopBar Component', () => { describe('TopBar Component', () => {
it('renders without crashing', async () => { it('renders without crashing', async () => {
render(<TopBar showSettings={() => console.log() } />); render(<TopBar showSettings={jest.fn} showAddModal={jest.fn} />);
expect( expect(
await screen.findByText('SerpBear'), await screen.findByText('SerpBear'),
).toBeInTheDocument(); ).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 { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import SingleDomain from '../../pages/domain/[slug]'; import SingleDomain from '../../pages/domain/[slug]';
import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains'; import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains';
import { useAddKeywords, useDeleteKeywords, useFavKeywords, useFetchKeywords, useRefreshKeywords } from '../../services/keywords'; import { useAddKeywords, useDeleteKeywords,
import { dummyDomain, dummyKeywords } from '../data'; 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/domains');
jest.mock('../../services/keywords'); jest.mock('../../services/keywords');
jest.mock('../../services/settings');
jest.mock('next/router', () => ({ jest.mock('next/router', () => ({
useRouter: () => ({ useRouter: () => ({
query: { slug: dummyDomain.slug }, query: { slug: dummyDomain.slug },
}), }),
})); }));
jest.mock('react-chartjs-2', () => ({
Line: () => null,
}));
const useFetchDomainsFunc = useFetchDomains as jest.Mock<any>; const useFetchDomainsFunc = useFetchDomains as jest.Mock<any>;
const useFetchKeywordsFunc = useFetchKeywords as jest.Mock<any>; const useFetchKeywordsFunc = useFetchKeywords as jest.Mock<any>;
const useDeleteKeywordsFunc = useDeleteKeywords 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 useAddKeywordsFunc = useAddKeywords as jest.Mock<any>;
const useUpdateDomainFunc = useUpdateDomain as jest.Mock<any>; const useUpdateDomainFunc = useUpdateDomain as jest.Mock<any>;
const useDeleteDomainFunc = useDeleteDomain 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', () => { describe('SingleDomain Page', () => {
const queryClient = new QueryClient();
beforeEach(() => { beforeEach(() => {
useFetchSettingsFunc.mockImplementation(() => ({ data: { settings: dummySettings }, isLoading: false }));
useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false })); useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false }));
useFetchKeywordsFunc.mockImplementation(() => ({ keywordsData: { keywords: dummyKeywords }, keywordsLoading: 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: () => { } })); useDeleteKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useFavKeywordsFunc.mockImplementation(() => ({ mutate: () => { } })); useFavKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
useRefreshKeywordsFunc.mockImplementation(() => ({ mutate: () => { } })); useRefreshKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
@@ -38,158 +53,154 @@ describe('SingleDomain Page', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('Render without crashing.', async () => { it('Render without crashing.', async () => {
const { getByTestId } = render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
// screen.debug(undefined, Infinity); expect(screen.getByTestId('domain-header')).toBeInTheDocument();
expect(getByTestId('domain-header')).toBeInTheDocument();
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
}); });
it('Should Call the useFetchDomains hook on render.', async () => { it('Should Call the useFetchDomains hook on render.', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
// screen.debug(undefined, Infinity);
expect(useFetchDomains).toHaveBeenCalled(); expect(useFetchDomains).toHaveBeenCalled();
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
}); });
it('Should Render the Keywords', async () => { it('Should Render the Keywords', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const keywordsCount = document.querySelectorAll('.keyword').length; const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(2); expect(keywordsCount).toBe(2);
}); });
it('Should Display the Keywords Details Sidebar on Keyword Click.', async () => { 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 keywords = document.querySelectorAll('.keyword');
const firstKeyword = keywords && keywords[0].querySelector('a'); 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(); expect(screen.getByTestId('keywordDetails')).toBeVisible();
}); });
it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => { it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=add_domain]'); const button = screen.getByTestId('add_domain');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
expect(screen.getByTestId('adddomain_modal')).toBeVisible(); expect(screen.getByTestId('adddomain_modal')).toBeVisible();
}); });
it('Should Display the AddKeywords Modal on Add Keyword Button Click.', async () => { it('Should Display the AddKeywords Modal on Add Keyword Button Click.', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=add_keyword]'); const button = screen.getByTestId('add_keyword');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
expect(screen.getByTestId('addkeywords_modal')).toBeVisible(); expect(screen.getByTestId('addkeywords_modal')).toBeVisible();
}); });
it('Should display the Domain Settings on Settings Button click.', async () => { it('Should display the Domain Settings on Settings Button click.', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=show_domain_settings]'); const button = screen.getByTestId('show_domain_settings');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
expect(screen.getByTestId('domain_settings')).toBeVisible(); expect(screen.getByTestId('domain_settings')).toBeVisible();
}); });
it('Device Tab change should be functioning.', async () => { it('Device Tab change should be functioning.', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=mobile_tab]'); const button = screen.getByTestId('mobile_tab');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
const keywordsCount = document.querySelectorAll('.keyword').length; const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(0); expect(keywordsCount).toBe(0);
}); });
it('Search Filter should function properly', async () => { it('Search Filter should function properly', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const inputNode = screen.getByTestId('filter_input'); 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'); expect(inputNode.getAttribute('value')).toBe('compressor');
const keywordsCount = document.querySelectorAll('.keyword').length; const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(1); expect(keywordsCount).toBe(1);
}); });
it('Country Filter should function properly', async () => { it('Country Filter should function properly', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=filter_button]'); const button = screen.getByTestId('filter_button');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
expect(document.querySelector('.country_filter')).toBeVisible(); expect(document.querySelector('.country_filter')).toBeVisible();
const countrySelect = document.querySelector('.country_filter .selected'); 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(); expect(document.querySelector('.country_filter .select_list')).toBeVisible();
const firstCountry = document.querySelector('.country_filter .select_list ul li:nth-child(1)'); 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; const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(0); expect(keywordsCount).toBe(0);
}); });
// Tags Filter should function properly // Tags Filter should function properly
it('Tags Filter should Render & Function properly', async () => { it('Tags Filter should Render & Function properly', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=filter_button]'); const button = screen.getByTestId('filter_button');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
expect(document.querySelector('.tags_filter')).toBeVisible(); expect(document.querySelector('.tags_filter')).toBeVisible();
const countrySelect = document.querySelector('.tags_filter .selected'); 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.querySelector('.tags_filter .select_list')).toBeVisible();
expect(document.querySelectorAll('.tags_filter .select_list ul li').length).toBe(1); 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)'); 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); expect(document.querySelectorAll('.keyword').length).toBe(1);
}); });
it('Sort Options Should be visible Sort Button on Click.', async () => { it('Sort Options Should be visible Sort Button on Click.', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=sort_button]'); const button = screen.getByTestId('sort_button');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
expect(document.querySelector('.sort_options')).toBeVisible(); expect(document.querySelector('.sort_options')).toBeVisible();
}); });
it('Sort: Position should sort keywords accordingly', async () => { it('Sort: Position should sort keywords accordingly', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=sort_button]'); const button = screen.getByTestId('sort_button');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
// Test Top Position Sort // Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(1)'); 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; const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image'); expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort // 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)'); 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; const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor'); expect(secondKeywordTitle).toBe('image compressor');
}); });
it('Sort: Date Added should sort keywords accordingly', async () => { it('Sort: Date Added should sort keywords accordingly', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=sort_button]'); const button = screen.getByTestId('sort_button');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
// Test Top Position Sort // Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(3)'); 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; const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image'); expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort // 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)'); 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; const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor'); expect(secondKeywordTitle).toBe('image compressor');
}); });
it('Sort: Alphabetical should sort keywords accordingly', async () => { it('Sort: Alphabetical should sort keywords accordingly', async () => {
render(<SingleDomain />); render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = document.querySelector('[data-testid=sort_button]'); const button = screen.getByTestId('sort_button');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); if (button) fireEvent.click(button);
// Test Top Position Sort // Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(5)'); 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; const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image'); expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort // 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)'); 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; const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor'); 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 { QueryClient, QueryClientProvider } from 'react-query';
import Home from '../../pages/index'; import Home from '../../pages/index';
const routerPush = jest.fn();
jest.mock('next/router', () => ({
useRouter: () => ({
push: routerPush,
}),
}));
describe('Home Page', () => { describe('Home Page', () => {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
it('Renders without crashing', async () => { 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( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Home /> <Home />
@@ -26,12 +21,12 @@ describe('Home Page', () => {
expect(await screen.findByRole('main')).toBeInTheDocument(); expect(await screen.findByRole('main')).toBeInTheDocument();
expect(screen.queryByText('Add Domain')).not.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( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Home /> <Home />
</QueryClientProvider>, </QueryClientProvider>,
); );
expect(await screen.findByText('Add Domain')).toBeInTheDocument(); expect(routerPush).toHaveBeenCalledWith('/domains');
}); });
}); });

View File

@@ -8,9 +8,10 @@ type ChartProps ={
labels: string[], labels: string[],
sreies: number[], sreies: number[],
reverse? : boolean, reverse? : boolean,
noMaxLimit?: boolean
} }
const Chart = ({ labels, sreies, reverse = true }:ChartProps) => { const Chart = ({ labels, sreies, reverse = true, noMaxLimit = false }:ChartProps) => {
const options = { const options = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -19,7 +20,7 @@ const Chart = ({ labels, sreies, reverse = true }:ChartProps) => {
y: { y: {
reverse, reverse,
min: 1, min: 1,
max: reverse ? 100 : undefined, max: !noMaxLimit && reverse ? 100 : undefined,
}, },
}, },
plugins: { plugins: {

View File

@@ -6,10 +6,12 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler,
type ChartProps ={ type ChartProps ={
labels: string[], labels: string[],
sreies: number[] sreies: number[],
noMaxLimit?: boolean,
reverse?: boolean
} }
const ChartSlim = ({ labels, sreies }:ChartProps) => { const ChartSlim = ({ labels, sreies, noMaxLimit = false, reverse = true }:ChartProps) => {
const options = { const options = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -17,9 +19,9 @@ const ChartSlim = ({ labels, sreies }:ChartProps) => {
scales: { scales: {
y: { y: {
display: false, display: false,
reverse: true, reverse,
min: 1, min: 1,
max: 100, max: noMaxLimit ? undefined : 100,
}, },
x: { x: {
display: false, display: false,
@@ -35,7 +37,7 @@ const ChartSlim = ({ labels, sreies }:ChartProps) => {
}, },
}; };
return <div className='w-[120px] h-[30px] rounded border border-gray-200'> return <div className='w-[80px] h-[30px] rounded border border-gray-200'>
<Line <Line
datasetIdKey='XXX' datasetIdKey='XXX'
options={options} options={options}

View File

@@ -0,0 +1,32 @@
import { useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import { useFetchChangelog } from '../../services/misc';
import ChangeLog from '../settings/Changelog';
interface FooterProps {
currentVersion: string
}
const Footer = ({ currentVersion = '' }: FooterProps) => {
const [showChangelog, setShowChangelog] = useState(false);
const { data: changeLogs } = useFetchChangelog();
const latestVersionNum = changeLogs && Array.isArray(changeLogs) && changeLogs[0] ? changeLogs[0].name : '';
return (
<footer className='text-center flex flex-1 justify-center pb-5 items-end'>
<span className='text-gray-500 text-xs'>
<a className='cursor-pointer' onClick={() => setShowChangelog(true)}>SerpBear v{currentVersion || '0.0.0'}</a>
{currentVersion && latestVersionNum && `v${currentVersion}` !== latestVersionNum && (
<a className='cursor-pointer text-indigo-700 font-semibold' onClick={() => setShowChangelog(true)}>
{' '}| Update to Version {latestVersionNum} (latest)
</a>
)}
</span>
<CSSTransition in={showChangelog} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<ChangeLog closeChangeLog={() => setShowChangelog(false)} />
</CSSTransition>
</footer>
);
};
export default Footer;

View File

@@ -10,10 +10,10 @@ type IconProps = {
} }
const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '' }: IconProps) => { const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '' }: IconProps) => {
const xmlnsProps = { xmlns: 'http://www.w3.org/2000/svg', xmlnsXlink: 'http://www.w3.org/1999/xlink', preserveAspectRatio: 'xMidYMid meet' }; const xmlnsProps = { title, xmlns: 'http://www.w3.org/2000/svg', xmlnsXlink: 'http://www.w3.org/1999/xlink', preserveAspectRatio: 'xMidYMid meet' };
return ( return (
<span className={`icon inline-block relative top-[2px] ${classes}`} title={title}> <span className={`icon inline-block relative top-[2px] ${classes}`}>
{type === 'logo' {type === 'logo'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 1484.32 1348.5"> && <svg {...xmlnsProps} width={size} viewBox="0 0 1484.32 1348.5">
<path fill={color} d="M1406.23,604.17s-44-158.18,40.43-192.67,195,97.52,195,97.52,314-65.41,534,0c0,0,122.16-105.61,214.68-80.28,99.9,27.36,32.7,181.38,32.7,181.38s228.36,384.15,239.06,737.38c0,0-346.1,346.09-746.9,406.75,0,0-527.47-106.44-737.38-449.57C1177.88,1304.68,1169.55,1008.54,1406.23,604.17Z" transform="translate(-1177.84 -405.75)"/> <path fill={color} d="M1406.23,604.17s-44-158.18,40.43-192.67,195,97.52,195,97.52,314-65.41,534,0c0,0,122.16-105.61,214.68-80.28,99.9,27.36,32.7,181.38,32.7,181.38s228.36,384.15,239.06,737.38c0,0-346.1,346.09-746.9,406.75,0,0-527.47-106.44-737.38-449.57C1177.88,1304.68,1169.55,1008.54,1406.23,604.17Z" transform="translate(-1177.84 -405.75)"/>
@@ -131,13 +131,13 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
} }
{type === 'star' {type === 'star'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}> && <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<path fill={color} d="M10 1L7 7l-6 .75l4.13 4.62L4 19l6-3l6 3l-1.12-6.63L19 7.75L13 7zm0 2.24l2.34 4.69l4.65.58l-3.18 3.56l.87 5.15L10 14.88l-4.68 2.34l.87-5.15l-3.18-3.56l4.65-.58z"/> <path fill={color} d="m12 15.39l-3.76 2.27l.99-4.28l-3.32-2.88l4.38-.37L12 6.09l1.71 4.04l4.38.37l-3.32 2.88l.99 4.28M22 9.24l-7.19-.61L12 2L9.19 8.63L2 9.24l5.45 4.73L5.82 21L12 17.27L18.18 21l-1.64-7.03z"></path>
</svg> </svg>
} }
{type === 'star-filled' {type === 'star-filled'
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}> && <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
<path fill={color} d="M10 1l3 6l6 .75l-4.12 4.62L16 19l-6-3l-6 3l1.13-6.63L1 7.75L7 7z"/> <path fill={color} d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2L9.19 8.62L2 9.24l5.45 4.73L5.82 21z"></path>
</svg> </svg>
} }
{type === 'link' {type === 'link'
&& <svg width={size} viewBox="0 0 20 20" {...xmlnsProps}> && <svg width={size} viewBox="0 0 20 20" {...xmlnsProps}>
@@ -208,6 +208,26 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
<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" /> <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> </svg>
} }
{type === 'adwords'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 256 256">
<g>
<path d="M5.888,166.405103 L90.88,20.9 C101.676138,27.2558621 156.115862,57.3844138 164.908138,63.1135172 L79.9161379,208.627448 C70.6206897,220.906621 -5.888,185.040138 5.888,166.396276 L5.888,166.405103 Z" fill="#FBBC04"></path>
<path d="M250.084224,166.401789 L165.092224,20.9055131 C153.210293,1.13172 127.619121,-6.05393517 106.600638,5.62496138 C85.582155,17.3038579 79.182155,42.4624786 91.0640861,63.1190303 L176.056086,208.632961 C187.938017,228.397927 213.52919,235.583582 234.547672,223.904686 C254.648086,212.225789 261.966155,186.175582 250.084224,166.419444 L250.084224,166.401789 Z" fill="#4285F4"></path>
<ellipse fill="#34A853" cx="42.6637241" cy="187.924414" rx="42.6637241" ry="41.6044138"></ellipse>
</g>
</svg>
}
{type === 'keywords'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill="none" stroke={color} strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 12h14M5 16h6"></path>
</svg>
}
{type === 'integration'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill="none" stroke={color} strokeWidth={2} d="M10 21c-2.5 2.5-5 2-7 0s-2.5-4.5 0-7l3-3l7 7zm4-18c2.5-2.5 5-2 7.001 0c2.001 2 2.499 4.5 0 7l-3 3L11 6zm-3 7l-2.5 2.5zm3 3l-2.5 2.5z"></path>
</svg>
}
{type === 'cursor' {type === 'cursor'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24"> && <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"/> <path fill="none" stroke={color} strokeWidth="2" d="M6 3l12 11l-5 1l3 5.5l-3 1.5l-3-6l-4 3z"/>
@@ -221,6 +241,14 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
</g> </g>
</svg> </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' {type === 'target'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24"> && <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"/> <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"/>
@@ -245,6 +273,46 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
<path d="M15.75 9h3v2.25h-3z" fill={color} /> <path d="M15.75 9h3v2.25h-3z" fill={color} />
</svg> </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>
}
{type === 'research'
&& <svg width={size} viewBox="0 0 48 48" {...xmlnsProps}>
<g fill="none" stroke={color} strokeWidth={4}>
<path strokeLinecap="round" d="M4 7h40M4 23h11M4 39h11"></path>
<path d="M31.5 34a8.5 8.5 0 1 0 0-17a8.5 8.5 0 0 0 0 17Z"></path>
<path strokeLinecap="round" d="m37 32l7 7.05"></path>
</g>
</svg>
}
{type === 'domains'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 56 56">
<path fill={color} d="M7.328 43.504c.445 0 .914-.14 1.383-.469V17.957c0-.844.164-1.172.914-1.57L31.352 3.87c.07-1.547-.915-2.508-2.25-2.508c-.61 0-1.266.164-1.97.586L7.493 13.246c-2.297 1.336-2.578 1.758-2.578 4.43v22.43c0 2.015.96 3.398 2.414 3.398m9.375 5.414c.422 0 .89-.14 1.383-.469V23.371c0-.914.117-1.148.89-1.57L40.703 9.26c.07-1.523-.89-2.507-2.25-2.507c-.586 0-1.266.187-1.945.562L16.82 18.636c-2.297 1.313-2.555 1.805-2.555 4.43V45.52c0 2.015 1.008 3.398 2.438 3.398m10.031 5.719c.82 0 1.805-.328 2.977-.985l18.375-10.547c2.156-1.242 3-2.53 3-5.156l-.047-21.234c0-2.813-1.008-4.242-2.766-4.242c-.773 0-1.758.304-2.859.937L26.992 24.027c-2.203 1.29-2.977 2.602-2.977 5.157v21.234c0 2.719.961 4.219 2.72 4.219M28 50.067c-.117-.024-.164-.094-.164-.258L28 29.254c0-.89.258-1.36 1.055-1.805l17.742-10.43c.07-.046.14-.046.234-.023c.094.024.164.094.164.258l-.07 20.625c0 .773-.281 1.36-1.055 1.828L28.234 50.043a.284.284 0 0 1-.234.023"></path>
</svg>
}
{type === 'lock'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill={color} d="M6 22q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h1V6q0-2.075 1.463-3.537T12 1t3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22zm6-5q.825 0 1.413-.587T14 15t-.587-1.412T12 13t-1.412.588T10 15t.588 1.413T12 17M9 8h6V6q0-1.25-.875-2.125T12 3t-2.125.875T9 6z" />
</svg>
}
</span> </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 Icon from './Icon';
import useOnKey from '../../hooks/useOnKey';
type ModalProps = { type ModalProps = {
children: React.ReactNode, children: React.ReactNode,
width?: string, width?: string,
title?: string, title?: string,
verticalCenter?: boolean,
closeModal: Function, closeModal: Function,
} }
const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => { const Modal = ({ children, width = '1/2', closeModal, title, verticalCenter = false }:ModalProps) => {
useEffect(() => { useOnKey('Escape', closeModal);
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
closeModal();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeModal]);
const closeOnBGClick = (e:React.SyntheticEvent) => { const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -30,8 +22,9 @@ const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
return ( return (
<div className='modal fixed top-0 left-0 bg-white/[.7] w-full h-screen z-50' onClick={closeOnBGClick}> <div className='modal fixed top-0 left-0 bg-white/[.7] w-full h-screen z-50' onClick={closeOnBGClick}>
<div <div
className={`modal__content max-w-[340px] absolute top-1/4 left-0 right-0 ml-auto mr-auto w-${width} 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`}> 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>} {title && <h3 className=' font-semibold mb-3'>{title}</h3>}
<button <button
className='modal-close absolute right-2 top-2 p-2 cursor-pointer text-gray-400 transition-all 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 w-full 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,12 +9,15 @@ type SelectFieldProps = {
defaultLabel: string, defaultLabel: string,
options: SelectionOption[], options: SelectionOption[],
selected: string[], selected: string[],
label?: string,
multiple?: boolean, multiple?: boolean,
updateField: Function, updateField: Function,
fullWidth?: boolean,
minWidth?: number, minWidth?: number,
maxHeight?: number|string, maxHeight?: number|string,
rounded?: string, rounded?: string,
flags?: boolean, flags?: boolean,
inline?: boolean,
emptyMsg?: string emptyMsg?: string
} }
const SelectField = (props: SelectFieldProps) => { const SelectField = (props: SelectFieldProps) => {
@@ -26,8 +29,11 @@ const SelectField = (props: SelectFieldProps) => {
updateField, updateField,
minWidth = 180, minWidth = 180,
maxHeight = 96, maxHeight = 96,
fullWidth = false,
rounded = 'rounded-3xl', rounded = 'rounded-3xl',
inline = false,
flags = false, flags = false,
label = '',
emptyMsg = '' } = props; emptyMsg = '' } = props;
const [showOptions, setShowOptions] = useState<boolean>(false); const [showOptions, setShowOptions] = useState<boolean>(false);
@@ -66,12 +72,13 @@ const SelectField = (props: SelectFieldProps) => {
}; };
return ( return (
<div className="select font-semibold text-gray-500"> <div className={`select font-semibold text-gray-500 relative ${inline ? 'inline-block' : 'flex'} justify-between items-center"`}>
{label && <label className='mb-2 font-semibold inline-block text-sm text-gray-700 capitalize'>{label}</label>}
<div <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 ${fullWidth ? 'w-full' : 'w-[210px]'}
${showOptions ? 'border-indigo-200' : ''}`} min-w-[${minWidth}px] ${showOptions ? 'border-indigo-200' : ''}`}
onClick={() => setShowOptions(!showOptions)}> onClick={() => setShowOptions(!showOptions)}>
<span className={`w-[${minWidth - 30}px] inline-block truncate mr-2 capitalize`}> <span className={'w-full inline-block truncate mr-2 capitalize'}>
{selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel} {selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel}
</span> </span>
{multiple && selected.length > 2 {multiple && selected.length > 2
@@ -80,7 +87,7 @@ const SelectField = (props: SelectFieldProps) => {
</div> </div>
{showOptions && ( {showOptions && (
<div <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 ${fullWidth ? 'w-full' : 'w-[210px]'}
${rounded === 'rounded-3xl' ? 'rounded-lg' : rounded} max-h-${maxHeight} bg-white z-50 overflow-y-auto styled-scrollbar`}> ${rounded === 'rounded-3xl' ? 'rounded-lg' : rounded} max-h-${maxHeight} bg-white z-50 overflow-y-auto styled-scrollbar`}>
{options.length > 20 && ( {options.length > 20 && (
<div className=''> <div className=''>
@@ -99,7 +106,7 @@ const SelectField = (props: SelectFieldProps) => {
return ( return (
<li <li
key={opt.value} key={opt.value}
className={`select-none cursor-pointer px-3 py-2 hover:bg-[#FCFCFF] capitalize className={`select-none cursor-pointer px-3 py-2 hover:bg-[#FCFCFF] capitalize text-ellipsis overflow-hidden
${itemActive ? ' bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''} `} ${itemActive ? ' bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''} `}
onClick={() => selectItem(opt)} onClick={() => selectItem(opt)}
> >

View File

@@ -0,0 +1,37 @@
import React, { useEffect, useState } from 'react';
import Icon from './Icon';
import useOnKey from '../../hooks/useOnKey';
type SidePanelProps = {
children: React.ReactNode,
closePanel: Function,
title?: string,
width?: 'large' | 'medium' | 'small',
position?: 'left' | 'right'
}
const SidePanel = ({ children, closePanel, width, position = 'right', title = '' }:SidePanelProps) => {
useOnKey('Escape', closePanel);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (e.target === e.currentTarget) { closePanel(); }
};
return (
<div className="SidePanel fixed w-full h-screen top-0 left-0 z-50" onClick={closeOnBGClick}>
<div className={`absolute w-full max-w-md border-l border-l-gray-400 bg-white customShadow top-0
${position === 'left' ? 'left-0' : 'right-0'} h-screen`}>
<div className='SidePanel__header px-5 py-4 text-slate-500 border-b border-b-gray-100'>
<h3 className=' text-black text-lg font-bold'>{title}</h3>
<button
className=' absolute top-2 right-2 p-2 px- text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
onClick={() => closePanel()}>
<Icon type='close' size={24} />
</button>
</div>
<div>{children}</div>
</div>
</div>
);
};
export default SidePanel;

View File

@@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-img-element */
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
@@ -24,13 +25,13 @@ const Sidebar = ({ domains, showAddModal } : SidebarProps) => {
<Link href={`/domain/${d.slug}`} passHref={true}> <Link href={`/domain/${d.slug}`} passHref={true}>
<a className={`block cursor-pointer px-4 text-ellipsis max-w-[215px] overflow-hidden whitespace-nowrap rounded <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 || `/domain/console/${d.slug}` === router.asPath rounded-r-none ${((`/domain/${d.slug}` === router.asPath || `/domain/console/${d.slug}` === router.asPath
|| `/domain/insight/${d.slug}` === router.asPath) || `/domain/insight/${d.slug}` === router.asPath || `/domain/ideas/${d.slug}` === router.asPath)
? 'bg-white text-zinc-800 border border-r-0' : 'text-zinc-500')}`}> ? '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'}> <img
{d.domain.charAt(0)} className={' inline-block mr-1'}
</i> src={`https://www.google.com/s2/favicons?domain=${d.domain}&sz=16`} alt={d.domain}
/>
{d.domain} {d.domain}
{/* <span>0</span> */}
</a> </a>
</Link> </Link>
</li>) </li>)

View File

@@ -0,0 +1,32 @@
type ToggleFieldProps = {
label: string;
value: boolean;
onChange: (bool:boolean) => void ;
classNames?: string;
}
const ToggleField = ({ label = '', value = false, 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-auto">{label}</span>
<input
type="checkbox"
value={value.toString()}
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

@@ -37,7 +37,7 @@ const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
<span className=' relative top-[3px] mr-1'><Icon type="logo" size={24} color="#364AFF" /></span> SerpBear <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> <button className='px-3 py-1 font-bold text-blue-700 lg:hidden ml-3 text-lg' onClick={() => showAddModal()}>+</button>
</h3> </h3>
{!isDomainsPage && ( {!isDomainsPage && router.asPath !== '/research' && (
<Link href={'/domains'} passHref={true}> <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 <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'> absolute lg:top-3 lg:right-auto lg:left-8 lg:px-3 lg:py-2 rounded-full'>
@@ -52,16 +52,30 @@ const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
<ul <ul
className={`text-sm font-semibold text-gray-500 absolute mt-[-10px] right-3 bg-white className={`text-sm font-semibold text-gray-500 absolute mt-[-10px] right-3 bg-white
border border-gray-200 lg:mt-2 lg:relative lg:block lg:border-0 lg:bg-transparent ${showMobileMenu ? 'block' : 'hidden'}`}> border border-gray-200 lg:mt-2 lg:relative lg:block lg:border-0 lg:bg-transparent ${showMobileMenu ? 'block' : 'hidden'}`}>
<li className='block lg:inline-block lg:ml-5'> <li className={`block lg:inline-block lg:ml-5 ${router.asPath === '/domains' ? ' text-blue-700' : ''}`}>
<a className='block px-3 py-2 cursor-pointer' href='https://docs.serpbear.com/' target="_blank" rel='noreferrer'> <Link href={'/domains'} passHref={true}>
<Icon type="question" color={'#888'} size={14} /> Help <a className='block px-3 py-2 cursor-pointer'>
</a> <Icon type="domains" color={router.asPath === '/domains' ? '#1d4ed8' : '#888'} size={14} /> Domains
</a>
</Link>
</li>
<li className={`block lg:inline-block lg:ml-5 ${router.asPath === '/research' ? ' text-blue-700' : ''}`}>
<Link href={'/research'} passHref={true}>
<a className='block px-3 py-2 cursor-pointer'>
<Icon type="research" color={router.asPath === '/research' ? '#1d4ed8' : '#888'} size={14} /> Research
</a>
</Link>
</li> </li>
<li className='block lg:inline-block lg:ml-5'> <li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' onClick={() => showSettings()}> <a className='block px-3 py-2 cursor-pointer' onClick={() => showSettings()}>
<Icon type="settings-alt" color={'#888'} size={14} /> Settings <Icon type="settings-alt" color={'#888'} size={14} /> Settings
</a> </a>
</li> </li>
<li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' href='https://docs.serpbear.com/' target="_blank" rel='noreferrer'>
<Icon type="question" color={'#888'} size={14} /> Help
</a>
</li>
<li className='block lg:inline-block lg:ml-5'> <li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' onClick={() => logoutUser()}> <a className='block px-3 py-2 cursor-pointer' onClick={() => logoutUser()}>
<Icon type="logout" color={'#888'} size={14} /> Logout <Icon type="logout" color={'#888'} size={14} /> Logout

View File

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

View File

@@ -13,15 +13,19 @@ type DomainHeaderProps = {
exportCsv:Function, exportCsv:Function,
scFilter?: string scFilter?: string
setScFilter?: Function setScFilter?: Function
showIdeaUpdateModal?:Function
} }
const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, domains, scFilter = 'thirtyDays', setScFilter }: DomainHeaderProps) => { const DomainHeader = (
{ domain, showAddModal, showSettingsModal, exportCsv, domains, scFilter = 'thirtyDays', setScFilter, showIdeaUpdateModal }: DomainHeaderProps,
) => {
const router = useRouter(); const router = useRouter();
const [showOptions, setShowOptions] = useState<boolean>(false); const [showOptions, setShowOptions] = useState<boolean>(false);
const [ShowSCDates, setShowSCDates] = useState<boolean>(false); const [ShowSCDates, setShowSCDates] = useState<boolean>(false);
const { mutate: refreshMutate } = useRefreshKeywords(() => {}); const { mutate: refreshMutate } = useRefreshKeywords(() => {});
const isConsole = router.pathname === '/domain/console/[slug]'; const isConsole = router.pathname === '/domain/console/[slug]';
const isInsight = router.pathname === '/domain/insight/[slug]'; const isInsight = router.pathname === '/domain/insight/[slug]';
const isIdeas = router.pathname === '/domain/ideas/[slug]';
const daysName = (dayKey:string) => dayKey.replace('three', '3').replace('seven', '7').replace('thirty', '30').replace('Days', ' Days'); 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 buttonStyle = 'leading-6 inline-block px-2 py-2 text-gray-500 hover:text-gray-700';
@@ -45,8 +49,8 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
/> />
</div> </div>
</div> </div>
<div className='flex w-full justify-between'> <div className='flex w-full justify-between mt-4 lg:mt-0'>
<ul className=' flex items-end text-sm relative top-[2px]'> <ul className=' max-w-[270px] overflow-auto flex items-end text-sm relative top-[2px] lg:max-w-none'>
<li className={`${tabStyle} ${router.pathname === '/domain/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}> <li className={`${tabStyle} ${router.pathname === '/domain/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}>
<Link href={`/domain/${domain.slug}`} passHref={true}> <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' /> <a className='px-4 py-2 inline-block'><Icon type="tracking" color='#999' classes='hidden lg:inline-block' />
@@ -70,8 +74,22 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
</a> </a>
</Link> </Link>
</li> </li>
<li className={`${tabStyle} ${router.pathname === '/domain/ideas/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}>
<Link href={`/domain/ideas/${domain.slug}`} passHref={true}>
<a className='px-4 py-2 inline-block'><Icon type="adwords" size={13} classes='hidden lg:inline-block' />
<span className='text-xs lg:text-sm lg:ml-2'>Ideas</span>
<Icon
type='help'
size={14}
color="#aaa"
classes="ml-2 hidden lg:inline-block"
title='Get Keyword Ideas for this domain from Google Ads'
/>
</a>
</Link>
</li>
</ul> </ul>
<div className={'flex mt-3 mb-0 lg:mb-3'}> <div className={'flex mb-0 lg:mb-1 lg:mt-3'}>
{!isInsight && <button className={`${buttonStyle} lg:hidden`} onClick={() => setShowOptions(!showOptions)}> {!isInsight && <button className={`${buttonStyle} lg:hidden`} onClick={() => setShowOptions(!showOptions)}>
<Icon type='dots' size={20} /> <Icon type='dots' size={20} />
</button> </button>
@@ -89,7 +107,7 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
<Icon type='download' size={20} /><i className={`${buttonLabelStyle}`}>Export as csv</i> <Icon type='download' size={20} /><i className={`${buttonLabelStyle}`}>Export as csv</i>
</button> </button>
)} )}
{!isConsole && !isInsight && ( {!isConsole && !isInsight && !isIdeas && (
<button <button
className={`domheader_action_button relative ${buttonStyle} lg:ml-3`} className={`domheader_action_button relative ${buttonStyle} lg:ml-3`}
aria-pressed="false" aria-pressed="false"
@@ -105,10 +123,10 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
<i className={`${buttonLabelStyle}`}>Domain Settings</i> <i className={`${buttonLabelStyle}`}>Domain Settings</i>
</button> </button>
</div> </div>
{!isConsole && !isInsight && ( {!isConsole && !isInsight && !isIdeas && (
<button <button
data-testid="add_keyword" data-testid="add_keyword"
className={'ml-2 inline-block px-4 py-2 text-blue-700 font-bold text-sm'} className={'ml-2 inline-block text-blue-700 font-bold text-sm lg:px-4 lg:py-2'}
onClick={() => showAddModal(true)}> onClick={() => showAddModal(true)}>
<span <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> 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>
@@ -135,6 +153,18 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
)} )}
</div> </div>
)} )}
{isIdeas && (
<button
data-testid="load_ideas"
className={'ml-2 text-blue-700 font-bold text-sm flex items-center lg:px-4 lg:py-2'}
onClick={() => showIdeaUpdateModal && showIdeaUpdateModal()}>
<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'>
<Icon type='reload' size={12} />
</span>
<i className=' not-italic hidden lg:inline-block'>Load Ideas</i>
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -11,9 +11,10 @@ type DomainItemProps = {
selected: boolean, selected: boolean,
isConsoleIntegrated: boolean, isConsoleIntegrated: boolean,
thumb: string, thumb: string,
updateThumb: Function,
} }
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: DomainItemProps) => { const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb, updateThumb }: DomainItemProps) => {
const { keywordsUpdated, slug, keywordCount = 0, avgPosition = 0, scVisits = 0, scImpressions = 0, scPosition = 0 } = domain; const { keywordsUpdated, slug, keywordCount = 0, avgPosition = 0, scVisits = 0, scImpressions = 0, scPosition = 0 } = domain;
// const router = useRouter(); // const router = useRouter();
return ( return (
@@ -21,11 +22,23 @@ const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: Do
<Link href={`/domain/${slug}`} passHref={true}> <Link href={`/domain/${slug}`} passHref={true}>
<a className='flex flex-col lg:flex-row'> <a className='flex flex-col lg:flex-row'>
<div className={`flex-1 p-6 flex ${!isConsoleIntegrated ? 'basis-1/3' : ''}`}> <div className={`flex-1 p-6 flex ${!isConsoleIntegrated ? 'basis-1/3' : ''}`}>
<div className="domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded border border-gray-200 overflow-hidden"> <div className="group domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded
{thumb && <img src={thumb} alt={domain.domain} />} 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>
<div className="domain_details flex-1"> <div className="domain_details flex-1">
<h3 className='font-semibold text-base mb-2'>{domain.domain}</h3> <h3 className='font-semibold text-base mb-2 max-w-[200px] text-ellipsis overflow-hidden' title={domain.domain}>{domain.domain}</h3>
{keywordsUpdated && ( {keywordsUpdated && (
<span className=' text-gray-600 text-xs'> <span className=' text-gray-600 text-xs'>
Updated <TimeAgo title={dayjs(keywordsUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={keywordsUpdated} /> Updated <TimeAgo title={dayjs(keywordsUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={keywordsUpdated} />

View File

@@ -1,8 +1,10 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import Modal from '../common/Modal'; 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 = { type DomainSettingsProps = {
domain:DomainType|false, domain:DomainType|false,
@@ -16,32 +18,31 @@ type DomainSettingsError = {
const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => { const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
const router = useRouter(); const router = useRouter();
const [currentTab, setCurrentTab] = useState<'notification'|'searchconsole'>('notification');
const [showRemoveDomain, setShowRemoveDomain] = useState<boolean>(false); const [showRemoveDomain, setShowRemoveDomain] = useState<boolean>(false);
const [settingsError, setSettingsError] = useState<DomainSettingsError>({ type: '', msg: '' }); 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: updateMutate, error: domainUpdateError, isLoading: isUpdating } = useUpdateDomain(() => closeModal(false));
const { mutate: deleteMutate } = useDeleteDomain(() => { const { mutate: deleteMutate } = useDeleteDomain(() => { closeModal(false); router.push('/domains'); });
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 = () => { const updateDomain = () => {
console.log('Domain: ');
let error: DomainSettingsError | null = null; let error: DomainSettingsError | null = null;
if (domainSettings.notification_emails) { if (domainSettings.notification_emails) {
const notification_emails = domainSettings.notification_emails.split(','); 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); console.log('invalidEmails: ', invalidEmails);
if (invalidEmails) { if (invalidEmails) {
error = { type: 'email', msg: 'Invalid Email' }; error = { type: 'email', msg: 'Invalid Email' };
@@ -58,24 +59,103 @@ const DomainSettings = ({ domain, 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 ( return (
<div> <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 data-testid="domain_settings" className=" text-sm">
<div className="mb-6 flex justify-between items-center"> <div className=' mt-3 mb-5 border border-slate-200 px-2 py-4 pb-0
<h4>Notification Emails relative left-[-20px] w-[calc(100%+40px)] border-l-0 border-r-0 bg-[#f8f9ff]'>
{settingsError.type === 'email' && <span className="text-red-500 font-semibold ml-2">{settingsError.msg}</span>} <ul>
</h4> <li
<input className={`${tabStyle} ${currentTab === 'notification' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'} `}
className={`border w-46 text-sm transition-all rounded p-1.5 px-4 outline-none ring-0 onClick={() => setCurrentTab('notification')}>
${settingsError.type === 'email' ? ' border-red-300' : ''}`} <Icon type='email' /> Notification
type="text" </li>
placeholder='Your Emails' <li
onChange={updateNotiEmails} className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'}`}
value={domainSettings.notification_emails || ''} onClick={() => setCurrentTab('searchconsole')}>
/> <Icon type='google' /> Search Console
</li>
</ul>
</div> </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>
<div className="flex justify-between border-t-[1px] border-gray-100 mt-8 pt-4 pb-0"> <div className="flex justify-between border-t-[1px] border-gray-100 mt-8 pt-4 pb-0">
<button <button
className="text-sm font-semibold text-red-500" className="text-sm font-semibold text-red-500"
@@ -83,9 +163,9 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
<Icon type="trash" /> Remove Domain <Icon type="trash" /> Remove Domain
</button> </button>
<button <button
className='text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white' className={`text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white ${isUpdating ? 'cursor-not-allowed' : ''}`}
onClick={() => updateDomain()}> onClick={() => !isUpdating && updateDomain()}>
Update Settings {isUpdating && <Icon type='loading' />} Update Settings
</button> </button>
</div> </div>
</Modal> </Modal>

View File

@@ -0,0 +1,139 @@
import React, { useMemo, useRef } from 'react';
import dayjs from 'dayjs';
import { useQuery } from 'react-query';
import { useRouter } from 'next/router';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import Chart from '../common/Chart';
import useOnKey from '../../hooks/useOnKey';
import { formattedNum } from '../../utils/client/helpers';
import { fetchSearchResults } from '../../services/keywords';
type IdeaDetailsProps = {
keyword: IdeaKeyword,
closeDetails: Function
}
const dummySearchResults = [
{ position: 1, url: 'https://google.com/?search=dummy+text', title: 'Google Search Result One' },
{ position: 1, url: 'https://yahoo.com/?search=dummy+text', title: 'Yahoo Results | Sample Dummy' },
{ position: 1, url: 'https://gamespot.com/?search=dummy+text', title: 'GameSpot | Dummy Search Results' },
{ position: 1, url: 'https://compressimage.com/?search=dummy+text', title: 'Compress Images Online' },
];
const IdeaDetails = ({ keyword, closeDetails }:IdeaDetailsProps) => {
const router = useRouter();
const updatedDate = new Date(keyword.updated);
const searchResultContainer = useRef<HTMLDivElement>(null);
const searchResultFound = useRef<HTMLDivElement>(null);
const searchResultReqPayload = { keyword: keyword.keyword, country: keyword.country, device: 'desktop' };
const { data: keywordSearchResultData, refetch: fetchKeywordSearchResults, isLoading: fetchingResult } = useQuery(
`ideas:${keyword.uid}`,
() => fetchSearchResults(router, searchResultReqPayload),
{ refetchOnWindowFocus: false, enabled: false },
);
const { monthlySearchVolumes } = keyword;
useOnKey('Escape', closeDetails);
const chartData = useMemo(() => {
const chartDataObj: { labels: string[], sreies: number[] } = { labels: [], sreies: [] };
Object.keys(monthlySearchVolumes).forEach((dateKey:string) => {
const dateKeyArr = dateKey.split('-');
const labelDate = `${dateKeyArr[0].slice(0, 1).toUpperCase()}${dateKeyArr[0].slice(1, 3).toLowerCase()}, ${dateKeyArr[1].slice(2)}`;
chartDataObj.labels.push(labelDate);
chartDataObj.sreies.push(parseInt(monthlySearchVolumes[dateKey], 10));
});
return chartDataObj;
}, [monthlySearchVolumes]);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (e.target === e.currentTarget) { closeDetails(); }
};
const searchResultsFetched = !!keywordSearchResultData?.searchResult?.results;
const keywordSearchResult = searchResultsFetched ? keywordSearchResultData?.searchResult?.results : dummySearchResults;
return (
<div className="IdeaDetails fixed w-full h-screen top-0 left-0 z-[99999]" onClick={closeOnBGClick} data-testid="IdeaDetails">
<div className="IdeaDetails absolute w-full lg:w-5/12 bg-white customShadow top-0 right-0 h-screen" >
<div className='IdeaDetails__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 ml-2 rounded bg-blue-50 text-blue-700 text-xs font-bold'>
{formattedNum(keyword.avgMonthlySearches)}/month
</span>
</h3>
<button
className='absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
onClick={() => closeDetails()}>
<Icon type='close' size={24} />
</button>
</div>
<div className='IdeaDetails__content p-6'>
<div className='IdeaDetails__section'>
<div className="IdeaDetails__section__head flex justify-between mb-5">
<h3 className=' font-bold text-gray-700 text-lg'>Search Volume Trend</h3>
</div>
<div className='IdeaDetails__section__chart h-64'>
<Chart labels={chartData.labels} sreies={chartData.sreies} noMaxLimit={true} reverse={false} />
</div>
</div>
<div className='IdeaDetails__section mt-10'>
<div className="IdeaDetails__section__head flex justify-between items-center pb-4 mb-4 border-b border-b-slate-200">
<h3 className=' font-bold text-gray-700 lg:text-lg'>Google Search Result
<a className='text-gray-400 hover:text-indigo-600 inline-block ml-1 px-2 py-1'
href={`https://www.google.com/search?q=${encodeURI(keyword.keyword)}`}
target="_blank"
rel='noreferrer'>
<Icon type='link' size={14} />
</a>
</h3>
<span className=' text-xs text-gray-500'>{dayjs(updatedDate).format('MMMM D, YYYY')}</span>
</div>
<div className={'keywordDetails__section__results styled-scrollbar overflow-y-auto relative'} ref={searchResultContainer}>
{!searchResultsFetched && (
<div className=' absolute flex w-full h-full justify-center items-center flex-col z-50 font-semibold'>
<p>View Google Search Results for &quot;{keyword.keyword}&quot;</p>
<button
onClick={() => fetchKeywordSearchResults()}
className='mt-4 text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white '>
<Icon type={fetchingResult ? 'loading' : 'google'} /> {fetchingResult ? 'Performing' : 'Perform'} Google Search
</button>
</div>
)}
<div className={`${!searchResultsFetched ? ' blur-sm ' : ''}`}>
{keywordSearchResult && Array.isArray(keywordSearchResult) && keywordSearchResult.length > 0 && (
keywordSearchResult.map((item, index) => {
const { position } = keyword;
const domainExist = position < 100 && index === (position - 1);
return (
<div
ref={domainExist ? searchResultFound : null}
className={`leading-6 mb-4 mr-3 p-3 text-sm break-all pr-3 rounded
${domainExist ? ' bg-amber-50 border border-amber-200' : ''}`}
key={item.url + item.position}>
<h4 className='font-semibold text-blue-700'>
<a href={item.url} target="_blank" rel='noreferrer'>{`${index + 1}. ${item.title}`}</a>
</h4>
{/* <p>{item.description}</p> */}
<a className=' text-green-900' href={item.url} target="_blank" rel='noreferrer'>{item.url}</a>
</div>
);
})
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default IdeaDetails;

View File

@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
type IdeasFilterProps = {
allTags: string[],
filterParams: KeywordFilters,
filterKeywords: Function,
keywords: IdeaKeyword[],
favorites: IdeaKeyword[],
updateSort: Function,
showFavorites: Function,
sortBy: string,
}
const IdeasFilters = (props: IdeasFilterProps) => {
const { filterKeywords, allTags = [], updateSort, showFavorites, sortBy, filterParams, keywords = [], favorites = [] } = props;
const [keywordType, setKeywordType] = useState<'all'|'favorites'>('all');
const [sortOptions, showSortOptions] = useState(false);
const [filterOptions, showFilterOptions] = useState(false);
const filterTags = (tags:string[]) => filterKeywords({ ...filterParams, tags });
const searchKeywords = (event:React.FormEvent<HTMLInputElement>) => {
const filtered = filterKeywords({ ...filterParams, search: event.currentTarget.value });
return filtered;
};
const sortOptionChoices: SelectionOption[] = [
{ value: 'alpha_asc', label: 'Alphabetically(A-Z)' },
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
{ value: 'vol_asc', label: 'Lowest Search Volume' },
{ value: 'vol_desc', label: 'Highest Search Volume' },
{ value: 'competition_asc', label: 'High Competition' },
{ value: 'competition_desc', label: 'Low Competition' },
];
const sortItemStyle = (sortType:string) => {
return `cursor-pointer py-2 px-3 hover:bg-[#FCFCFF] ${sortBy === sortType ? 'bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''}`;
};
const deviceTabStyle = 'select-none cursor-pointer px-3 py-2 rounded-3xl mr-2';
const deviceTabCountStyle = 'px-2 py-0 rounded-3xl bg-[#DEE1FC] text-[0.7rem] font-bold ml-1';
const mobileFilterOptionsStyle = 'visible mt-8 border absolute min-w-[0] rounded-lg max-h-96 bg-white z-50 w-52 right-2 p-4';
return (
<div className='domKeywords_filters py-4 px-6 flex justify-between text-sm text-gray-500 font-semibold border-b-[1px] lg:border-0 items-center'>
<div>
<ul className='flex text-xs'>
<li
data-testid="desktop_tab"
className={`${deviceTabStyle} ${keywordType === 'all' ? ' bg-[#F8F9FF] text-gray-700' : ''}`}
onClick={() => { setKeywordType('all'); showFavorites(false); }}>
<Icon type='keywords' classes='top-[3px]' size={15} />
<i className='hidden not-italic lg:inline-block ml-1'>All Keywords</i>
<span className={`${deviceTabCountStyle}`}>{keywords.length}</span>
</li>
<li
data-testid="mobile_tab"
className={`${deviceTabStyle} ${keywordType === 'favorites' ? ' bg-[#F8F9FF] text-gray-700' : ''}`}
onClick={() => { setKeywordType('favorites'); showFavorites(true); }}>
<Icon type='star' classes='top-[4px]' />
<i className='hidden not-italic lg:inline-block ml-1'>Favorites</i>
<span className={`${deviceTabCountStyle}`}>{favorites.length}</span>
</li>
</ul>
</div>
<div className='flex gap-5'>
<div className=' lg:hidden'>
<button
data-testid="filter_button"
className={`px-2 py-1 rounded ${filterOptions ? ' bg-indigo-100 text-blue-700' : ''}`}
title='Filter'
onClick={() => showFilterOptions(!filterOptions)}>
<Icon type="filter" size={18} />
</button>
</div>
<div className={`lg:flex gap-5 lg:visible ${filterOptions ? mobileFilterOptionsStyle : 'hidden'}`}>
{keywordType === 'all' && (
<div className={'tags_filter mb-2 lg:mb-0'}>
<SelectField
selected={filterParams.tags}
options={allTags.map((tag:string) => ({ label: tag, value: tag }))}
defaultLabel={`All Groups (${allTags.length})`}
updateField={(updated:string[]) => filterTags(updated)}
emptyMsg="No Groups Found for this Domain"
minWidth={270}
/>
</div>
)}
<div className={'mb-2 lg:mb-0'}>
<input
data-testid="filter_input"
className={`border w-44 lg:w-36 focus:w-44 transition-all rounded-3xl
p-1.5 px-4 outline-none ring-0 focus:border-indigo-200`}
type="text"
placeholder='Filter Keywords...'
onChange={searchKeywords}
value={filterParams.search}
/>
</div>
</div>
<div className='relative'>
<button
data-testid="sort_button"
className={`px-2 py-1 rounded ${sortOptions ? ' bg-indigo-100 text-blue-700' : ''}`}
title='Sort'
onClick={() => showSortOptions(!sortOptions)}>
<Icon type="sort" size={18} />
</button>
{sortOptions && (
<ul
data-testid="sort_options"
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg
max-h-96 bg-white z-[9999] w-44 overflow-y-auto styled-scrollbar'>
{sortOptionChoices.map((sortOption) => {
return <li
key={sortOption.value}
className={sortItemStyle(sortOption.value)}
onClick={() => { updateSort(sortOption.value); showSortOptions(false); }}>
{sortOption.label}
</li>;
})}
</ul>
)}
</div>
</div>
</div>
);
};
export default IdeasFilters;

View File

@@ -0,0 +1,77 @@
import React, { useMemo } from 'react';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import { formattedNum } from '../../utils/client/helpers';
import ChartSlim from '../common/ChartSlim';
type KeywordIdeaProps = {
keywordData: IdeaKeyword,
selected: boolean,
lastItem?:boolean,
isFavorite: boolean,
style: Object,
selectKeyword: Function,
favoriteKeyword:Function,
showKeywordDetails: Function
}
const KeywordIdea = (props: KeywordIdeaProps) => {
const { keywordData, selected, lastItem, selectKeyword, style, isFavorite = false, favoriteKeyword, showKeywordDetails } = props;
const { keyword, uid, position, country, monthlySearchVolumes, avgMonthlySearches, competition, competitionIndex } = keywordData;
const chartData = useMemo(() => {
const chartDataObj: { labels: string[], sreies: number[] } = { labels: [], sreies: [] };
Object.keys(monthlySearchVolumes).forEach((dateKey:string) => {
chartDataObj.labels.push(dateKey);
chartDataObj.sreies.push(parseInt(monthlySearchVolumes[dateKey], 10));
});
return chartDataObj;
}, [monthlySearchVolumes]);
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
${selected ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}`}
onClick={() => selectKeyword(uid)}
>
<Icon type="check" size={10} />
</button>
<a className='py-2 hover:text-blue-600' onClick={() => showKeywordDetails()}>
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country] && countries[country][0]} />{keyword}
</a>
<button
className={`ml-2 hover:text-yellow-600 hover:opacity-100 ${isFavorite ? 'text-yellow-600' : ' opacity-50'}`}
onClick={() => favoriteKeyword()}>
<Icon type={isFavorite ? 'star-filled' : 'star'} classes='top-[4px]' size={18} />
</button>
</div>
<div className='keyword_imp text-center inline-block ml-6 lg:ml-0 lg:flex-1 '>
{formattedNum(avgMonthlySearches)}<span className='lg:hidden'>/month</span>
</div>
<div
onClick={() => showKeywordDetails()}
className={`keyword_visits text-center hidden mt-4 mr-5 ml-5 cursor-pointer
lg:flex-1 lg:m-0 lg:ml-10 max-w-[70px] lg:max-w-none lg:pr-5 lg:flex justify-center`}>
{chartData.labels.length > 0 && <ChartSlim labels={chartData.labels} sreies={chartData.sreies} noMaxLimit={true} reverse={false} />}
</div>
<div className='keyword_ctr text-center inline-block ml-4 lg:flex mt-4 relative lg:flex-1 lg:m-0 justify-center'>
<div className={`idea_competiton idea_competiton--${competition} flex bg-slate-100 rounded w-28 text-xs font-semibold`}>
<span className=' inline-block p-1 flex-1'>{competitionIndex}/100</span>
<span className=' inline-block p-1 flex-1 rounded-e text-white'>{competition}</span>
</div>
</div>
</div>
);
};
export default KeywordIdea;

View File

@@ -0,0 +1,247 @@
import { useRouter } from 'next/router';
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import { useQuery } from 'react-query';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { useAddKeywords } from '../../services/keywords';
import Icon from '../common/Icon';
import KeywordIdea from './KeywordIdea';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
import { IdeasSortKeywords, IdeasfilterKeywords } from '../../utils/client/IdeasSortFilter';
import IdeasFilters from './IdeasFilter';
import { useMutateFavKeywordIdeas } from '../../services/adwords';
import IdeaDetails from './IdeaDetails';
import { fetchDomains } from '../../services/domains';
import SelectField from '../common/SelectField';
type IdeasKeywordsTableProps = {
domain: DomainType | null,
keywords: IdeaKeyword[],
favorites: IdeaKeyword[],
noIdeasDatabase: boolean,
isLoading: boolean,
showFavorites: boolean,
setShowFavorites: Function,
isAdwordsIntegrated: boolean,
}
const IdeasKeywordsTable = ({
domain, keywords = [], favorites = [], isLoading = true, isAdwordsIntegrated = true, setShowFavorites,
showFavorites = false, noIdeasDatabase = false }: IdeasKeywordsTableProps) => {
const router = useRouter();
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [showKeyDetails, setShowKeyDetails] = useState<IdeaKeyword|null>(null);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('imp_desc');
const [listHeight, setListHeight] = useState(500);
const [addKeywordDevice, setAddKeywordDevice] = useState<'desktop'|'mobile'>('desktop');
const [addKeywordDomain, setAddKeywordDomain] = useState('');
const { mutate: addKeywords } = useAddKeywords(() => { if (domain && domain.slug) router.push(`/domain/${domain.slug}`); });
const { mutate: faveKeyword, isLoading: isFaving } = useMutateFavKeywordIdeas(router);
const [isMobile] = useIsMobile();
const isResearchPage = router.pathname === '/research';
const { data: domainsData } = useQuery('domains', () => fetchDomains(router, false), { enabled: selectedKeywords.length > 0, retry: false });
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
useWindowResize(() => setListHeight(window.innerHeight - (isMobile ? 200 : 400)));
const finalKeywords: IdeaKeyword[] = useMemo(() => {
const filteredKeywords = IdeasfilterKeywords(showFavorites ? favorites : keywords, filterParams);
const sortedKeywords = IdeasSortKeywords(filteredKeywords, sortBy);
return sortedKeywords;
}, [keywords, showFavorites, favorites, filterParams, sortBy]);
const favoriteIDs: string[] = useMemo(() => favorites.map((fav) => fav.uid), [favorites]);
const allTags:string[] = useMemo(() => {
const wordTags: Map<string, number> = new Map();
keywords.forEach((k) => {
const keywordsArray = k.keyword.split(' ');
const keywordFirstTwoWords = keywordsArray.slice(0, 2).join(' ');
const keywordFirstTwoWordsReversed = keywordFirstTwoWords.split(' ').reverse().join(' ');
if (!wordTags.has(keywordFirstTwoWordsReversed)) {
wordTags.set(keywordFirstTwoWords, 0);
}
});
[...wordTags].forEach((tag) => {
const foundTags = keywords.filter((kw) => kw.keyword.includes(tag[0]) || kw.keyword.includes(tag[0].split(' ').reverse().join(' ')));
if (foundTags.length < 3) {
wordTags.delete(tag[0]);
} else {
wordTags.set(tag[0], foundTags.length);
}
});
const finalWordTags = [...wordTags].sort((a, b) => (a[1] > b[1] ? -1 : 1)).map((t) => `${t[0]} (${t[1]})`);
return finalWordTags;
}, [keywords]);
const selectKeyword = (keywordID: string) => {
let updatedSelectd = [...selectedKeywords, keywordID];
if (selectedKeywords.includes(keywordID)) {
updatedSelectd = selectedKeywords.filter((keyID) => keyID !== keywordID);
}
setSelectedKeywords(updatedSelectd);
};
const favoriteKeyword = (keywordID: string) => {
if (!isFaving) {
faveKeyword({ keywordID, domain: isResearchPage ? 'research' : domain?.slug });
}
};
const addKeywordIdeasToTracker = () => {
const selectedkeywords:KeywordAddPayload[] = [];
keywords.forEach((kitem:IdeaKeyword) => {
if (selectedKeywords.includes(kitem.uid)) {
const { keyword, country } = kitem;
selectedkeywords.push({
keyword,
device: addKeywordDevice,
country,
domain: isResearchPage ? addKeywordDomain : (domain?.domain || ''),
tags: '',
});
}
});
addKeywords(selectedkeywords);
setSelectedKeywords([]);
};
const selectedAllItems = selectedKeywords.length === finalKeywords.length;
const Row = ({ data, index, style }:ListChildComponentProps) => {
const keyword: IdeaKeyword = data[index];
return (
<KeywordIdea
key={keyword.uid}
style={style}
selected={selectedKeywords.includes(keyword.uid)}
selectKeyword={selectKeyword}
favoriteKeyword={() => favoriteKeyword(keyword.uid)}
showKeywordDetails={() => setShowKeyDetails(keyword)}
isFavorite={favoriteIDs.includes(keyword.uid)}
keywordData={keyword}
lastItem={index === (finalKeywords.length - 1)}
/>
);
};
return (
<div>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-5'>
{selectedKeywords.length > 0 && (
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
<div className={`inline-block ${isResearchPage ? ' mr-2' : ''}`}>Add Keywords to Tracker</div>
{isResearchPage && (
<SelectField
selected={[]}
options={theDomains.map((d) => ({ label: d.domain, value: d.domain }))}
defaultLabel={'Select a Domain'}
updateField={(updated:string[]) => updated[0] && setAddKeywordDomain(updated[0])}
emptyMsg="No Domains Found"
multiple={false}
inline={true}
rounded='rounded'
/>
)}
<div className='inline-block ml-2'>
<button
className={`inline-block px-2 py-1 rounded-s
${addKeywordDevice === 'desktop' ? 'bg-indigo-100 text-blue-700' : 'bg-indigo-50 '}`}
onClick={() => setAddKeywordDevice('desktop')}>
{addKeywordDevice === 'desktop' ? '◉' : '○'} Desktop
</button>
<button
className={`inline-block px-2 py-1 rounded-e ${addKeywordDevice === 'mobile' ? 'bg-indigo-100 text-blue-700' : 'bg-indigo-50 '}`}
onClick={() => setAddKeywordDevice('mobile')}>
{addKeywordDevice === 'mobile' ? '◉' : '○'} Mobile
</button>
</div>
<a
className='inline-block px-2 py-2 cursor-pointer hover:text-indigo-600'
onClick={() => addKeywordIdeasToTracker()}
>
<span className=' text-white bg-blue-700 px-2 py-1 rounded font-semibold'>+ Add Keywords</span>
</a>
</div>
)}
{selectedKeywords.length === 0 && (
<IdeasFilters
allTags={allTags}
filterParams={filterParams}
filterKeywords={(params:KeywordFilters) => setFilterParams(params)}
updateSort={(sorted:string) => setSortBy(sorted)}
sortBy={sortBy}
keywords={keywords}
favorites={favorites}
showFavorites={(show:boolean) => { setShowFavorites(show); }}
/>
)}
<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.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.map((k: IdeaKeyword) => k.uid))}
>
<Icon type="check" size={10} />
</button>
)}
Keyword
</span>
<span className='domKeywords_head_vol flex-1 text-center'>Monthly Search</span>
<span className='domKeywords_head_trend flex-1 text-center'>Search Trend</span>
<span className='domKeywords_head_competition flex-1 text-center'>Competition</span>
</div>
<div className='domKeywords_keywords border-gray-200 min-h-[55vh] relative' data-domain={domain?.domain}>
{!isLoading && finalKeywords && finalKeywords.length > 0 && (
<List
innerElementType="div"
itemData={finalKeywords}
itemCount={finalKeywords.length}
itemSize={isMobile ? 100 : 57}
height={listHeight}
width={'100%'}
className={'styled-scrollbar'}
>
{Row}
</List>
)}
{isAdwordsIntegrated && isLoading && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>Loading Keywords Ideas...</p>
)}
{isAdwordsIntegrated && noIdeasDatabase && !isLoading && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>
{'No keyword Ideas has been generated for this domain yet. Click the "Load Ideas" button to generate keyword ideas.'}
</p>
)}
{isAdwordsIntegrated && !isLoading && finalKeywords.length === 0 && !noIdeasDatabase && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>
{'No Keyword Ideas found. Please try generating Keyword Ideas again by clicking the "Load Ideas" button.'}
</p>
)}
{!isAdwordsIntegrated && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>
Google Ads has not been Integrated yet. Please follow <a className='text-indigo-600 underline' href='https://docs.serpbear.com/miscellaneous/integrate-google-ads' target="_blank" rel='noreferrer'>These Steps</a> to integrate Google Ads.
</p>
)}
</div>
</div>
</div>
</div>
{showKeyDetails && showKeyDetails.uid && (
<IdeaDetails keyword={showKeyDetails} closeDetails={() => setShowKeyDetails(null)} />
)}
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};
export default IdeasKeywordsTable;

View File

@@ -0,0 +1,139 @@
import { useMemo, useState } from 'react';
import { useRouter } from 'next/router';
import { useMutateKeywordIdeas } from '../../services/adwords';
import allCountries, { adwordsLanguages } from '../../utils/countries';
import SelectField from '../common/SelectField';
import Icon from '../common/Icon';
interface KeywordIdeasUpdaterProps {
onUpdate?: Function,
domain?: DomainType,
searchConsoleConnected: boolean,
adwordsConnected: boolean,
settings?: {
seedSCKeywords: boolean,
seedCurrentKeywords: boolean,
seedDomain: boolean,
language: string,
countries: string[],
keywords: string,
seedType: string
}
}
const KeywordIdeasUpdater = ({ onUpdate, settings, domain, searchConsoleConnected = false, adwordsConnected = false }: KeywordIdeasUpdaterProps) => {
const router = useRouter();
const [seedType, setSeedType] = useState(() => settings?.seedType || 'auto');
const [language, setLanguage] = useState(() => settings?.language.toString() || '1000');
const [countries, setCoutries] = useState<string[]>(() => settings?.countries || ['US']);
const [keywords, setKeywords] = useState(() => (settings?.keywords && Array.isArray(settings?.keywords) ? settings?.keywords.join(',') : ''));
const { mutate: updateKeywordIdeas, isLoading: isUpdatingIdeas } = useMutateKeywordIdeas(router, () => onUpdate && onUpdate());
const seedTypeOptions = useMemo(() => {
const options = [
{ label: 'Automatically from Website Content', value: 'auto' },
{ label: 'Based on currently tracked keywords', value: 'tracking' },
{ label: 'From Custom Keywords', value: 'custom' },
];
if (searchConsoleConnected) {
options.splice(-2, 0, { label: 'Based on already ranking keywords (GSC)', value: 'searchconsole' });
}
return options;
}, [searchConsoleConnected]);
const reloadKeywordIdeas = () => {
const keywordPaylod = seedType !== 'auto' && keywords ? keywords.split(',').map((key) => key.trim()) : undefined;
console.log('keywordPaylod :', keywords, keywordPaylod);
updateKeywordIdeas({
seedType,
language,
domain: domain?.domain,
domainSlug: domain?.slug,
keywords: keywordPaylod,
country: countries[0],
});
};
const countryOptions = useMemo(() => {
return Object.keys(allCountries)
.filter((countryISO) => allCountries[countryISO][3] !== 0)
.map((countryISO) => ({ label: allCountries[countryISO][0], value: countryISO }));
}, []);
const languageOPtions = useMemo(() => Object.entries(adwordsLanguages).map(([value, label]) => ({ label, value })), []);
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize w-full';
return (
<div>
<div>
<div className={'mb-3'}>
<label className={labelStyle}>Get Keyword Ideas</label>
<SelectField
selected={[seedType]}
options={seedTypeOptions}
defaultLabel='Get Ideas Based On'
updateField={(updated:string[]) => setSeedType(updated[0])}
fullWidth={true}
multiple={false}
rounded='rounded'
/>
</div>
{seedType === 'custom' && (
<>
<div className={'mb-3'}>
<label className={labelStyle}>Get Ideas from given Keywords (Max 20)</label>
<textarea
className='w-full border border-solid border-gray-300 focus:border-blue-100 p-3 rounded outline-none'
value={keywords}
onChange={(event) => setKeywords(event.target.value)}
placeholder="keyword1, keyword2.."
/>
</div>
<hr className=' my-4' />
</>
)}
<div className={'mb-3'}>
<label className={labelStyle}>Country</label>
<SelectField
selected={countries}
options={countryOptions}
defaultLabel='All Countries'
updateField={(updated:string[]) => setCoutries(updated)}
flags={true}
multiple={false}
fullWidth={true}
maxHeight={48}
rounded='rounded'
/>
</div>
<div className={'mb-3'}>
<label className={labelStyle}>Language</label>
<SelectField
selected={[language]}
options={languageOPtions}
defaultLabel='All Languages'
updateField={(updated:string[]) => setLanguage(updated[0])}
rounded='rounded'
multiple={false}
fullWidth={true}
maxHeight={48}
/>
</div>
<button
className={`w-full py-2 px-5 mt-2 rounded bg-blue-700 text-white
font-semibold ${!adwordsConnected ? ' cursor-not-allowed opacity-40' : 'cursor-pointer'}`}
title={!adwordsConnected ? 'Please Connect Ads account to generate Keyword Ideas..' : ''}
onClick={() => !isUpdatingIdeas && adwordsConnected && reloadKeywordIdeas()}>
<Icon type={isUpdatingIdeas ? 'loading' : 'reload'} size={12} /> {isUpdatingIdeas ? 'Loading....' : 'Load Keyword Ideas'}
</button>
</div>
</div>
);
};
export default KeywordIdeasUpdater;

View File

@@ -12,7 +12,7 @@ type SCInsightProps = {
isConsoleIntegrated: boolean, isConsoleIntegrated: boolean,
} }
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SCInsightProps) => { const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true, domain }: SCInsightProps) => {
const [activeTab, setActiveTab] = useState<string>('stats'); const [activeTab, setActiveTab] = useState<string>('stats');
const insightItems = insight[activeTab as keyof InsightDataType]; const insightItems = insight[activeTab as keyof InsightDataType];
@@ -51,7 +51,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SC
return ( return (
<div> <div>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'> <div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-5'>
<div className='domKeywords_filters py-4 px-6 flex flex-col justify-between <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'> text-sm text-gray-500 font-semibold border-b-[1px] lg:border-0 lg:flex-row'>
<div> <div>
@@ -108,7 +108,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SC
(item:SCInsightItem, index: number) => { (item:SCInsightItem, index: number) => {
const insightItemCount = insight ? insightItems : []; const insightItemCount = insight ? insightItems : [];
const lastItem = !!(insightItemCount && (index === insightItemCount.length)); const lastItem = !!(insightItemCount && (index === insightItemCount.length));
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} />; return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} domain={domain?.domain || ''} />;
}, },
) )
} }
@@ -117,7 +117,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SC
)} )}
{!isConsoleIntegrated && ( {!isConsoleIntegrated && (
<p className=' p-9 pt-[10%] text-center text-gray-500'> <p className=' p-9 pt-[10%] text-center text-gray-500'>
Google Search 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. 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> </p>
)} )}
</div> </div>

View File

@@ -1,21 +1,22 @@
import React from 'react'; import React from 'react';
import countries from '../../utils/countries'; import countries from '../../utils/countries';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import { formattedNum } from '../../utils/client/helpers';
type InsightItemProps = { type InsightItemProps = {
item: SCInsightItem, item: SCInsightItem,
lastItem: boolean, lastItem: boolean,
type: string type: string,
domain: string
} }
const InsightItem = ({ item, lastItem, type }:InsightItemProps) => { const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
const { clicks, impressions, ctr, position, country = 'zzz', keyword, page, keywords = 0, countries: cntrs = 0, date } = item; const { clicks, impressions, ctr, position, country = 'zzz', keyword, page, keywords = 0, countries: cntrs = 0, date } = item;
let firstItem = keyword; let firstItem = keyword;
if (type === 'pages') { firstItem = page; } if (type === 'stats') { if (type === 'pages') { firstItem = page; } if (type === 'stats') {
firstItem = date && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(date)); firstItem = date && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(date));
} }
if (type === 'countries') { firstItem = countries[country] && countries[country][0]; } if (type === 'countries') { firstItem = countries[country] && countries[country][0]; }
const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);
return ( return (
<div <div
@@ -24,7 +25,7 @@ const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold'> <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 === 'countries' && <span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} />}
{firstItem} {type === 'pages' && domain ? <a href={`https://${domain}${page}`} target='_blank' rel="noreferrer">{firstItem}</a> : firstItem}
</div> </div>
<div className='keyword_pos text-center inline-block mr-3 lg:mr-0 lg:flex-1'> <div className='keyword_pos text-center inline-block mr-3 lg:mr-0 lg:flex-1'>
@@ -34,7 +35,6 @@ const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
{Math.round(position)} {Math.round(position)}
</div> </div>
{/* <div className='keyword_imp text-center inline-block lg:flex-1'>{formattedNum(clicks)}</div> */}
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-14 p-2 text-base mt-[-55px] rounded right-5 lg:relative <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`}> 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)} {formattedNum(clicks)}

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState, useEffect } from 'react'; import React, { useMemo } from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js'; import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { formattedNum } from '../../utils/client/helpers';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
@@ -12,22 +13,20 @@ type InsightStatsProps = {
} }
const InsightStats = ({ stats = [], totalKeywords = 0, totalPages = 0 }:InsightStatsProps) => { const InsightStats = ({ stats = [], totalKeywords = 0, totalPages = 0 }:InsightStatsProps) => {
const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num); const totalStat = useMemo(() => {
const [totalStat, setTotalStat] = useState({ impressions: 0, clicks: 0, ctr: 0, position: 0 }); const totals = stats.reduce((acc, item) => {
return {
impressions: item.impressions + acc.impressions,
clicks: item.clicks + acc.clicks,
position: item.position + acc.position,
};
}, { impressions: 0, clicks: 0, position: 0 });
useEffect(() => { return {
if (stats.length > 0) { ...totals,
const totalStats = stats.reduce((acc, item) => { ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0,
return { };
impressions: item.impressions + acc.impressions, }, [stats]);
clicks: item.clicks + acc.clicks,
ctr: item.ctr + acc.ctr,
position: item.position + acc.position,
};
}, { impressions: 0, clicks: 0, ctr: 0, position: 0 });
setTotalStat(totalStats);
}
}, [stats]);
const chartData = useMemo(() => { const chartData = useMemo(() => {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import Modal from '../common/Modal'; import Modal from '../common/Modal';
import SelectField from '../common/SelectField'; import SelectField from '../common/SelectField';
@@ -7,6 +7,8 @@ import { useAddKeywords } from '../../services/keywords';
type AddKeywordsProps = { type AddKeywordsProps = {
keywords: KeywordType[], keywords: KeywordType[],
scraperName: string,
allowsCity: boolean,
closeModal: Function, closeModal: Function,
domain: string domain: string
} }
@@ -17,26 +19,62 @@ type KeywordsInput = {
country: string, country: string,
domain: string, domain: string,
tags: string, tags: string,
city?:string,
} }
const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => { const AddKeywords = ({ closeModal, domain, keywords, scraperName = '', allowsCity = false }: AddKeywordsProps) => {
const inputRef = useRef(null);
const defCountry = localStorage.getItem('default_country') || 'US';
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [newKeywordsData, setNewKeywordsData] = useState<KeywordsInput>({ keywords: '', device: 'desktop', country: 'US', domain, tags: '' }); const [showTagSuggestions, setShowTagSuggestions] = useState(false);
const [newKeywordsData, setNewKeywordsData] = useState<KeywordsInput>({ keywords: '', device: 'desktop', country: defCountry, domain, tags: '' });
const { mutate: addMutate, isLoading: isAdding } = useAddKeywords(() => closeModal(false)); const { mutate: addMutate, isLoading: isAdding } = useAddKeywords(() => closeModal(false));
const deviceTabStyle = 'cursor-pointer px-3 py-2 rounded mr-2';
const existingTags: string[] = useMemo(() => {
const allTags = keywords.reduce((acc: string[], keyword) => [...acc, ...keyword.tags], []).filter((t) => t && t.trim() !== '');
return [...new Set(allTags)];
}, [keywords]);
const setDeviceType = useCallback((input:string) => {
let updatedDevice = '';
if (newKeywordsData.device.includes(input)) {
updatedDevice = newKeywordsData.device.replace(',', '').replace(input, '');
} else {
updatedDevice = newKeywordsData.device ? `${newKeywordsData.device},${input}` : input;
}
setNewKeywordsData({ ...newKeywordsData, device: updatedDevice });
}, [newKeywordsData]);
const addKeywords = () => { const addKeywords = () => {
if (newKeywordsData.keywords) { const nkwrds = newKeywordsData;
const keywordsArray = [...new Set(newKeywordsData.keywords.split('\n').map((item) => item.trim()).filter((item) => !!item))]; if (nkwrds.keywords) {
const currentKeywords = keywords.map((k) => `${k.keyword}-${k.device}-${k.country}`); const devices = nkwrds.device.split(',');
const keywordExist = keywordsArray.filter((k) => currentKeywords.includes(`${k}-${newKeywordsData.device}-${newKeywordsData.country}`)); const multiDevice = nkwrds.device.includes(',') && devices.length > 1;
if (keywordExist.length > 0) { const keywordsArray = [...new Set(nkwrds.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) =>
devices.some((device) => currentKeywords.includes(`${k}-${device}-${nkwrds.country}${nkwrds.city ? `-${nkwrds.city}` : ''}`)),
);
if (!multiDevice && (keywordsArray.length === 1 || currentKeywords.length === keywordExist.length) && keywordExist.length > 0) {
setError(`Keywords ${keywordExist.join(',')} already Exist`); setError(`Keywords ${keywordExist.join(',')} already Exist`);
setTimeout(() => { setError(''); }, 3000); setTimeout(() => { setError(''); }, 3000);
} else { } else {
const { device, country, domain: kDomain, tags } = newKeywordsData; const newKeywords = keywordsArray.flatMap((k) =>
const newKeywordsArray = keywordsArray.map((nItem) => ({ keyword: nItem, device, country, domain: kDomain, tags })); devices.filter((device) =>
addMutate(newKeywordsArray); !currentKeywords.includes(`${k}-${device}-${nkwrds.country}${nkwrds.city ? `-${nkwrds.city}` : ''}`),
).map((device) => ({
keyword: k,
device,
country: nkwrds.country,
domain: nkwrds.domain,
tags: nkwrds.tags,
city: nkwrds.city,
})),
);
addMutate(newKeywords);
} }
} else { } else {
setError('Please Insert a Keyword'); setError('Please Insert a Keyword');
@@ -44,6 +82,8 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
} }
}; };
const deviceTabStyle = 'cursor-pointer px-2 py-2 rounded';
return ( return (
<Modal closeModal={() => { closeModal(false); }} title={'Add New Keywords'} width="[420px]"> <Modal closeModal={() => { closeModal(false); }} title={'Add New Keywords'} width="[420px]">
<div data-testid="addkeywords_modal"> <div data-testid="addkeywords_modal">
@@ -64,7 +104,10 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
selected={[newKeywordsData.country]} selected={[newKeywordsData.country]}
options={Object.keys(countries).map((countryISO:string) => { return { label: countries[countryISO][0], value: countryISO }; })} options={Object.keys(countries).map((countryISO:string) => { return { label: countries[countryISO][0], value: countryISO }; })}
defaultLabel='All Countries' 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' rounded='rounded'
maxHeight={48} maxHeight={48}
flags={true} flags={true}
@@ -72,25 +115,63 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
</div> </div>
<ul className='flex text-xs font-semibold text-gray-500'> <ul className='flex text-xs font-semibold text-gray-500'>
<li <li
className={`${deviceTabStyle} ${newKeywordsData.device === 'desktop' ? ' bg-indigo-50 text-gray-700' : ''}`} className={`${deviceTabStyle} mr-2 ${newKeywordsData.device.includes('desktop') ? ' bg-indigo-50 text-indigo-700' : ''}`}
onClick={() => setNewKeywordsData({ ...newKeywordsData, device: 'desktop' })} onClick={() => setDeviceType('desktop')}>
><Icon type='desktop' classes={'top-[3px]'} size={15} /> <i className='not-italic hidden lg:inline-block'>Desktop</i></li> <Icon type='desktop' classes={'top-[3px]'} size={15} /> <i className='not-italic hidden lg:inline-block'>Desktop</i>
<Icon type='check' classes={'pl-1'} size={12} color={newKeywordsData.device.includes('desktop') ? '#4338ca' : '#bbb'} />
</li>
<li <li
className={`${deviceTabStyle} ${newKeywordsData.device === 'mobile' ? ' bg-indigo-50 text-gray-700' : ''}`} className={`${deviceTabStyle} ${newKeywordsData.device.includes('mobile') ? ' bg-indigo-50 text-indigo-700' : ''}`}
onClick={() => setNewKeywordsData({ ...newKeywordsData, device: 'mobile' })} onClick={() => setDeviceType('mobile')}>
><Icon type='mobile' /> <i className='not-italic hidden lg:inline-block'>Mobile</i></li> <Icon type='mobile' /> <i className='not-italic hidden lg:inline-block'>Mobile</i>
<Icon type='check' classes={'pl-1'} size={12} color={newKeywordsData.device.includes('mobile') ? '#4338ca' : '#bbb'} />
</li>
</ul> </ul>
</div> </div>
<div className='relative'> <div className='relative'>
{/* TODO: Insert Existing Tags as Suggestions */}
<input <input
className='w-full border rounded border-gray-200 py-2 px-4 pl-8 outline-none focus:border-indigo-300' className='w-full border rounded border-gray-200 py-2 px-4 pl-12 outline-none focus:border-indigo-300'
placeholder='Insert Tags' placeholder='Insert Tags (Optional)'
value={newKeywordsData.tags} value={newKeywordsData.tags}
onChange={(e) => setNewKeywordsData({ ...newKeywordsData, tags: e.target.value })} onChange={(e) => setNewKeywordsData({ ...newKeywordsData, tags: e.target.value })}
/> />
<span className='absolute text-gray-400 top-2 left-2'><Icon type="tags" size={16} /></span> <span className='absolute text-gray-400 top-3 left-2 cursor-pointer' onClick={() => setShowTagSuggestions(!showTagSuggestions)}>
<Icon type="tags" size={16} color={showTagSuggestions ? '#777' : '#aaa'} />
<Icon type={showTagSuggestions ? 'caret-up' : 'caret-down'} size={14} color={showTagSuggestions ? '#666' : '#aaa'} />
</span>
{showTagSuggestions && (
<ul className={`absolute z-50
bg-white border border-t-0 border-gray-200 rounded rounded-t-none w-full`}>
{existingTags.length > 0 && existingTags.map((tag, index) => {
return newKeywordsData.tags.split(',').map((t) => t.trim()).includes(tag) === false && <li
className=' p-2 cursor-pointer hover:text-indigo-600 hover:bg-indigo-50 transition'
key={index}
onClick={() => {
const tagInput = newKeywordsData.tags;
// eslint-disable-next-line no-nested-ternary
const tagToInsert = tagInput + (tagInput.trim().slice(-1) === ',' ? '' : (tagInput.trim() ? ', ' : '')) + tag;
setNewKeywordsData({ ...newKeywordsData, tags: tagToInsert });
setShowTagSuggestions(false);
if (inputRef?.current) (inputRef.current as HTMLInputElement).focus();
}}>
<Icon type='tags' size={14} color='#bbb' /> {tag}
</li>;
})}
{existingTags.length === 0 && <p>No Existing Tags Found... </p>}
</ul>
)}
</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>
</div> </div>
{error && <div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{error}</div>} {error && <div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{error}</div>}

View File

@@ -1,21 +1,24 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import { useUpdateKeywordTags } from '../../services/keywords'; import { useUpdateKeywordTags } from '../../services/keywords';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import Modal from '../common/Modal'; import Modal from '../common/Modal';
type AddTagsProps = { type AddTagsProps = {
keywords: KeywordType[], keywords: KeywordType[],
existingTags: string[],
closeModal: Function closeModal: Function
} }
const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => { const AddTags = ({ keywords = [], existingTags = [], closeModal }: AddTagsProps) => {
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState(() => (keywords.length === 1 ? keywords[0].tags.join(', ') : ''));
const [inputError, setInputError] = useState(''); const [inputError, setInputError] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); }); const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); });
const inputRef = useRef(null);
const addTag = () => { const addTag = () => {
if (keywords.length === 0) { return; } if (keywords.length === 0) { return; }
if (!tagInput) { if (!tagInput && keywords.length > 1) {
setInputError('Please Insert a Tag!'); setInputError('Please Insert a Tag!');
setTimeout(() => { setInputError(''); }, 3000); setTimeout(() => { setInputError(''); }, 3000);
return; return;
@@ -24,7 +27,7 @@ const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => {
const tagsArray = tagInput.split(',').map((t) => t.trim()); const tagsArray = tagInput.split(',').map((t) => t.trim());
const tagsPayload:any = {}; const tagsPayload:any = {};
keywords.forEach((keyword:KeywordType) => { keywords.forEach((keyword:KeywordType) => {
tagsPayload[keyword.ID] = [...keyword.tags, ...tagsArray]; tagsPayload[keyword.ID] = keywords.length === 1 ? tagsArray : [...(new Set([...keyword.tags, ...tagsArray]))];
}); });
updateMutate({ tags: tagsPayload }); updateMutate({ tags: tagsPayload });
}; };
@@ -33,9 +36,13 @@ const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => {
<Modal closeModal={() => { closeModal(false); }} title={`Add New Tags to ${keywords.length} Selected Keyword`}> <Modal closeModal={() => { closeModal(false); }} title={`Add New Tags to ${keywords.length} Selected Keyword`}>
<div className="relative"> <div className="relative">
{inputError && <span className="absolute top-[-24px] text-red-400 text-sm font-semibold">{inputError}</span>} {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> <span className='absolute text-gray-400 top-3 left-2 cursor-pointer' onClick={() => setShowSuggestions(!showSuggestions)}>
<Icon type="tags" size={16} color={showSuggestions ? '#777' : '#aaa'} />
<Icon type={showSuggestions ? 'caret-up' : 'caret-down'} size={14} color={showSuggestions ? '#666' : '#aaa'} />
</span>
<input <input
className='w-full border rounded border-gray-200 py-3 px-4 pl-8 outline-none focus:border-indigo-300' ref={inputRef}
className='w-full border rounded border-gray-200 py-3 px-4 pl-12 outline-none focus:border-indigo-300'
placeholder='Insert Tags. eg: tag1, tag2' placeholder='Insert Tags. eg: tag1, tag2'
value={tagInput} value={tagInput}
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
@@ -46,6 +53,27 @@ const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => {
} }
}} }}
/> />
{showSuggestions && (
<ul className={`absolute z-50
bg-white border border-t-0 border-gray-200 rounded rounded-t-none w-full`}>
{existingTags.length > 0 && existingTags.map((tag, index) => {
return tagInput.split(',').map((t) => t.trim()).includes(tag) === false && <li
className=' p-2 cursor-pointer hover:text-indigo-600 hover:bg-indigo-50 transition'
key={index}
onClick={() => {
// eslint-disable-next-line no-nested-ternary
const tagToInsert = tagInput + (tagInput.trim().slice(-1) === ',' ? '' : (tagInput.trim() ? ', ' : '')) + tag;
setTagInput(tagToInsert);
setShowSuggestions(false);
if (inputRef?.current) (inputRef.current as HTMLInputElement).focus();
}}>
<Icon type='tags' size={14} color='#bbb' /> {tag}
</li>;
})}
{existingTags.length === 0 && <p>No Existing Tags Found... </p>}
</ul>
)}
<button <button
className=" absolute right-2 top-2 cursor-pointer rounded p-2 px-4 bg-indigo-600 text-white font-semibold text-sm" className=" absolute right-2 top-2 cursor-pointer rounded p-2 px-4 bg-indigo-600 text-white font-semibold text-sm"
onClick={addTag}> onClick={addTag}>

View File

@@ -4,7 +4,9 @@ import dayjs from 'dayjs';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import countries from '../../utils/countries'; import countries from '../../utils/countries';
import ChartSlim from '../common/ChartSlim'; import ChartSlim from '../common/ChartSlim';
import { generateTheChartData } from '../common/generateChartData'; import KeywordPosition from './KeywordPosition';
import { generateTheChartData } from '../../utils/client/generateChartData';
import { formattedNum } from '../../utils/client/helpers';
type KeywordProps = { type KeywordProps = {
keywordData: KeywordType, keywordData: KeywordType,
@@ -19,7 +21,9 @@ type KeywordProps = {
lastItem?:boolean, lastItem?:boolean,
showSCData: boolean, showSCData: boolean,
scDataType: string, scDataType: string,
style: Object style: Object,
maxTitleColumnWidth: number,
tableColumns? : string[]
} }
const Keyword = (props: KeywordProps) => { const Keyword = (props: KeywordProps) => {
@@ -37,12 +41,16 @@ const Keyword = (props: KeywordProps) => {
style, style,
index, index,
scDataType = 'threeDays', scDataType = 'threeDays',
tableColumns = [],
maxTitleColumnWidth,
} = props; } = props;
const { const {
keyword, domain, ID, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = false, keyword, domain, ID, city, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = false, volume,
} = keywordData; } = keywordData;
const [showOptions, setShowOptions] = useState(false); const [showOptions, setShowOptions] = useState(false);
const [showPositionError, setPositionError] = useState(false); const [showPositionError, setPositionError] = useState(false);
const turncatedURL = useMemo(() => { const turncatedURL = useMemo(() => {
return url.replace(`https://${domain}`, '').replace(`https://www.${domain}`, '').replace(`http://${domain}`, ''); return url.replace(`https://${domain}`, '').replace(`https://www.${domain}`, '').replace(`http://${domain}`, '');
}, [url, domain]); }, [url, domain]);
@@ -67,25 +75,29 @@ const Keyword = (props: KeywordProps) => {
return status; return status;
}, [history, position]); }, [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).filter((el) => (el.position > 0));
if (historyArray[0]) {
bestPos = { ...historyArray[0] };
}
}
const renderPosition = (pos:number, type?:string) => { return bestPos || false;
if (pos === 0) { }, [history]);
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
} const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
if (updating && type !== 'sc') {
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
}
return pos;
};
return ( return (
<div <div
key={keyword} key={keyword + ID}
style={style} 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 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' : ''}`}> 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:shrink-0 lg:basis-28 lg:w-auto lg:flex lg:items-center'>
<button <button
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border 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'}`} ${selected ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}`}
@@ -94,9 +106,15 @@ const Keyword = (props: KeywordProps) => {
<Icon type="check" size={10} /> <Icon type="check" size={10} />
</button> </button>
<a <a
className='py-2 hover:text-blue-600' style={{ maxWidth: `${maxTitleColumnWidth - 35}px` }}
onClick={() => showKeywordDetails()}> className={'py-2 hover:text-blue-600 lg:flex lg:items-center w-full'}
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />{keyword} onClick={() => showKeywordDetails()}
title={keyword}
>
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />
<span className='inline-block text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-50px)]'>
{keyword}{city ? ` (${city})` : ''}
</span>
</a> </a>
{sticky && <button className='ml-2 relative top-[2px]' title='Favorite'><Icon type="star-filled" size={16} color="#fbd346" /></button>} {sticky && <button className='ml-2 relative top-[2px]' title='Favorite'><Icon type="star-filled" size={16} color="#fbd346" /></button>}
{lastUpdateError && lastUpdateError.date {lastUpdateError && lastUpdateError.date
@@ -105,37 +123,65 @@ const Keyword = (props: KeywordProps) => {
</button> </button>
} }
</div> </div>
<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 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`}> lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-24 lg:grow-0 lg:right-0 text-center font-semibold`}>
{renderPosition(position)} <KeywordPosition position={position} updating={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-[#5ed7c3]'> {positionChange}</i>}
{!updating && positionChange < 0 && <i className=' not-italic ml-1 text-xs text-red-300'> {positionChange}</i>} {!updating && positionChange < 0 && <i className=' not-italic ml-1 text-xs text-red-300'> {positionChange}</i>}
</div> </div>
<div
title={bestPosition && bestPosition.date
? new Date(bestPosition.date).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' }) : ''
}
className={`keyword_best mr-1 bg-[#f8f9ff] float-right mt-8 w-14 rounded right-5 lg:relative lg:block
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:mr-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-16 lg:grow-0 lg:right-0 text-center font-semibold
${!tableColumns.includes('Best') ? 'lg:hidden' : ''}
`}>
{bestPosition ? bestPosition.position || '-' : (position || '-')}
</div>
{chartData.labels.length > 0 && ( {chartData.labels.length > 0 && (
<div className='lg:flex-1 hidden lg:block'> <div
className={`hidden basis-20 grow-0 cursor-pointer lg:block ${!tableColumns.includes('History') ? 'lg:hidden' : ''}`}
onClick={() => showKeywordDetails()}>
<ChartSlim labels={chartData.labels} sreies={chartData.sreies} /> <ChartSlim labels={chartData.labels} sreies={chartData.sreies} />
</div> </div>
)} )}
<div
className={`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-24 lg:grow-0 lg:right-0 text-center
${!tableColumns.includes('Volume') ? 'lg:hidden' : ''}
`}>
{formattedNum(volume)}
</div>
<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] 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`}> overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-none lg:pr-5 lg:pl-3`}>
<a href={url} target="_blank" rel="noreferrer"><span className='mr-3 lg:hidden'> <a href={url} target="_blank" rel="noreferrer"><span className='mr-3 lg:hidden'>
<Icon type="link-alt" size={14} color="#999" /></span>{turncatedURL || '-'} <Icon type="link-alt" size={14} color="#999" /></span>{turncatedURL || '-'}
</a> </a>
</div> </div>
<div <div
className='inline-block mt-[4] top-[-5px] relative lg:flex-1 lg:m-0'> className='inline-block mt-[4] top-[-5px] relative lg:flex-1 lg:m-0 lg:top-0 max-w-[150px]'>
<span className='mr-2 lg:hidden'><Icon type="clock" size={14} color="#999" /></span> <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} /> <TimeAgo title={dayjs(lastUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={lastUpdated} />
</div> </div>
{showSCData && ( {showSCData && tableColumns.includes('Search Console') && (
<div className='keyword_sc_data min-w-[170px] text-xs mt-4 pt-2 border-t border-gray-100 top-[6px] <div className='keyword_sc_data min-w-[170px] lg:max-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'> 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='min-w-[40px]'>
<span className='lg:hidden'>SC Position: </span> <span className='lg:hidden'>SC Position: </span>
{renderPosition(keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0, 'sc')} <KeywordPosition
position={keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0}
type='sc'
/>
</span> </span>
<span className='min-w-[40px]'> <span className='min-w-[40px]'>
<span className='lg:hidden'>Impressions: </span>{keywordData?.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0} <span className='lg:hidden'>Impressions: </span>{keywordData?.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0}
@@ -176,6 +222,7 @@ const Keyword = (props: KeywordProps) => {
</ul> </ul>
)} )}
</div> </div>
{lastUpdateError && lastUpdateError.date && showPositionError && ( {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 <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'}`}> ${index > 2 ? 'lg:bottom-12 mt-[-70px]' : ' top-12'}`}>
@@ -189,7 +236,8 @@ const Keyword = (props: KeywordProps) => {
{lastUpdateError.scraper && <strong className='capitalize'>{lastUpdateError.scraper}: </strong>}{lastUpdateError.error} {lastUpdateError.scraper && <strong className='capitalize'>{lastUpdateError.scraper}: </strong>}{lastUpdateError.error}
</div> </div>
</div> </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 dayjs from 'dayjs';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import countries from '../../utils/countries'; import countries from '../../utils/countries';
import Chart from '../common/Chart'; import Chart from '../common/Chart';
import SelectField from '../common/SelectField'; 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 = { type KeywordDetailsProps = {
keyword: KeywordType, keyword: KeywordType,
@@ -13,11 +15,12 @@ type KeywordDetailsProps = {
const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => { const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
const updatedDate = new Date(keyword.lastUpdated); 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 [chartTime, setChartTime] = useState<string>('30');
const searchResultContainer = useRef<HTMLDivElement>(null); const searchResultContainer = useRef<HTMLDivElement>(null);
const searchResultFound = 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 = [ const dateOptions = [
{ label: 'Last 7 Days', value: '7' }, { label: 'Last 7 Days', value: '7' },
{ label: 'Last 30 Days', value: '30' }, { label: 'Last 30 Days', value: '30' },
@@ -26,39 +29,9 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
{ label: 'All Time', value: 'all' }, { label: 'All Time', value: 'all' },
]; ];
useEffect(() => { useOnKey('Escape', closeDetails);
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]);
useEffect(() => { useLayoutEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
console.log(event.key);
closeDetails();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeDetails]);
useEffect(() => {
if (keyword.position < 100 && keyword.position > 0 && searchResultFound?.current) { if (keyword.position < 100 && keyword.position > 0 && searchResultFound?.current) {
searchResultFound.current.scrollIntoView({ searchResultFound.current.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
@@ -81,11 +54,14 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
return ( return (
<div className="keywordDetails fixed w-full h-screen top-0 left-0 z-[99999]" 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 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'> <div className='keywordDetails__header p-6 border-b border-b-slate-200 text-slate-600'>
<h3 className=' text-lg font-bold'> <h3 className=' text-lg font-bold'>
<span title={countries[keyword.country][0]} <span title={countries[keyword.country][0]}
className={`fflag fflag-${keyword.country} w-[18px] h-[12px] mr-2`} /> {keyword.keyword} className={`fflag fflag-${keyword.country} w-[18px] h-[12px] mr-2`} /> {keyword.keyword}
<span className='py-1 px-2 ml-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 ${keyword.position === 0 ? 'text-gray-500' : 'text-blue-700'} text-xs font-bold`}>
{keyword.position === 0 ? 'Not in First 100' : keyword.position}
</span>
</h3> </h3>
<button <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-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 Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField'; import SelectField, { SelectionOption } from '../common/SelectField';
import countries from '../../utils/countries'; import countries from '../../utils/countries';
@@ -15,11 +15,8 @@ type KeywordFilterProps = {
integratedConsole?: boolean, integratedConsole?: boolean,
isConsole?: boolean, isConsole?: boolean,
SCcountries?: string[]; SCcountries?: string[];
} updateColumns?: Function,
tableColumns?: string[]
type KeywordCountState = {
desktop: number,
mobile: number
} }
const KeywordFilters = (props: KeywordFilterProps) => { const KeywordFilters = (props: KeywordFilterProps) => {
@@ -28,28 +25,32 @@ const KeywordFilters = (props: KeywordFilterProps) => {
setDevice, setDevice,
filterKeywords, filterKeywords,
allTags = [], allTags = [],
keywords, keywords = [],
updateSort, updateSort,
sortBy, sortBy,
filterParams, filterParams,
isConsole = false, isConsole = false,
integratedConsole = false, integratedConsole = false,
updateColumns,
SCcountries = [], SCcountries = [],
tableColumns = [],
} = props; } = props;
const [keywordCounts, setKeywordCounts] = useState<KeywordCountState>({ desktop: 0, mobile: 0 });
const [sortOptions, showSortOptions] = useState(false); const [sortOptions, showSortOptions] = useState(false);
const [filterOptions, showFilterOptions] = useState(false); const [filterOptions, showFilterOptions] = useState(false);
const [columnOptions, showColumnOptions] = useState(false);
useEffect(() => { const keywordCounts = useMemo(() => {
const keyWordCount = { desktop: 0, mobile: 0 }; const counts = { desktop: 0, mobile: 0 };
keywords.forEach((k) => { if (keywords && keywords.length > 0) {
if (k.device === 'desktop') { keywords.forEach((k) => {
keyWordCount.desktop += 1; if (k.device === 'desktop') {
} else { counts.desktop += 1;
keyWordCount.mobile += 1; } else {
} counts.mobile += 1;
}); }
setKeywordCounts(keyWordCount); });
}
return counts;
}, [keywords]); }, [keywords]);
const filterCountry = (cntrs:string[]) => filterKeywords({ ...filterParams, countries: cntrs }); const filterCountry = (cntrs:string[]) => filterKeywords({ ...filterParams, countries: cntrs });
@@ -64,14 +65,22 @@ const KeywordFilters = (props: KeywordFilterProps) => {
const countryOptions = useMemo(() => { const countryOptions = useMemo(() => {
const optionObject:{label:string, value:string}[] = []; const optionObject:{label:string, value:string}[] = [];
Object.keys(countries).forEach((countryISO:string) => { if (!isConsole) {
if (!isConsole || (isConsole && SCcountries.includes(countryISO))) { const allCountries = Array.from(keywords as KeywordType[])
optionObject.push({ label: countries[countryISO][0], value: countryISO }); .map((keyword) => keyword.country)
} .reduce<string[]>((acc, country) => [...acc, country], [])
}); .filter((t) => t && t.trim() !== '');
[...new Set(allCountries)].forEach((c) => optionObject.push({ label: countries[c][0], value: c }));
} else {
Object.keys(countries).forEach((countryISO:string) => {
if ((SCcountries.includes(countryISO))) {
optionObject.push({ label: countries[countryISO][0], value: countryISO });
}
});
}
return optionObject; return optionObject;
}, [SCcountries, isConsole]); }, [SCcountries, isConsole, keywords]);
const sortOptionChoices: SelectionOption[] = [ const sortOptionChoices: SelectionOption[] = [
{ value: 'pos_asc', label: 'Top Position' }, { value: 'pos_asc', label: 'Top Position' },
@@ -80,6 +89,19 @@ const KeywordFilters = (props: KeywordFilterProps) => {
{ value: 'date_desc', label: 'Oldest' }, { value: 'date_desc', label: 'Oldest' },
{ value: 'alpha_asc', label: 'Alphabetically(A-Z)' }, { value: 'alpha_asc', label: 'Alphabetically(A-Z)' },
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' }, { value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
{ value: 'vol_asc', label: 'Lowest Search Volume' },
{ value: 'vol_desc', label: 'Highest Search Volume' },
];
const columnOptionChoices: {label: string, value: string, locked: boolean}[] = [
{ value: 'Keyword', label: 'Keyword', locked: true },
{ value: 'Position', label: 'Position', locked: true },
{ value: 'URL', label: 'URL', locked: true },
{ value: 'Updated', label: 'Updated', locked: true },
{ value: 'Best', label: 'Best', locked: false },
{ value: 'History', label: 'History', locked: false },
{ value: 'Volume', label: 'Volume', locked: false },
{ value: 'Search Console', label: 'Search Console', locked: false },
]; ];
if (integratedConsole) { if (integratedConsole) {
sortOptionChoices.push({ value: 'imp_desc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` }); sortOptionChoices.push({ value: 'imp_desc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` });
@@ -174,8 +196,8 @@ const KeywordFilters = (props: KeywordFilterProps) => {
{sortOptions && ( {sortOptions && (
<ul <ul
data-testid="sort_options" data-testid="sort_options"
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg className='sort_options mt-2 border absolute w-48 min-w-[0] right-0 rounded-lg
max-h-96 bg-white z-[9999] w-44 overflow-y-auto styled-scrollbar'> max-h-96 bg-white z-[9999] overflow-y-auto styled-scrollbar'>
{sortOptionChoices.map((sortOption) => { {sortOptionChoices.map((sortOption) => {
return <li return <li
key={sortOption.value} key={sortOption.value}
@@ -187,6 +209,43 @@ const KeywordFilters = (props: KeywordFilterProps) => {
</ul> </ul>
)} )}
</div> </div>
{!isConsole && (
<div className='relative'>
<button
data-testid="columns_button"
className={`px-2 py-1 rounded ${columnOptions ? ' bg-indigo-100 text-blue-700' : ''}`}
title='Show/Hide Columns'
onClick={() => showColumnOptions(!columnOptions)}
>
<Icon type='eye-closed' size={18} />
</button>
{columnOptions && (
<ul
data-testid="sort_options"
className='sort_options mt-2 border absolute w-48 min-w-[0] right-0 rounded-lg
max-h-96 bg-white z-[9999] overflow-y-auto styled-scrollbar border-gray-200 '>
{columnOptionChoices.map(({ value, label, locked }) => {
return <li
key={value}
className={sortItemStyle(value) + (locked ? 'bg-gray-50 cursor-not-allowed pointer-events-none' : '') }
onClick={() => { if (updateColumns) { updateColumns(value); } showColumnOptions(false); }}
>
<span className={' inline-block px-[3px] border border-gray-200 rounded-[4px] w-5'}>
<Icon
title={locked ? 'Cannot be Hidden' : ''}
type={locked ? 'lock' : 'check'}
color={!tableColumns.includes(value) && !locked ? 'transparent' : '#999' }
size={12}
/>
</span>
{' '}{label}
</li>;
})}
</ul>
)}
</div>
)}
</div> </div>
</div> </div>
); );

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

@@ -10,7 +10,7 @@ type keywordTagManagerProps = {
allTags: string[] allTags: string[]
} }
const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => { const KeywordTagManager = ({ keyword, allTags = [], closeModal }: keywordTagManagerProps) => {
const [showAddTag, setShowAddTag] = useState<boolean>(false); const [showAddTag, setShowAddTag] = useState<boolean>(false);
const { mutate: updateMutate } = useUpdateKeywordTags(() => { }); const { mutate: updateMutate } = useUpdateKeywordTags(() => { });
@@ -51,6 +51,7 @@ const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
</div> </div>
{showAddTag && keyword && ( {showAddTag && keyword && (
<AddTags <AddTags
existingTags={allTags}
keywords={[keyword]} keywords={[keyword]}
closeModal={() => setShowAddTag(false)} closeModal={() => setShowAddTag(false)}
/> />

View File

@@ -1,9 +1,7 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { CSSTransition } from 'react-transition-group';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import AddKeywords from './AddKeywords'; import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/client/sortFilter';
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/sortFilter';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import Keyword from './Keyword'; import Keyword from './Keyword';
import KeywordDetails from './KeywordDetails'; import KeywordDetails from './KeywordDetails';
@@ -12,6 +10,10 @@ import Modal from '../common/Modal';
import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords'; import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords';
import KeywordTagManager from './KeywordTagManager'; import KeywordTagManager from './KeywordTagManager';
import AddTags from './AddTags'; import AddTags from './AddTags';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
import { useUpdateSettings } from '../../services/settings';
import { defaultSettings } from '../settings/Settings';
type KeywordsTableProps = { type KeywordsTableProps = {
domain: DomainType | null, domain: DomainType | null,
@@ -20,10 +22,12 @@ type KeywordsTableProps = {
showAddModal: boolean, showAddModal: boolean,
setShowAddModal: Function, setShowAddModal: Function,
isConsoleIntegrated: boolean, isConsoleIntegrated: boolean,
settings?: SettingsType
} }
const KeywordsTable = (props: KeywordsTableProps) => { const KeywordsTable = (props: KeywordsTableProps) => {
const { domain, keywords = [], isLoading = true, showAddModal = false, setShowAddModal, isConsoleIntegrated = false } = props; const titleColumnRef = useRef(null);
const { keywords = [], isLoading = true, isConsoleIntegrated = false, settings } = props;
const showSCData = isConsoleIntegrated; const showSCData = isConsoleIntegrated;
const [device, setDevice] = useState<string>('desktop'); const [device, setDevice] = useState<string>('desktop');
const [selectedKeywords, setSelectedKeywords] = useState<number[]>([]); const [selectedKeywords, setSelectedKeywords] = useState<number[]>([]);
@@ -31,35 +35,42 @@ const KeywordsTable = (props: KeywordsTableProps) => {
const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false); const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
const [showTagManager, setShowTagManager] = useState<null|number>(null); const [showTagManager, setShowTagManager] = useState<null|number>(null);
const [showAddTags, setShowAddTags] = useState<boolean>(false); const [showAddTags, setShowAddTags] = useState<boolean>(false);
const [isMobile, setIsMobile] = useState<boolean>(false);
const [SCListHeight, setSCListHeight] = useState(500); const [SCListHeight, setSCListHeight] = useState(500);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' }); const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('date_asc'); const [sortBy, setSortBy] = useState<string>('date_asc');
const [scDataType, setScDataType] = useState<string>('threeDays'); const [scDataType, setScDataType] = useState<string>('threeDays');
const [showScDataTypes, setShowScDataTypes] = useState<boolean>(false); const [showScDataTypes, setShowScDataTypes] = useState<boolean>(false);
const [maxTitleColumnWidth, setMaxTitleColumnWidth] = useState(235);
const { mutate: deleteMutate } = useDeleteKeywords(() => {}); const { mutate: deleteMutate } = useDeleteKeywords(() => {});
const { mutate: favoriteMutate } = useFavKeywords(() => {}); const { mutate: favoriteMutate } = useFavKeywords(() => {});
const { mutate: refreshMutate } = useRefreshKeywords(() => {}); const { mutate: refreshMutate } = useRefreshKeywords(() => {});
const [isMobile] = useIsMobile();
useWindowResize(() => {
setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
if (titleColumnRef.current) {
setMaxTitleColumnWidth((titleColumnRef.current as HTMLElement).clientWidth);
}
});
useEffect(() => {
if (titleColumnRef.current) {
setMaxTitleColumnWidth((titleColumnRef.current as HTMLElement).clientWidth);
}
}, [titleColumnRef]);
const tableColumns = settings?.keywordsColumns || ['Best', 'History', 'Volume', 'Search Console'];
const { mutate: updateMutate, isLoading: isUpdatingSettings } = useUpdateSettings(() => console.log(''));
const scDataObject:{ [k:string] : string} = { const scDataObject:{ [k:string] : string} = {
threeDays: 'Last Three Days', threeDays: 'Last Three Days',
sevenDays: 'Last Seven Days', sevenDays: 'Last Seven Days',
thirtyDays: 'Last Thirty Days', thirtyDays: 'Last Thirty Days',
avgSevenDays: 'Last Three Days Avg', avgThreeDays: 'Last Three Days Avg',
avgThreeDays: 'Last Seven Days Avg', avgSevenDays: 'Last Seven Days Avg',
avgThirtyDays: 'Last Thirty Days Avg', avgThirtyDays: 'Last Thirty Days Avg',
}; };
useEffect(() => {
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
const resizeList = () => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
resizeList();
window.addEventListener('resize', resizeList);
return () => {
window.removeEventListener('resize', resizeList);
};
}, [isMobile]);
const processedKeywords: {[key:string] : KeywordType[]} = useMemo(() => { const processedKeywords: {[key:string] : KeywordType[]} = useMemo(() => {
const procKeywords = keywords.filter((x) => x.device === device); const procKeywords = keywords.filter((x) => x.device === device);
const filteredKeywords = filterKeywords(procKeywords, filterParams); const filteredKeywords = filterKeywords(procKeywords, filterParams);
@@ -68,7 +79,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
}, [keywords, device, sortBy, filterParams, scDataType]); }, [keywords, device, sortBy, filterParams, scDataType]);
const allDomainTags: string[] = useMemo(() => { const allDomainTags: string[] = useMemo(() => {
const allTags = keywords.reduce((acc: string[], keyword) => [...acc, ...keyword.tags], []); const allTags = keywords.reduce((acc: string[], keyword) => [...acc, ...keyword.tags], []).filter((t) => t && t.trim() !== '');
return [...new Set(allTags)]; return [...new Set(allTags)];
}, [keywords]); }, [keywords]);
@@ -80,6 +91,16 @@ const KeywordsTable = (props: KeywordsTableProps) => {
} }
setSelectedKeywords(updatedSelectd); setSelectedKeywords(updatedSelectd);
}; };
const updateColumns = (column:string) => {
const newColumns = tableColumns.includes(column) ? tableColumns.filter((col) => col !== column) : [...tableColumns, column];
updateMutate({ ...defaultSettings, ...settings, keywordsColumns: newColumns });
};
const shouldHideColumn = useCallback((col:string) => {
return settings?.keywordsColumns && !settings?.keywordsColumns.includes(col) ? 'lg:hidden' : '';
}, [settings?.keywordsColumns]);
const Row = ({ data, index, style }:ListChildComponentProps) => { const Row = ({ data, index, style }:ListChildComponentProps) => {
const keyword = data[index]; const keyword = data[index];
return ( return (
@@ -98,6 +119,8 @@ const KeywordsTable = (props: KeywordsTableProps) => {
lastItem={index === (processedKeywords[device].length - 1)} lastItem={index === (processedKeywords[device].length - 1)}
showSCData={showSCData} showSCData={showSCData}
scDataType={scDataType} scDataType={scDataType}
tableColumns={tableColumns}
maxTitleColumnWidth={maxTitleColumnWidth}
/> />
); );
}; };
@@ -106,7 +129,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
return ( return (
<div> <div>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'> <div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-5'>
{selectedKeywords.length > 0 && ( {selectedKeywords.length > 0 && (
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '> <div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
<ul className=''> <ul className=''>
@@ -145,15 +168,19 @@ const KeywordsTable = (props: KeywordsTableProps) => {
keywords={keywords} keywords={keywords}
device={device} device={device}
setDevice={setDevice} setDevice={setDevice}
updateColumns={updateColumns}
tableColumns={tableColumns}
integratedConsole={isConsoleIntegrated} integratedConsole={isConsoleIntegrated}
/> />
)} )}
<div className={`domkeywordsTable domkeywordsTable--keywords ${showSCData ? 'domkeywordsTable--hasSC' : ''} <div className={`domkeywordsTable domkeywordsTable--keywords
${showSCData && tableColumns.includes('Search Console') ? 'domkeywordsTable--hasSC' : ''}
styled-scrollbar w-full overflow-auto min-h-[60vh]`}> styled-scrollbar w-full overflow-auto min-h-[60vh]`}>
<div className=' lg:min-w-[800px]'> <div className=' lg:min-w-[800px]'>
<div className={`domKeywords_head domKeywords_head--${sortBy} hidden lg:flex p-3 px-6 bg-[#FCFCFF] <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`}> text-gray-600 justify-between items-center font-semibold border-y`}>
<span className='domKeywords_head_keyword flex-1 basis-20 w-auto '> <span ref={titleColumnRef} className={`domKeywords_head_keyword flex-1 basis-[4rem] w-auto lg:flex-1
${showSCData && tableColumns.includes('Search Console') ? 'lg:basis-20' : 'lg:basis-10'} lg:w-auto lg:flex lg:items-center `}>
{processedKeywords[device].length > 0 && ( {processedKeywords[device].length > 0 && (
<button <button
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border border-slate-300 className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border border-slate-300
@@ -163,19 +190,25 @@ const KeywordsTable = (props: KeywordsTableProps) => {
<Icon type="check" size={10} /> <Icon type="check" size={10} />
</button> </button>
)} )}
Keyword {/* ${showSCData ? 'lg:min-w-[220px]' : 'lg:min-w-[280px]'} */}
<span className={`inline-block lg:flex lg:items-center
${showSCData && tableColumns.includes('Search Console') ? 'lg:max-w-[235px]' : ''}`}>
Keyword
</span>
</span> </span>
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>Position</span> <span className='domKeywords_head_position flex-1 basis-24 grow-0 text-center'>Position</span>
<span className='domKeywords_head_history flex-1'>History (7d)</span> <span className={`domKeywords_head_best flex-1 basis-16 grow-0 text-center ${shouldHideColumn('Best')}`}>Best</span>
<span className={`domKeywords_head_history flex-1 basis-20 grow-0 ${shouldHideColumn('History')}`}>History (7d)</span>
<span className={`domKeywords_head_volume flex-1 basis-24 grow-0 text-center ${shouldHideColumn('Volume')}`}>Volume</span>
<span className='domKeywords_head_url flex-1'>URL</span> <span className='domKeywords_head_url flex-1'>URL</span>
<span className='domKeywords_head_updated flex-1'>Updated</span> <span className='domKeywords_head_updated flex-1 relative left-3 max-w-[150px]'>Updated</span>
{showSCData && ( {showSCData && tableColumns.includes('Search Console') && (
<div className='domKeywords_head_sc flex-1 min-w-[170px] mr-7 text-center'> <div className='domKeywords_head_sc flex-1 min-w-[170px] lg:max-w-[170px] mr-7 text-center'>
{/* Search Console */} {/* Search Console */}
<div> <div>
<div <div
className=' w-48 select-none cursor-pointer absolute bg-white rounded-full 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-50' px-2 py-[2px] mt-[-22px] ml-3 border border-gray-200 z-40'
onClick={() => setShowScDataTypes(!showScDataTypes)}> onClick={() => setShowScDataTypes(!showScDataTypes)}>
<Icon type="google" size={13} /> {scDataObject[scDataType]} <Icon type="google" size={13} /> {scDataObject[scDataType]}
<Icon classes="ml-2" type={showScDataTypes ? 'caret-up' : 'caret-down'} size={10} /> <Icon classes="ml-2" type={showScDataTypes ? 'caret-up' : 'caret-down'} size={10} />
@@ -249,13 +282,6 @@ const KeywordsTable = (props: KeywordsTableProps) => {
</div> </div>
</Modal> </Modal>
)} )}
<CSSTransition in={showAddModal} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddKeywords
domain={domain?.domain || ''}
keywords={keywords}
closeModal={() => setShowAddModal(false)}
/>
</CSSTransition>
{showTagManager && ( {showTagManager && (
<KeywordTagManager <KeywordTagManager
allTags={allDomainTags} allTags={allDomainTags}
@@ -265,6 +291,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
)} )}
{showAddTags && ( {showAddTags && (
<AddTags <AddTags
existingTags={allDomainTags}
keywords={keywords.filter((k) => selectedKeywords.includes(k.ID))} keywords={keywords.filter((k) => selectedKeywords.includes(k.ID))}
closeModal={() => setShowAddTags(false)} closeModal={() => setShowAddTags(false)}
/> />

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import countries from '../../utils/countries'; import countries from '../../utils/countries';
import KeywordPosition from './KeywordPosition';
import { formattedNum } from '../../utils/client/helpers';
type SCKeywordProps = { type SCKeywordProps = {
keywordData: SearchAnalyticsItem, keywordData: SearchAnalyticsItem,
@@ -15,13 +17,6 @@ const SCKeyword = (props: SCKeywordProps) => {
const { keywordData, selected, lastItem, selectKeyword, style, isTracked = false } = props; const { keywordData, selected, lastItem, selectKeyword, style, isTracked = false } = props;
const { keyword, uid, position, country, impressions, ctr, clicks } = keywordData; const { keyword, uid, position, country, impressions, ctr, clicks } = keywordData;
const renderPosition = () => {
if (position === 0) {
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
}
return Math.round(position);
};
return ( return (
<div <div
key={keyword} key={keyword}
@@ -45,7 +40,7 @@ const SCKeyword = (props: SCKeywordProps) => {
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-15 p-2 text-base mt-[-20px] rounded right-5 lg:relative <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`}> lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
{renderPosition()} <KeywordPosition position={position} />
<span className='block text-xs text-gray-500 lg:hidden'>Position</span> <span className='block text-xs text-gray-500 lg:hidden'>Position</span>
</div> </div>
@@ -53,14 +48,14 @@ const SCKeyword = (props: SCKeywordProps) => {
<span className='mr-3 lg:hidden'> <span className='mr-3 lg:hidden'>
<Icon type="eye" size={14} color="#999" /> <Icon type="eye" size={14} color="#999" />
</span> </span>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(impressions)} {formattedNum(impressions)}
</div> </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'}> <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'> <span className='mr-3 lg:hidden'>
<Icon type="cursor" size={14} color="#999" /> <Icon type="cursor" size={14} color="#999" />
</span> </span>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(clicks)} {formattedNum(clicks)}
</div> </div>
<div className='keyword_ctr text-center inline-block mt-4 relative lg:flex-1 lg:m-0 '> <div className='keyword_ctr text-center inline-block mt-4 relative lg:flex-1 lg:m-0 '>

View File

@@ -1,12 +1,15 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { useAddKeywords, useFetchKeywords } from '../../services/keywords'; import { useAddKeywords, useFetchKeywords } from '../../services/keywords';
import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/SCsortFilter'; import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/client/SCsortFilter';
import Icon from '../common/Icon'; import Icon from '../common/Icon';
import KeywordFilters from './KeywordFilter'; import KeywordFilters from './KeywordFilter';
import SCKeyword from './SCKeyword'; import SCKeyword from './SCKeyword';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
import { formattedNum } from '../../utils/client/helpers';
type SCKeywordsTableProps = { type SCKeywordsTableProps = {
domain: DomainType | null, domain: DomainType | null,
@@ -27,11 +30,13 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]); const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' }); const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('imp_desc'); const [sortBy, setSortBy] = useState<string>('imp_desc');
const [isMobile, setIsMobile] = useState<boolean>(false);
const [SCListHeight, setSCListHeight] = useState(500); const [SCListHeight, setSCListHeight] = useState(500);
const { keywordsData } = useFetchKeywords(router); const { keywordsData } = useFetchKeywords(router, domain?.domain || '');
const addedkeywords: string[] = keywordsData?.keywords?.map((key: KeywordType) => `${key.keyword}:${key.country}:${key.device}`) || []; 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 { 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 finalKeywords: {[key:string] : SCKeywordType[] } = useMemo(() => {
const procKeywords = keywords.filter((x) => x.device === device); const procKeywords = keywords.filter((x) => x.device === device);
const filteredKeywords = SCfilterKeywords(procKeywords, filterParams); const filteredKeywords = SCfilterKeywords(procKeywords, filterParams);
@@ -71,16 +76,6 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
}; };
}, [finalKeywords, device]); }, [finalKeywords, device]);
useEffect(() => {
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
const resizeList = () => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
resizeList();
window.addEventListener('resize', resizeList);
return () => {
window.removeEventListener('resize', resizeList);
};
}, [isMobile]);
const selectKeyword = (keywordID: string) => { const selectKeyword = (keywordID: string) => {
console.log('Select Keyword: ', keywordID); console.log('Select Keyword: ', keywordID);
let updatedSelectd = [...selectedKeywords, keywordID]; let updatedSelectd = [...selectedKeywords, keywordID];
@@ -121,7 +116,7 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
return ( return (
<div> <div>
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'> <div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-4'>
{selectedKeywords.length > 0 && ( {selectedKeywords.length > 0 && (
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '> <div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
<ul className=''> <ul className=''>
@@ -194,10 +189,10 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
</span> </span>
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>{viewSummary.position}</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'> <span className='domKeywords_head_imp flex-1 text-center'>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.impressions)} {formattedNum(viewSummary.impressions)}
</span> </span>
<span className='domKeywords_head_visits flex-1 text-center'> <span className='domKeywords_head_visits flex-1 text-center'>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.visits)} {formattedNum(viewSummary.visits)}
</span> </span>
<span className='domKeywords_head_ctr flex-1 text-center'> <span className='domKeywords_head_ctr flex-1 text-center'>
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(viewSummary.ctr)}% {new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(viewSummary.ctr)}%
@@ -214,7 +209,7 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
)} )}
{!isConsoleIntegrated && ( {!isConsoleIntegrated && (
<p className=' p-9 pt-[10%] text-center text-gray-500'> <p className=' p-9 pt-[10%] text-center text-gray-500'>
Google Search 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. 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> </p>
)} )}
</div> </div>

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { useMutateKeywordsVolume, useTestAdwordsIntegration } from '../../services/adwords';
import Icon from '../common/Icon';
import SecretField from '../common/SecretField';
type AdWordsSettingsProps = {
settings: SettingsType,
settingsError: null | {
type: string,
msg: string
},
updateSettings: Function,
performUpdate: Function,
closeSettings: Function
}
const AdWordsSettings = ({ settings, settingsError, updateSettings, performUpdate, closeSettings }:AdWordsSettingsProps) => {
const {
adwords_client_id = '',
adwords_client_secret = '',
adwords_developer_token = '',
adwords_account_id = '',
adwords_refresh_token = '',
} = settings || {};
const { mutate: testAdWordsIntegration, isLoading: isTesting } = useTestAdwordsIntegration();
const { mutate: getAllVolumeData, isLoading: isUpdatingVolume } = useMutateKeywordsVolume();
const cloudProjectIntegrated = adwords_client_id && adwords_client_secret && adwords_refresh_token;
const hasAllCredentials = adwords_client_id && adwords_client_secret && adwords_refresh_token && adwords_developer_token && adwords_account_id;
const udpateAndAuthenticate = () => {
if (adwords_client_id && adwords_client_secret) {
const link = document.createElement('a');
link.href = `https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fadwords&response_type=code&client_id=${adwords_client_id}&redirect_uri=${`${encodeURIComponent(window.location.origin)}/api/adwords`}&service=lso&o2v=2&theme=glif&flowName=GeneralOAuthFlow`;
link.target = '_blank';
link.click();
if (performUpdate) {
performUpdate();
closeSettings();
}
}
};
const testIntegration = () => {
if (hasAllCredentials) {
testAdWordsIntegration({ developer_token: adwords_developer_token, account_id: adwords_account_id });
}
};
const updateVolumeData = () => {
if (hasAllCredentials) {
getAllVolumeData({ domain: 'all' });
}
};
return (
<div>
<div>
<div className=' border-t border-gray-100 pt-4 pb-0'>
<h4 className=' mb-3 font-semibold text-blue-700'>Step 1: Connect Google Cloud Project</h4>
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<SecretField
label='Client ID'
onChange={(client_id:string) => updateSettings('adwords_client_id', client_id)}
value={adwords_client_id}
placeholder='3943006-231f65cjm.apps.googleusercontent.com'
/>
</div>
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<SecretField
label='Client Secret'
onChange={(client_secret:string) => updateSettings('adwords_client_secret', client_secret)}
value={adwords_client_secret}
placeholder='GTXSPX-45asaf-u1s252sd6qdE9yc8T'
/>
</div>
<button
className={`py-2 px-5 w-full text-sm font-semibold rounded bg-indigo-50 text-blue-700 border border-indigo-100
${adwords_client_id && adwords_client_secret ? 'cursor-pointer' : ' cursor-not-allowed opacity-40'}
hover:bg-blue-700 hover:text-white transition`}
title='Insert All the data in the above fields to Authenticate'
onClick={udpateAndAuthenticate}>
<Icon type='google' size={14} /> {adwords_refresh_token ? 'Re-Authenticate' : 'Authenticate'} Integration
</button>
</div>
<div className='mt-4 border-t mb-4 border-b border-gray-100 pt-4 pb-0 relative'>
{!cloudProjectIntegrated && <div className=' absolute w-full h-full z-50' />}
<h4 className=' mb-3 font-semibold text-blue-700'>Step 2: Connect Google Ads</h4>
<div className={!cloudProjectIntegrated ? 'opacity-40' : ''}>
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<SecretField
label='Developer Token'
onChange={(developer_token:string) => updateSettings('adwords_developer_token', developer_token)}
value={adwords_developer_token}
placeholder='4xr6jY94kAxtXk4rfcgc4w'
/>
</div>
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<SecretField
label='Test Account ID'
onChange={(account_id:string) => updateSettings('adwords_account_id', account_id)}
value={adwords_account_id}
placeholder='590-948-9101'
/>
</div>
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<button
className={`py-2 px-5 w-full text-sm font-semibold rounded bg-indigo-50 text-blue-700 border border-indigo-100
${hasAllCredentials ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}
hover:bg-blue-700 hover:text-white transition`}
title={hasAllCredentials ? '' : 'Insert All the data in the above fields to Test the Integration'}
onClick={testIntegration}>
{isTesting && <Icon type='loading' />}
<Icon type='adwords' size={14} /> Test Google Ads Integration
</button>
</div>
</div>
</div>
<div className='mt-4 mb-4 border-b border-gray-100 pt-4 pb-0 relative'>
{!hasAllCredentials && <div className=' absolute w-full h-full z-50' />}
<h4 className=' mb-3 font-semibold text-blue-700'>Update Keyword Volume Data</h4>
<div className={!hasAllCredentials ? 'opacity-40' : ''}>
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<p>Update Volume data for all your Tracked Keywords.</p>
</div>
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<button
className={`py-2 px-5 w-full text-sm font-semibold rounded bg-indigo-50 text-blue-700 border border-indigo-100
${hasAllCredentials ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}
hover:bg-blue-700 hover:text-white transition`}
onClick={updateVolumeData}>
<Icon type={isUpdatingVolume ? 'loading' : 'reload'} size={isUpdatingVolume ? 16 : 12} /> Update Keywords Volume Data
</button>
</div>
</div>
</div>
<p className='mb-4 text-xs'>
Relevant Documentation: <a target='_blank' rel='noreferrer' href='https://docs.serpbear.com/miscellaneous/integrate-google-ads' className=' underline text-blue-600'>Integrate Google Ads</a>.
</p>
</div>
</div>
);
};
export default AdWordsSettings;

View File

@@ -0,0 +1,80 @@
import React, { useLayoutEffect, useMemo } from 'react';
import TimeAgo from 'react-timeago';
import dayjs from 'dayjs';
import SidePanel from '../common/SidePanel';
import { useFetchChangelog } from '../../services/misc';
import Icon from '../common/Icon';
const Markdown = React.lazy(() => import('react-markdown'));
type ChangeLogProps = {
closeChangeLog: Function,
}
const ChangeLogloader = () => {
return (
<div className='w-full h-full absolute flex justify-center items-center'>
<Icon type="loading" size={36} color='#999' />
</div>
);
};
const ChangeLog = ({ closeChangeLog }: ChangeLogProps) => {
const { data: changeLogData, isLoading } = useFetchChangelog();
useLayoutEffect(() => {
document.body.style.overflow = 'hidden';
// eslint-disable-next-line no-unused-expressions
() => {
console.log('run CleanUp !');
document.body.style.overflow = 'auto';
};
}, []);
const onClose = () => {
document.body.style.overflow = 'auto';
closeChangeLog();
};
const changeLogs = useMemo(() => {
if (changeLogData && Array.isArray(changeLogData)) {
return changeLogData.map(({ name = '', body, published_at }:{name: string, body: string, published_at: string}) => ({
version: name,
major: !!(name.match(/v\d+\.0+\.0/)),
date: published_at,
content: body.replaceAll(/^(##|###) \[([^\]]+)\]\(([^)]+)\) \(([^)]+)\)/g, '')
.replaceAll(/\(\[(.*?)\]\((https:\/\/github\.com\/towfiqi\/serpbear\/commit\/([a-f0-9]{40}))\)\)/g, ''),
}));
}
return [];
}, [changeLogData]);
return <SidePanel title='SerpBear Changelog' closePanel={onClose}>
<React.Suspense fallback={<ChangeLogloader />}>
{!isLoading && changeLogs.length > 0 && (
<div className='changelog-body bg-[#f8f9ff] px-6 pt-4 pb-10 overflow-y-auto styled-scrollbar'>
{changeLogs.map(({ version, content, date, major }) => {
return (
<div
key={version}
className={`domKeywords bg-white rounded mb-6 border ${major ? ' border-indigo-400' : 'border-transparent'}`}>
<h4 className=' px-5 py-3 border-b border-b-gray-100 flex justify-between text-indigo-700 font-semibold'>
<a href={`https://github.com/towfiqi/serpbear/releases/tag/${version}`}>
{version} {major && <span className=' text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded ml-2'>Major</span>}
</a>
<span className=' text-sm text-gray-500'>
<TimeAgo title={dayjs(date).format('DD-MMM-YYYY, hh:mm:ss A')} date={date} />
</span>
</h4>
<div className='changelog-content px-5 py-3 text-sm text-left'><Markdown>{content}</Markdown></div>
</div>
);
})}
</div>
)}
{isLoading && <ChangeLogloader />}
</React.Suspense>
</SidePanel>;
};
export default ChangeLog;

View File

@@ -0,0 +1,53 @@
import React, { useState } from 'react';
import SearchConsoleSettings from './SearchConsoleSettings';
import AdWordsSettings from './AdWordsSettings';
import Icon from '../common/Icon';
type IntegrationSettingsProps = {
settings: SettingsType,
settingsError: null | {
type: string,
msg: string
},
updateSettings: Function,
performUpdate: Function,
closeSettings: Function
}
const IntegrationSettings = ({ settings, settingsError, updateSettings, performUpdate, closeSettings }:IntegrationSettingsProps) => {
const [currentTab, setCurrentTab] = useState<string>('searchconsole');
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm';
return (
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className='mb-4 '>
<ul>
<li
className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-blue-50 text-blue-600' : ''}`}
onClick={() => setCurrentTab('searchconsole')}>
<Icon type='google' size={14} /> Search Console
</li>
<li
className={`${tabStyle} ${currentTab === 'adwords' ? ' bg-blue-50 text-blue-600' : ''}`}
onClick={() => setCurrentTab('adwords')}>
<Icon type='adwords' size={14} /> Google Ads
</li>
</ul>
</div>
<div>
{currentTab === 'searchconsole' && settings && (
<SearchConsoleSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
{currentTab === 'adwords' && settings && (
<AdWordsSettings
settings={settings}
updateSettings={updateSettings}
settingsError={settingsError}
performUpdate={performUpdate}
closeSettings={closeSettings}
/>
)}
</div>
</div>
);
};
export default IntegrationSettings;

View File

@@ -0,0 +1,114 @@
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 className="settings__section__input mb-5">
<InputField
label='Email From Name'
hasError={settingsError?.type === 'no_smtp_from'}
value={settings?.notification_email_from_name || 'Serpbear'}
placeholder="Serpbear"
onChange={(value:string) => updateSettings('notification_email_from_name', 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,137 @@
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' && (
<div className="settings__section__secret mb-5">
<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)}
/>
</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?.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 }
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,48 @@
import React from 'react';
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>
{/* <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,9 +1,11 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
// import { useQuery } from 'react-query'; import { useFetchSettings, useUpdateSettings } from '../../services/settings';
import useUpdateSettings, { useFetchSettings } from '../../services/settings';
import Icon from '../common/Icon'; 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 IntegrationSettings from './IntegrationSettings';
type SettingsProps = { type SettingsProps = {
closeSettings: Function, closeSettings: Function,
@@ -15,9 +17,10 @@ type SettingsError = {
msg: string msg: string
} }
const defaultSettings = { export const defaultSettings: SettingsType = {
scraper_type: 'none', scraper_type: 'none',
scrape_delay: 'none', scrape_delay: 'none',
scrape_retry: false,
notification_interval: 'daily', notification_interval: 'daily',
notification_email: '', notification_email: '',
smtp_server: '', smtp_server: '',
@@ -25,6 +28,11 @@ const defaultSettings = {
smtp_username: '', smtp_username: '',
smtp_password: '', smtp_password: '',
notification_email_from: '', notification_email_from: '',
notification_email_from_name: 'SerpBear',
search_console: true,
search_console_client_email: '',
search_console_private_key: '',
keywordsColumns: ['Best', 'History', 'Volume', 'Search Console'],
}; };
const Settings = ({ closeSettings }:SettingsProps) => { const Settings = ({ closeSettings }:SettingsProps) => {
@@ -33,6 +41,7 @@ const Settings = ({ closeSettings }:SettingsProps) => {
const [settingsError, setSettingsError] = useState<SettingsError|null>(null); const [settingsError, setSettingsError] = useState<SettingsError|null>(null);
const { mutate: updateMutate, isLoading: isUpdating } = useUpdateSettings(() => console.log('')); const { mutate: updateMutate, isLoading: isUpdating } = useUpdateSettings(() => console.log(''));
const { data: appSettings, isLoading } = useFetchSettings(); const { data: appSettings, isLoading } = useFetchSettings();
useOnKey('Escape', closeSettings);
useEffect(() => { useEffect(() => {
if (appSettings && appSettings.settings) { if (appSettings && appSettings.settings) {
@@ -40,26 +49,13 @@ const Settings = ({ closeSettings }:SettingsProps) => {
} }
}, [appSettings]); }, [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) => { const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation(); e.stopPropagation();
e.nativeEvent.stopImmediatePropagation(); e.nativeEvent.stopImmediatePropagation();
if (e.target === e.currentTarget) { closeSettings(); } 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 }); setSettings({ ...settings, [key]: value });
}; };
@@ -88,231 +84,64 @@ const Settings = ({ closeSettings }:SettingsProps) => {
} else { } else {
// Perform Update // Perform Update
updateMutate(settings); updateMutate(settings);
// If Scraper is updated, refresh the page.
if (appSettings.settings === 'none' && scraper_type !== 'none') {
window.location.reload();
}
} }
}; };
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 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 tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm';
return ( return (
<div className="settings fixed w-full h-screen top-0 left-0 z-50" onClick={closeOnBGClick}> <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>} {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> <h3 className=' text-black text-lg font-bold'>Settings</h3>
<button <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()}> onClick={() => closeSettings()}>
<Icon type='close' size={24} /> <Icon type='close' size={24} />
</button> </button>
</div> </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> <ul>
<li <li
className={`${tabStyle} ${currentTab === 'scraper' ? ' bg-blue-50 text-blue-600' : ''}`} className={`${tabStyle} ${currentTab === 'scraper' ? tabStyleActive : 'border-transparent '}`}
onClick={() => setCurrentTab('scraper')}> onClick={() => setCurrentTab('scraper')}>
Scraper <Icon type='scraper' /> Scraper
</li> </li>
<li <li
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-blue-50 text-blue-600' : ''}`} className={`${tabStyle} ${currentTab === 'notification' ? tabStyleActive : 'border-transparent'}`}
onClick={() => setCurrentTab('notification')}> onClick={() => setCurrentTab('notification')}>
Notification <Icon type='email' /> Notification
</li>
<li
className={`${tabStyle} ${currentTab === 'integrations' ? tabStyleActive : 'border-transparent'}`}
onClick={() => setCurrentTab('integrations')}>
<Icon type='integration' size={14} /> Integrations
</li> </li>
</ul> </ul>
</div> </div>
{currentTab === 'scraper' && ( {currentTab === 'scraper' && settings && (
<div> <ScraperSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
<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', 'serply', 'serpapi', 'spaceSerp'].includes(settings.scraper_type) && (
<div className="settings__section__input mr-3">
<label className={labelStyle}>Scraper API Key or Token</label>
<input
className={`w-full p-2 border border-gray-200 rounded mt-2 mb-3 focus:outline-none focus:border-blue-200
${settingsError && 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>
)}
{settings.scraper_type !== 'none' && (
<div className="settings__section__input mb-5">
<label className={labelStyle}>Scraping Frequency</label>
<SelectField
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={270}
/>
<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">
<label className={labelStyle}>Delay Between Each keyword Scrape</label>
<SelectField
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={270}
/>
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
</div>
</div>
</div>
)} )}
{currentTab === 'notification' && ( {currentTab === 'notification' && settings && (
<div> <NotificationSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
<div className='settings__content styled-scrollbar p-6 text-sm'> )}
<div className="settings__section__input mb-5"> {currentTab === 'integrations' && settings && (
<label className={labelStyle}>Notification Frequency</label> <IntegrationSettings
<SelectField settings={settings}
multiple={false} updateSettings={updateSettings}
selected={[settings.notification_interval]} settingsError={settingsError}
options={notificationOptions} performUpdate={performUpdate}
defaultLabel={'Notification Settings'} closeSettings={closeSettings}
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'}
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'}
type="text"
value={settings?.smtp_password || ''}
onChange={(event) => updateSettings('smtp_password', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>From Email Address</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_from' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.notification_email_from || ''}
placeholder="no-reply@mydomain.com"
onChange={(event) => updateSettings('notification_email_from', event.target.value)}
/>
</div>
</>
)}
</div>
{settingsError && (
<div className='absolute w-full bottom-16 text-center p-3 bg-red-100 text-red-600 text-sm font-semibold'>
{settingsError.msg}
</div>
)}
</div>
)}
<div className=' border-t-[1px] border-gray-200 p-2 px-3'> <div className=' border-t-[1px] border-gray-200 p-2 px-3'>
<button <button
onClick={() => performUpdate()} onClick={() => performUpdate()}

37
cron.js
View File

@@ -1,7 +1,8 @@
/* eslint-disable no-new */
const Cryptr = require('cryptr'); const Cryptr = require('cryptr');
const { promises } = require('fs'); const { promises } = require('fs');
const { readFile } = require('fs'); const { readFile } = require('fs');
const Cron = require('croner'); const { Cron } = require('croner');
require('dotenv').config({ path: './.env.local' }); require('dotenv').config({ path: './.env.local' });
const getAppSettings = async () => { const getAppSettings = async () => {
@@ -56,7 +57,7 @@ const generateCronTime = (interval) => {
cronTime = '0 0 3 * * *'; cronTime = '0 0 3 * * *';
} }
if (interval === 'weekly') { if (interval === 'weekly') {
cronTime = '0 0 0 */7 * *'; cronTime = '0 0 * * 1';
} }
if (interval === 'monthly') { if (interval === 'monthly') {
cronTime = '0 0 1 * *'; // Run every first day of the month at 00:00(midnight) cronTime = '0 0 1 * *'; // Run every first day of the month at 00:00(midnight)
@@ -71,7 +72,7 @@ const runAppCronJobs = () => {
const scrape_interval = settings.scrape_interval || 'daily'; const scrape_interval = settings.scrape_interval || 'daily';
if (scrape_interval !== 'never') { if (scrape_interval !== 'never') {
const scrapeCronTime = generateCronTime(scrape_interval); const scrapeCronTime = generateCronTime(scrape_interval);
Cron(scrapeCronTime, () => { new Cron(scrapeCronTime, () => {
// console.log('### Running Keyword Position Cron Job!'); // console.log('### Running Keyword Position Cron Job!');
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } }; const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/cron`, fetchOpts) fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/cron`, fetchOpts)
@@ -89,7 +90,7 @@ const runAppCronJobs = () => {
if (notif_interval) { if (notif_interval) {
const cronTime = generateCronTime(notif_interval === 'daily' ? 'daily_morning' : notif_interval); const cronTime = generateCronTime(notif_interval === 'daily' ? 'daily_morning' : notif_interval);
if (cronTime) { if (cronTime) {
Cron(cronTime, () => { new Cron(cronTime, () => {
// console.log('### Sending Notification Email...'); // console.log('### Sending Notification Email...');
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } }; const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/notify`, fetchOpts) fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/notify`, fetchOpts)
@@ -106,21 +107,25 @@ const runAppCronJobs = () => {
// Run Failed scraping CRON (Every Hour) // Run Failed scraping CRON (Every Hour)
const failedCronTime = generateCronTime('hourly'); const failedCronTime = generateCronTime('hourly');
Cron(failedCronTime, () => { new Cron(failedCronTime, () => {
// console.log('### Retrying Failed Scrapes...'); // console.log('### Retrying Failed Scrapes...');
readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => { readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => {
if (data) { if (data) {
const keywordsToRetry = data ? JSON.parse(data) : []; try {
if (keywordsToRetry.length > 0) { const keywordsToRetry = data ? JSON.parse(data) : [];
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } }; if (keywordsToRetry.length > 0) {
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/refresh?id=${keywordsToRetry.join(',')}`, fetchOpts) const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
.then((res) => res.json()) fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/refresh?id=${keywordsToRetry.join(',')}`, fetchOpts)
.then((refreshedData) => console.log(refreshedData)) .then((res) => res.json())
.catch((fetchErr) => { .then((refreshedData) => console.log(refreshedData))
console.log('ERROR Making failed_queue Cron Request..'); .catch((fetchErr) => {
console.log(fetchErr); console.log('ERROR Making failed_queue Cron Request..');
}); console.log(fetchErr);
});
}
} catch (error) {
console.log('ERROR Reading Failed Scrapes Queue File..', error);
} }
} else { } else {
console.log('ERROR Reading Failed Scrapes Queue File..', err); console.log('ERROR Reading Failed Scrapes Queue File..', err);
@@ -131,7 +136,7 @@ const runAppCronJobs = () => {
// Run Google Search Console Scraper Daily // Run Google Search Console Scraper Daily
if (process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) { if (process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) {
const searchConsoleCRONTime = generateCronTime('daily'); const searchConsoleCRONTime = generateCronTime('daily');
Cron(searchConsoleCRONTime, () => { new Cron(searchConsoleCRONTime, () => {
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } }; const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/searchconsole`, fetchOpts) fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/searchconsole`, fetchOpts)
.then((res) => res.json()) .then((res) => res.json())

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

@@ -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

@@ -0,0 +1,35 @@
// Migration: Adds volume field to the 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.volume) {
await queryInterface.addColumn('keyword', 'volume', {
type: Sequelize.DataTypes.STRING, allowNull: false, defaultValue: 0,
}, { 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.volume) {
await queryInterface.removeColumn('keyword', 'volume', { 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: '' }) @Column({ type: DataType.STRING, allowNull: true, defaultValue: '' })
notification_emails!: string; notification_emails!: string;
@Column({ type: DataType.STRING, allowNull: true })
search_console!: string;
} }
export default Domain; export default Domain;

View File

@@ -19,7 +19,13 @@ class Keyword extends Model {
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'US' }) @Column({ type: DataType.STRING, allowNull: true, defaultValue: 'US' })
country!: string; 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; domain!: string;
// @ForeignKey(() => Domain) // @ForeignKey(() => Domain)
@@ -41,6 +47,9 @@ class Keyword extends Model {
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) }) @Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
history!: string; history!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0 })
volume!: number;
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) }) @Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
url!: string; url!: string;
@@ -58,6 +67,9 @@ class Keyword extends Model {
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'false' }) @Column({ type: DataType.STRING, allowNull: true, defaultValue: 'false' })
lastUpdateError!: string; lastUpdateError!: string;
@Column({ type: DataType.STRING, allowNull: true })
settings!: string;
} }
export default Keyword; export default Keyword;

View File

@@ -442,6 +442,7 @@
<tr align="left"> <tr align="left">
<th>Keyword</th> <th>Keyword</th>
<th>Position</th> <th>Position</th>
<th>Best</th>
<th>Updated</th> <th>Updated</th>
</tr> </tr>
{{keywordsTable}} {{keywordsTable}}

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 // eslint-disable-next-line no-unused-vars
import 'isomorphic-fetch'; import 'isomorphic-fetch';
import './styles/globals.css'; 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. // Optional: configure or set up a testing framework before each test.
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
// Used for __tests__/testing-library.js // Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom // 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'); global.ResizeObserver = require('resize-observer-polyfill');
// Enable Fetch Mocking
enableFetchMocks();

19164
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "serpbear", "name": "serpbear",
"version": "0.2.6", "version": "2.0.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -13,67 +13,76 @@
"test": "jest --watch --verbose", "test": "jest --watch --verbose",
"test:ci": "jest --ci", "test:ci": "jest --ci",
"test:cv": "jest --coverage --coverageDirectory='coverage'", "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" "release": "standard-version"
}, },
"dependencies": { "dependencies": {
"@googleapis/searchconsole": "^1.0.0", "@googleapis/searchconsole": "^1.0.5",
"@testing-library/react": "^13.4.0", "@isaacs/ttlcache": "^1.4.1",
"@types/react-transition-group": "^4.4.5", "@types/react-transition-group": "^4.4.5",
"axios": "^1.1.3", "axios": "^1.7.7",
"axios-retry": "^3.3.1", "axios-retry": "^3.3.1",
"chart.js": "^3.9.1", "chart.js": "^3.9.1",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0",
"concurrently": "^7.6.0", "concurrently": "^7.6.0",
"cookies": "^0.8.0", "cookies": "^0.8.0",
"croner": "^5.3.5", "croner": "^9.0.0",
"cryptr": "^6.0.3", "cryptr": "^6.0.3",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"google-auth-library": "^9.6.3",
"https-proxy-agent": "^5.0.1", "https-proxy-agent": "^5.0.1",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^9.0.2",
"msw": "^0.49.0", "next": "^12.3.4",
"next": "12.3.1", "nodemailer": "^6.9.9",
"node-cron": "^3.0.2",
"nodemailer": "^6.8.0",
"react": "18.2.0", "react": "18.2.0",
"react-chartjs-2": "^4.3.1", "react-chartjs-2": "^4.3.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"react-markdown": "^9.0.1",
"react-query": "^3.39.2", "react-query": "^3.39.2",
"react-timeago": "^7.1.0", "react-timeago": "^7.1.0",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-window": "^1.8.8", "react-window": "^1.8.8",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"sequelize": "^6.25.2", "sequelize": "^6.34.0",
"sequelize-typescript": "^2.1.5", "sequelize-typescript": "^2.1.6",
"sqlite3": "^5.1.2" "sqlite3": "^5.1.7",
"umzug": "^3.8.2"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^14.0.0",
"@types/cookies": "^0.7.7", "@types/cookies": "^0.7.7",
"@types/cryptr": "^4.0.1", "@types/cryptr": "^4.0.1",
"@types/isomorphic-fetch": "^0.0.36", "@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^29.5.8",
"@types/jsonwebtoken": "^8.5.9", "@types/jsonwebtoken": "^8.5.9",
"@types/node": "18.11.0", "@types/node": "18.11.0",
"@types/nodemailer": "^6.4.6", "@types/nodemailer": "^6.4.6",
"@types/react": "18.0.21", "@types/react": "18.2.0",
"@types/react-dom": "18.0.6", "@types/react-dom": "18.2.0",
"@types/react-timeago": "^4.1.3", "@types/react-timeago": "^4.1.3",
"@types/react-window": "^1.8.5", "@types/react-window": "^1.8.5",
"autoprefixer": "^10.4.12", "autoprefixer": "^10.4.12",
"eslint": "8.25.0", "eslint": "8.25.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-next": "12.3.1", "eslint-config-next": "12.3.1",
"jest": "^29.3.1", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.3.1", "jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.18", "jest-fetch-mock": "^3.0.3",
"msw": "^2.6.4",
"next-router-mock": "^0.9.10",
"postcss": "^8.4.49",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"sass": "^1.55.0", "sass": "^1.80.7",
"sequelize-cli": "^6.6.2",
"standard-version": "^9.5.0", "standard-version": "^9.5.0",
"stylelint-config-standard": "^29.0.0", "stylelint-config-standard": "^29.0.0",
"tailwindcss": "^3.1.8", "tailwindcss": "^3.4.14",
"typescript": "4.8.4" "typescript": "^4.8.4"
} }
} }

101
pages/api/adwords.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { OAuth2Client } from 'google-auth-library';
import { readFile, writeFile } from 'fs/promises';
import Cryptr from 'cryptr';
import db from '../../database/database';
import verifyUser from '../../utils/verifyUser';
import { getAdwordsCredentials, getAdwordsKeywordIdeas } from '../../utils/adwords';
type adwordsValidateResp = {
valid: boolean
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await db.sync();
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'GET') {
return getAdwordsRefreshToken(req, res);
}
if (req.method === 'POST') {
return validateAdwordsIntegration(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const getAdwordsRefreshToken = async (req: NextApiRequest, res: NextApiResponse<string>) => {
try {
const code = (req.query.code as string);
const https = req.headers.host?.includes('localhost:') ? 'http://' : 'https://';
const redirectURL = `${https}${req.headers.host}/api/adwords`;
if (code) {
try {
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
const cryptr = new Cryptr(process.env.SECRET as string);
const adwords_client_id = settings.adwords_client_id ? cryptr.decrypt(settings.adwords_client_id) : '';
const adwords_client_secret = settings.adwords_client_secret ? cryptr.decrypt(settings.adwords_client_secret) : '';
const oAuth2Client = new OAuth2Client(adwords_client_id, adwords_client_secret, redirectURL);
const r = await oAuth2Client.getToken(code);
if (r?.tokens?.refresh_token) {
const adwords_refresh_token = cryptr.encrypt(r.tokens.refresh_token);
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify({ ...settings, adwords_refresh_token }), { encoding: 'utf-8' });
return res.status(200).send('Google Ads Intergrated Successfully! You can close this window.');
}
return res.status(400).send('Error Getting the Google Ads Refresh Token. Please Try Again!');
} catch (error:any) {
let errorMsg = error?.response?.data?.error;
if (errorMsg.includes('redirect_uri_mismatch')) {
errorMsg += ` Redirected URL: ${redirectURL}`;
}
console.log('[Error] Getting Google Ads Refresh Token! Reason: ', errorMsg);
return res.status(400).send(`Error Saving the Google Ads Refresh Token ${errorMsg ? `. Details: ${errorMsg}` : ''}. Please Try Again!`);
}
} else {
return res.status(400).send('No Code Provided By Google. Please Try Again!');
}
} catch (error) {
console.log('[ERROR] Getting Google Ads Refresh Token: ', error);
return res.status(400).send('Error Getting Google Ads Refresh Token. Please Try Again!');
}
};
const validateAdwordsIntegration = async (req: NextApiRequest, res: NextApiResponse<adwordsValidateResp>) => {
const errMsg = 'Error Validating Google Ads Integration. Please make sure your provided data are correct!';
const { developer_token, account_id } = req.body;
if (!developer_token || !account_id) {
return res.status(400).json({ valid: false, error: 'Please Provide the Google Ads Developer Token and Test Account ID' });
}
try {
// Save the Adwords Developer Token & Google Ads Test Account ID in App Settings
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
const cryptr = new Cryptr(process.env.SECRET as string);
const adwords_developer_token = cryptr.encrypt(developer_token.trim());
const adwords_account_id = cryptr.encrypt(account_id.trim());
const securedSettings = { ...settings, adwords_developer_token, adwords_account_id };
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(securedSettings), { encoding: 'utf-8' });
// Make a test Request to Google Ads
const adwordsCreds = await getAdwordsCredentials();
const { client_id, client_secret, refresh_token } = adwordsCreds || {};
if (adwordsCreds && client_id && client_secret && developer_token && account_id && refresh_token) {
const keywords = await getAdwordsKeywordIdeas(
adwordsCreds,
{ country: 'US', language: '1000', keywords: ['compress'], seedType: 'custom' },
true,
);
if (keywords && Array.isArray(keywords) && keywords.length > 0) {
return res.status(200).json({ valid: true });
}
}
return res.status(400).json({ valid: false, error: errMsg });
} catch (error) {
console.log('[ERROR] Validating Google Ads Integration: ', error);
return res.status(400).json({ valid: false, error: errMsg });
}
};

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 Keyword from '../../database/models/keyword';
import { getAppSettings } from './settings'; import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser'; import verifyUser from '../../utils/verifyUser';
import { refreshAndUpdateKeywords } from './refresh'; import refreshAndUpdateKeywords from '../../utils/refresh';
type CRONRefreshRes = { type CRONRefreshRes = {
started: boolean started: boolean

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,9 +1,11 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr';
import db from '../../database/database'; import db from '../../database/database';
import Domain from '../../database/models/domain'; import Domain from '../../database/models/domain';
import Keyword from '../../database/models/keyword'; import Keyword from '../../database/models/keyword';
import getdomainStats from '../../utils/domains'; import getdomainStats from '../../utils/domains';
import verifyUser from '../../utils/verifyUser'; import verifyUser from '../../utils/verifyUser';
import { checkSerchConsoleIntegration, removeLocalSCData } from '../../utils/searchConsole';
type DomainsGetRes = { type DomainsGetRes = {
domains: DomainType[] domains: DomainType[]
@@ -11,13 +13,14 @@ type DomainsGetRes = {
} }
type DomainsAddResponse = { type DomainsAddResponse = {
domain: Domain|null, domains: DomainType[]|null,
error?: string|null, error?: string|null,
} }
type DomainsDeleteRes = { type DomainsDeleteRes = {
domainRemoved: number, domainRemoved: number,
keywordsRemoved: number, keywordsRemoved: number,
SCDataRemoved: boolean,
error?: string|null, error?: string|null,
} }
@@ -51,7 +54,13 @@ export const getDomains = async (req: NextApiRequest, res: NextApiResponse<Domai
const withStats = !!req?.query?.withstats; const withStats = !!req?.query?.withstats;
try { try {
const allDomains: Domain[] = await Domain.findAll(); const allDomains: Domain[] = await Domain.findAll();
const formattedDomains: DomainType[] = allDomains.map((el) => el.get({ plain: true })); 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; const theDomains: DomainType[] = withStats ? await getdomainStats(formattedDomains) : allDomains;
return res.status(200).json({ domains: theDomains }); return res.status(200).json({ domains: theDomains });
} catch (error) { } catch (error) {
@@ -59,41 +68,45 @@ export const getDomains = async (req: NextApiRequest, res: NextApiResponse<Domai
} }
}; };
export const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => { const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => {
if (!req.body.domain) { const { domains } = req.body;
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' }); if (domains && Array.isArray(domains) && domains.length > 0) {
} const domainsToAdd: any = [];
const { domain } = req.body || {};
const domainData = {
domain: domain.trim(),
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-'),
lastUpdated: new Date().toJSON(),
added: new Date().toJSON(),
};
try { domains.forEach((domain: string) => {
const addedDomain = await Domain.create(domainData); domainsToAdd.push({
return res.status(201).json({ domain: addedDomain }); domain: domain.trim(),
} catch (error) { slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-').replaceAll('/', '-'),
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' }); 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>) => { export const deleteDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsDeleteRes>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') { 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 { try {
const { domain } = req.query || {}; const { domain } = req.query || {};
const removedDomCount: number = await Domain.destroy({ where: { domain } }); const removedDomCount: number = await Domain.destroy({ where: { domain } });
const removedKeywordCount: number = await Keyword.destroy({ where: { domain } }); const removedKeywordCount: number = await Keyword.destroy({ where: { domain } });
return res.status(200).json({ const SCDataRemoved = await removeLocalSCData(domain as string);
domainRemoved: removedDomCount, return res.status(200).json({ domainRemoved: removedDomCount, keywordsRemoved: removedKeywordCount, SCDataRemoved });
keywordsRemoved: removedKeywordCount,
});
} catch (error) { } catch (error) {
console.log('[ERROR] Deleting Domain: ', req.query.domain, error); console.log('[ERROR] Deleting Domain: ', req.query.domain, error);
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Error Deleting Domain' }); return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Error Deleting Domain' });
} }
}; };
@@ -102,17 +115,28 @@ export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
return res.status(400).json({ domain: null, error: 'Domain is Required!' }); return res.status(400).json({ domain: null, error: 'Domain is Required!' });
} }
const { domain } = req.query || {}; const { domain } = req.query || {};
const { notification_interval, notification_emails } = req.body; const { notification_interval, notification_emails, search_console } = req.body as DomainSettings;
try { try {
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } }); 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) { if (domainToUpdate) {
domainToUpdate.set({ notification_interval, notification_emails }); domainToUpdate.set({ notification_interval, notification_emails, search_console: JSON.stringify(search_console) });
await domainToUpdate.save(); await domainToUpdate.save();
} }
return res.status(200).json({ domain: domainToUpdate }); return res.status(200).json({ domain: domainToUpdate });
} catch (error) { } catch (error) {
console.log('[ERROR] Updating Domain: ', req.query.domain, error); console.log('[ERROR] Updating Domain: ', req.query.domain, error);
return res.status(400).json({ domain: null, error: 'Error Updating Domain' }); return res.status(400).json({ domain: null, error: 'Error Updating Domain. An Unknown Error Occured.' });
} }
}; };

111
pages/api/ideas.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import verifyUser from '../../utils/verifyUser';
import {
KeywordIdeasDatabase, getAdwordsCredentials, getAdwordsKeywordIdeas, getLocalKeywordIdeas, updateLocalKeywordIdeas,
} from '../../utils/adwords';
type keywordsIdeasUpdateResp = {
keywords: IdeaKeyword[],
error?: string|null,
}
type keywordsIdeasGetResp = {
data: KeywordIdeasDatabase|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 getKeywordIdeas(req, res);
}
if (req.method === 'POST') {
return updateKeywordIdeas(req, res);
}
if (req.method === 'PUT') {
return favoriteKeywords(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const getKeywordIdeas = async (req: NextApiRequest, res: NextApiResponse<keywordsIdeasGetResp>) => {
try {
const domain = req.query.domain as string;
if (domain) {
const keywordsDatabase = await getLocalKeywordIdeas(domain);
// console.log('keywords :', keywordsDatabase);
if (keywordsDatabase) {
return res.status(200).json({ data: keywordsDatabase });
}
}
return res.status(400).json({ data: null, error: 'Error Loading Keyword Ideas.' });
} catch (error) {
console.log('[ERROR] Fetching Keyword Ideas: ', error);
return res.status(400).json({ data: null, error: 'Error Loading Keyword Ideas.' });
}
};
const updateKeywordIdeas = async (req: NextApiRequest, res: NextApiResponse<keywordsIdeasUpdateResp>) => {
const errMsg = 'Error Fetching Keywords. Please try again!';
const { keywords = [], country = 'US', language = '1000', domain = '', seedSCKeywords, seedCurrentKeywords, seedType } = req.body;
if (!country || !language) {
return res.status(400).json({ keywords: [], error: 'Error Fetching Keywords. Please Provide a Country and Language' });
}
if (seedType === 'custom' && (keywords.length === 0 && !seedSCKeywords && !seedCurrentKeywords)) {
return res.status(400).json({ keywords: [], error: 'Error Fetching Keywords. Please Provide one of these: keywords, url or domain' });
}
try {
const adwordsCreds = await getAdwordsCredentials();
const { client_id, client_secret, developer_token, account_id, refresh_token } = adwordsCreds || {};
if (adwordsCreds && client_id && client_secret && developer_token && account_id && refresh_token) {
const ideaOptions = { country, language, keywords, domain, seedSCKeywords, seedCurrentKeywords, seedType };
const keywordIdeas = await getAdwordsKeywordIdeas(adwordsCreds, ideaOptions);
if (keywordIdeas && Array.isArray(keywordIdeas) && keywordIdeas.length > 1) {
return res.status(200).json({ keywords: keywordIdeas });
}
}
return res.status(400).json({ keywords: [], error: errMsg });
} catch (error) {
console.log('[ERROR] Fetching Keyword Ideas: ', error);
return res.status(400).json({ keywords: [], error: errMsg });
}
};
const favoriteKeywords = async (req: NextApiRequest, res: NextApiResponse<keywordsIdeasUpdateResp>) => {
const errMsg = 'Error Favorating Keyword Idea. Please try again!';
const { keywordID = '', domain = '' } = req.body;
if (!keywordID || !domain) {
return res.status(400).json({ keywords: [], error: 'Missing Necessary data. Please provide both keywordID and domain values.' });
}
try {
const keywordsDatabase = await getLocalKeywordIdeas(domain);
if (keywordsDatabase && keywordsDatabase.keywords) {
const theKeyword = keywordsDatabase.keywords.find((kw) => kw.uid === keywordID);
const existingKeywords = keywordsDatabase.favorites || [];
const newFavorites = [...existingKeywords];
const existingKeywordIndex = newFavorites.findIndex((kw) => kw.uid === keywordID);
if (existingKeywordIndex > -1) {
newFavorites.splice(existingKeywordIndex, 1);
} else if (theKeyword) newFavorites.push(theKeyword);
const updated = await updateLocalKeywordIdeas(domain, { favorites: newFavorites });
if (updated) {
return res.status(200).json({ keywords: newFavorites, error: '' });
}
}
return res.status(400).json({ keywords: [], error: errMsg });
} catch (error) {
console.log('[ERROR] Favorating Keyword Idea: ', error);
return res.status(400).json({ keywords: [], error: errMsg });
}
};

View File

@@ -1,8 +1,9 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database'; import db from '../../database/database';
import { getCountryInsight, getKeywordsInsight, getPagesInsight } from '../../utils/insight'; import { getCountryInsight, getKeywordsInsight, getPagesInsight } from '../../utils/insight';
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole'; import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
import verifyUser from '../../utils/verifyUser'; import verifyUser from '../../utils/verifyUser';
import Domain from '../../database/models/domain';
type SCInsightRes = { type SCInsightRes = {
data: InsightDataType | null, data: InsightDataType | null,
@@ -23,9 +24,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiResponse<SCInsightRes>) => { 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.' }); if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
if (!!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) === false) {
return res.status(200).json({ data: null, error: 'Google Search Console Not Integrated' });
}
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-'); const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
const getInsightFromSCData = (localSCData: SCDomainDataType): InsightDataType => { const getInsightFromSCData = (localSCData: SCDomainDataType): InsightDataType => {
const { stats = [] } = localSCData; const { stats = [] } = localSCData;
@@ -37,14 +35,26 @@ const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiRe
// First try and read the Local SC Domain Data file. // First try and read the Local SC Domain Data file.
const localSCData = await readLocalSCData(domainname); const localSCData = await readLocalSCData(domainname);
if (localSCData && localSCData.stats && localSCData.stats.length) {
const response = getInsightFromSCData(localSCData); if (localSCData) {
return res.status(200).json({ data: response }); 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. // If the Local SC Domain Data file does not exist, fetch from Googel Search Console.
try { try {
const scData = await fetchDomainSCData(domainname); 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); const response = getInsightFromSCData(scData);
return res.status(200).json({ data: response }); return res.status(200).json({ data: response });
} catch (error) { } catch (error) {

View File

@@ -2,11 +2,12 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import db from '../../database/database'; import db from '../../database/database';
import Keyword from '../../database/models/keyword'; import Keyword from '../../database/models/keyword';
import { refreshAndUpdateKeywords } from './refresh';
import { getAppSettings } from './settings'; import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser'; import verifyUser from '../../utils/verifyUser';
import parseKeywords from '../../utils/parseKeywords'; import parseKeywords from '../../utils/parseKeywords';
import { integrateKeywordSCData, readLocalSCData } from '../../utils/searchConsole'; import { integrateKeywordSCData, readLocalSCData } from '../../utils/searchConsole';
import refreshAndUpdateKeywords from '../../utils/refresh';
import { getKeywordsVolume, updateKeywordsVolumeData } from '../../utils/adwords';
type KeywordsGetResponse = { type KeywordsGetResponse = {
keywords?: KeywordType[], keywords?: KeywordType[],
@@ -45,9 +46,11 @@ const getKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
if (!req.query.domain && typeof req.query.domain !== 'string') { if (!req.query.domain && typeof req.query.domain !== 'string') {
return res.status(400).json({ error: 'Domain is Required!' }); return res.status(400).json({ error: 'Domain is Required!' });
} }
const domain = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-'); const settings = await getAppSettings();
const domain = (req.query.domain as string);
const integratedSC = process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL; const integratedSC = process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL;
const domainSCData = integratedSC ? await readLocalSCData(domain) : false; const { search_console_client_email, search_console_private_key } = settings;
const domainSCData = integratedSC || (search_console_client_email && search_console_private_key) ? await readLocalSCData(domain) : false;
try { try {
const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain } }); const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain } });
@@ -79,13 +82,14 @@ const addKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
const keywordsToAdd: any = []; // QuickFIX for bug: https://github.com/sequelize/sequelize-typescript/issues/936 const keywordsToAdd: any = []; // QuickFIX for bug: https://github.com/sequelize/sequelize-typescript/issues/936
keywords.forEach((kwrd: KeywordAddPayload) => { keywords.forEach((kwrd: KeywordAddPayload) => {
const { keyword, device, country, domain, tags } = kwrd; const { keyword, device, country, domain, tags, city } = kwrd;
const tagsArray = tags ? tags.split(',').map((item:string) => item.trim()) : []; const tagsArray = tags ? tags.split(',').map((item:string) => item.trim()) : [];
const newKeyword = { const newKeyword = {
keyword, keyword,
device, device,
domain, domain,
country, country,
city,
position: 0, position: 0,
updating: true, updating: true,
history: JSON.stringify({}), history: JSON.stringify({}),
@@ -102,8 +106,20 @@ const addKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
const newKeywords:Keyword[] = await Keyword.bulkCreate(keywordsToAdd); const newKeywords:Keyword[] = await Keyword.bulkCreate(keywordsToAdd);
const formattedkeywords = newKeywords.map((el) => el.get({ plain: true })); const formattedkeywords = newKeywords.map((el) => el.get({ plain: true }));
const keywordsParsed: KeywordType[] = parseKeywords(formattedkeywords); const keywordsParsed: KeywordType[] = parseKeywords(formattedkeywords);
// Queue the SERP Scraping Process
const settings = await getAppSettings(); const settings = await getAppSettings();
refreshAndUpdateKeywords(newKeywords, settings); // Queue the SERP Scraping Process refreshAndUpdateKeywords(newKeywords, settings);
// Update the Keyword Volume
const { adwords_account_id, adwords_client_id, adwords_client_secret, adwords_developer_token } = settings;
if (adwords_account_id && adwords_client_id && adwords_client_secret && adwords_developer_token) {
const keywordsVolumeData = await getKeywordsVolume(keywordsParsed);
if (keywordsVolumeData.volumes !== false) {
await updateKeywordsVolumeData(keywordsVolumeData.volumes);
}
}
return res.status(201).json({ keywords: keywordsParsed }); return res.status(201).json({ keywords: keywordsParsed });
} catch (error) { } catch (error) {
console.log('[ERROR] Adding New Keywords ', error); console.log('[ERROR] Adding New Keywords ', error);

View File

@@ -62,9 +62,10 @@ const sendNotificationEmail = async (domain: Domain, settings: SettingsType) =>
smtp_password = '', smtp_password = '',
notification_email = '', notification_email = '',
notification_email_from = '', notification_email_from = '',
notification_email_from_name = 'SerpBear',
} = settings; } = settings;
const fromEmail = `SerpBear <${notification_email_from || 'no-reply@serpbear.com'}>`; const fromEmail = `${notification_email_from_name} <${notification_email_from || 'no-reply@serpbear.com'}>`;
const mailerSettings:any = { host: smtp_server, port: parseInt(smtp_port, 10) }; const mailerSettings:any = { host: smtp_server, port: parseInt(smtp_port, 10) };
if (smtp_username || smtp_password) { if (smtp_username || smtp_password) {
mailerSettings.auth = {}; mailerSettings.auth = {};
@@ -77,7 +78,7 @@ const sendNotificationEmail = async (domain: Domain, settings: SettingsType) =>
const domainKeywords:Keyword[] = await Keyword.findAll(query); const domainKeywords:Keyword[] = await Keyword.findAll(query);
const keywordsArray = domainKeywords.map((el) => el.get({ plain: true })); const keywordsArray = domainKeywords.map((el) => el.get({ plain: true }));
const keywords: KeywordType[] = parseKeywords(keywordsArray); const keywords: KeywordType[] = parseKeywords(keywordsArray);
const emailHTML = await generateEmail(domainName, keywords); const emailHTML = await generateEmail(domainName, keywords, settings);
await transporter.sendMail({ await transporter.sendMail({
from: fromEmail, from: fromEmail,
to: domain.notification_emails || notification_email, to: domain.notification_emails || notification_email,

View File

@@ -2,23 +2,36 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import db from '../../database/database'; import db from '../../database/database';
import Keyword from '../../database/models/keyword'; import Keyword from '../../database/models/keyword';
import refreshKeywords from '../../utils/refresh'; import refreshAndUpdateKeywords from '../../utils/refresh';
import { getAppSettings } from './settings'; import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser'; import verifyUser from '../../utils/verifyUser';
import parseKeywords from '../../utils/parseKeywords'; import parseKeywords from '../../utils/parseKeywords';
import { removeFromRetryQueue, retryScrape } from '../../utils/scraper'; import { scrapeKeywordFromGoogle } from '../../utils/scraper';
type KeywordsRefreshRes = { type KeywordsRefreshRes = {
keywords?: KeywordType[] keywords?: KeywordType[]
error?: string|null, error?: string|null,
} }
type KeywordSearchResultRes = {
searchResult?: {
results: { title: string, url: string, position: number }[],
keyword: string,
position: number,
country: string,
},
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await db.sync(); await db.sync();
const authorized = verifyUser(req, res); const authorized = verifyUser(req, res);
if (authorized !== 'authorized') { if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized }); return res.status(401).json({ error: authorized });
} }
if (req.method === 'GET') {
return getKeywordSearchResults(req, res);
}
if (req.method === 'POST') { if (req.method === 'POST') {
return refresTheKeywords(req, res); return refresTheKeywords(req, res);
} }
@@ -64,56 +77,46 @@ const refresTheKeywords = async (req: NextApiRequest, res: NextApiResponse<Keywo
} }
}; };
export const refreshAndUpdateKeywords = async (initKeywords:Keyword[], settings:SettingsType) => { const getKeywordSearchResults = async (req: NextApiRequest, res: NextApiResponse<KeywordSearchResultRes>) => {
const formattedKeywords = initKeywords.map((el) => el.get({ plain: true })); if (!req.query.keyword || !req.query.country || !req.query.device) {
const refreshed: any = await refreshKeywords(formattedKeywords, settings); return res.status(400).json({ error: 'A Valid keyword, Country Code, and device is Required!' });
// const fetchKeywords = await refreshKeywords(initialKeywords.map( k=> k.keyword )); }
const updatedKeywords: KeywordType[] = []; try {
const settings = await getAppSettings();
for (const keywordRaw of initKeywords) { if (!settings || (settings && settings.scraper_type === 'never')) {
const keywordPrased = parseKeywords([keywordRaw.get({ plain: true })]); return res.status(400).json({ error: 'Scraper has not been set up yet.' });
const keyword = keywordPrased[0]; }
const udpatedkeyword = refreshed.find((item:any) => item.ID && item.ID === keyword.ID); const dummyKeyword:KeywordType = {
ID: 99999999999999,
if (udpatedkeyword && keyword) { keyword: req.query.keyword as string,
const newPos = udpatedkeyword.position; device: 'desktop',
const newPosition = newPos !== false ? newPos : keyword.position; country: req.query.country as string,
const { history } = keyword; domain: '',
const theDate = new Date(); lastUpdated: '',
history[`${theDate.getFullYear()}-${theDate.getMonth() + 1}-${theDate.getDate()}`] = newPosition; volume: 0,
added: '',
const updatedVal = { position: 111,
position: newPosition, sticky: false,
updating: false, history: {},
url: udpatedkeyword.url, lastResult: [],
lastResult: udpatedkeyword.result, url: '',
history, tags: [],
lastUpdated: udpatedkeyword.error ? keyword.lastUpdated : theDate.toJSON(), updating: false,
lastUpdateError: udpatedkeyword.error lastUpdateError: false,
? JSON.stringify({ date: theDate.toJSON(), error: `${udpatedkeyword.error}`, scraper: settings.scraper_type }) };
: 'false', const scrapeResult = await scrapeKeywordFromGoogle(dummyKeyword, settings);
}; if (scrapeResult && !scrapeResult.error) {
updatedKeywords.push({ ...keyword, ...{ ...updatedVal, lastUpdateError: JSON.parse(updatedVal.lastUpdateError) } }); const searchResult = {
results: scrapeResult.result,
// If failed, Add to Retry Queue Cron keyword: scrapeResult.keyword,
if (udpatedkeyword.error) { position: scrapeResult.position !== 111 ? scrapeResult.position : 0,
await retryScrape(keyword.ID); country: req.query.country as string,
} else { };
await removeFromRetryQueue(keyword.ID); return res.status(200).json({ error: '', searchResult });
} }
return res.status(400).json({ error: 'Error Scraping Search Results for the given keyword!' });
// Update the Keyword Position in Database } catch (error) {
try { console.log('ERROR refresThehKeywords: ', error);
await keywordRaw.update({ return res.status(400).json({ error: 'Error refreshing keywords!' });
...updatedVal,
lastResult: Array.isArray(udpatedkeyword.result) ? JSON.stringify(udpatedkeyword.result) : 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

@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database'; import db from '../../database/database';
import Domain from '../../database/models/domain'; import Domain from '../../database/models/domain';
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole'; import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
import verifyUser from '../../utils/verifyUser'; import verifyUser from '../../utils/verifyUser';
type searchConsoleRes = { type searchConsoleRes = {
@@ -31,18 +31,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const getDomainSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleRes>) => { 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.' }); if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
if (!!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) === false) {
return res.status(200).json({ data: null, error: 'Google Search Console Not Integrated' });
}
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-'); const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
const localSCData = await readLocalSCData(domainname); const localSCData = await readLocalSCData(domainname);
console.log(localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length);
if (localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length) { if (localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length) {
return res.status(200).json({ data: localSCData }); return res.status(200).json({ data: localSCData });
} }
try { try {
const scData = await fetchDomainSCData(domainname); 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 }); return res.status(200).json({ data: scData });
} catch (error) { } catch (error) {
console.log('[ERROR] Getting Search Console Data for: ', domainname, error); console.log('[ERROR] Getting Search Console Data for: ', domainname, error);
@@ -53,9 +55,9 @@ const getDomainSearchConsoleData = async (req: NextApiRequest, res: NextApiRespo
const cronRefreshSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleCRONRes>) => { const cronRefreshSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleCRONRes>) => {
try { try {
const allDomainsRaw = await Domain.findAll(); const allDomainsRaw = await Domain.findAll();
const Domains: Domain[] = allDomainsRaw.map((el) => el.get({ plain: true })); const Domains: DomainType[] = allDomainsRaw.map((el) => el.get({ plain: true }));
for (const domain of Domains) { for (const domain of Domains) {
await fetchDomainSCData(domain.domain); await fetchDomainSCData(domain);
} }
return res.status(200).json({ status: 'completed' }); return res.status(200).json({ status: 'completed' });
} catch (error) { } catch (error) {

View File

@@ -1,7 +1,7 @@
import { writeFile, readFile } from 'fs/promises';
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr'; import Cryptr from 'cryptr';
import getConfig from 'next/config'; import getConfig from 'next/config';
import { writeFile, readFile } from 'fs/promises';
import verifyUser from '../../utils/verifyUser'; import verifyUser from '../../utils/verifyUser';
import allScrapers from '../../scrapers/index'; import allScrapers from '../../scrapers/index';
@@ -42,9 +42,26 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
} }
try { try {
const cryptr = new Cryptr(process.env.SECRET as string); const cryptr = new Cryptr(process.env.SECRET as string);
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api) : ''; const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api.trim()) : '';
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password) : ''; const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password.trim()) : '';
const securedSettings = { ...settings, scaping_api, smtp_password }; 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 adwords_client_id = settings.adwords_client_id ? cryptr.encrypt(settings.adwords_client_id.trim()) : '';
const adwords_client_secret = settings.adwords_client_secret ? cryptr.encrypt(settings.adwords_client_secret.trim()) : '';
const adwords_developer_token = settings.adwords_developer_token ? cryptr.encrypt(settings.adwords_developer_token.trim()) : '';
const adwords_account_id = settings.adwords_account_id ? cryptr.encrypt(settings.adwords_account_id.trim()) : '';
const securedSettings = {
...settings,
scaping_api,
smtp_password,
search_console_client_email,
search_console_private_key,
adwords_client_id,
adwords_client_secret,
adwords_developer_token,
adwords_account_id,
};
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(securedSettings), { encoding: 'utf-8' }); await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(securedSettings), { encoding: 'utf-8' });
return res.status(200).json({ settings }); return res.status(200).json({ settings });
@@ -55,8 +72,11 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
}; };
export const getAppSettings = async () : Promise<SettingsType> => { export const getAppSettings = async () : Promise<SettingsType> => {
const screenshotAPIKey = process.env.SCREENSHOT_API || '69408-serpbear';
try { try {
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' }); 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) : {}; const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
let decryptedSettings = settings; let decryptedSettings = settings;
@@ -64,12 +84,28 @@ export const getAppSettings = async () : Promise<SettingsType> => {
const cryptr = new Cryptr(process.env.SECRET as string); const cryptr = new Cryptr(process.env.SECRET as string);
const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : ''; const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : '';
const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : ''; const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.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) : '';
const adwords_client_id = settings.adwords_client_id ? cryptr.decrypt(settings.adwords_client_id) : '';
const adwords_client_secret = settings.adwords_client_secret ? cryptr.decrypt(settings.adwords_client_secret) : '';
const adwords_developer_token = settings.adwords_developer_token ? cryptr.decrypt(settings.adwords_developer_token) : '';
const adwords_account_id = settings.adwords_account_id ? cryptr.decrypt(settings.adwords_account_id) : '';
decryptedSettings = { decryptedSettings = {
...settings, ...settings,
scaping_api, scaping_api,
smtp_password, smtp_password,
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL), search_console_client_email,
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })), 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,
adwords_client_id,
adwords_client_secret,
adwords_developer_token,
adwords_account_id,
}; };
} catch (error) { } catch (error) {
console.log('Error Decrypting Settings API Keys!'); console.log('Error Decrypting Settings API Keys!');
@@ -78,17 +114,29 @@ export const getAppSettings = async () : Promise<SettingsType> => {
return decryptedSettings; return decryptedSettings;
} catch (error) { } catch (error) {
console.log('[ERROR] Getting App Settings. ', error); console.log('[ERROR] Getting App Settings. ', error);
const settings = { const settings: SettingsType = {
scraper_type: 'none', scraper_type: 'none',
notification_interval: 'never', notification_interval: 'never',
notification_email: '', notification_email: '',
notification_email_from: '', notification_email_from: '',
notification_email_from_name: 'SerpBear',
smtp_server: '', smtp_server: '',
smtp_port: '', smtp_port: '',
smtp_username: '', smtp_username: '',
smtp_password: '', smtp_password: '',
scrape_retry: false,
screenshot_key: screenshotAPIKey,
search_console: true,
search_console_client_email: '',
search_console_private_key: '',
keywordsColumns: ['Best', 'History', 'Volume', 'Search Console'],
};
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' }); 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 };
} }
}; };

66
pages/api/volume.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Op } from 'sequelize';
import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import verifyUser from '../../utils/verifyUser';
import parseKeywords from '../../utils/parseKeywords';
import { getKeywordsVolume, updateKeywordsVolumeData } from '../../utils/adwords';
type KeywordsRefreshRes = {
keywords?: KeywordType[]
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await db.sync();
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'POST') {
return updatekeywordVolume(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const updatekeywordVolume = async (req: NextApiRequest, res: NextApiResponse<KeywordsRefreshRes>) => {
const { keywords = [], domain = '', update = true } = req.body || {};
if (keywords.length === 0 && !domain) {
return res.status(400).json({ error: 'Please provide keyword Ids or a domain name.' });
}
try {
let keywordsToSend: KeywordType[] = [];
if (keywords.length > 0) {
const allKeywords:Keyword[] = await Keyword.findAll({ where: { ID: { [Op.in]: keywords } } });
keywordsToSend = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
}
if (domain) {
const allDomain = domain === 'all';
const allKeywords:Keyword[] = allDomain ? await Keyword.findAll() : await Keyword.findAll(allDomain ? {} : { where: { domain } });
keywordsToSend = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
}
if (keywordsToSend.length > 0) {
const keywordsVolumeData = await getKeywordsVolume(keywordsToSend);
if (keywordsVolumeData.error) {
return res.status(400).json({ keywords: [], error: keywordsVolumeData.error });
}
if (keywordsVolumeData.volumes !== false) {
if (update) {
const updated = await updateKeywordsVolumeData(keywordsVolumeData.volumes);
if (updated) {
return res.status(200).json({ keywords });
}
}
} else {
return res.status(400).json({ error: 'Error Fetching Keywords Volume Data from Google Ads' });
}
}
return res.status(400).json({ keywords: [], error: 'Error Updating Keywords Volume data' });
} catch (error) {
console.log('[Error] updating keywords Volume Data: ', error);
return res.status(400).json({ error: 'Error Updating Keywords Volume data' });
}
};

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 type { NextPage } from 'next';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
// import { useQuery } from 'react-query';
// import toast from 'react-hot-toast';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import Sidebar from '../../../components/common/Sidebar'; import Sidebar from '../../../components/common/Sidebar';
import TopBar from '../../../components/common/TopBar'; import TopBar from '../../../components/common/TopBar';
@@ -11,47 +9,47 @@ import DomainHeader from '../../../components/domains/DomainHeader';
import KeywordsTable from '../../../components/keywords/KeywordsTable'; import KeywordsTable from '../../../components/keywords/KeywordsTable';
import AddDomain from '../../../components/domains/AddDomain'; import AddDomain from '../../../components/domains/AddDomain';
import DomainSettings from '../../../components/domains/DomainSettings'; import DomainSettings from '../../../components/domains/DomainSettings';
import exportCSV from '../../../utils/exportcsv'; import exportCSV from '../../../utils/client/exportcsv';
import Settings from '../../../components/settings/Settings'; import Settings from '../../../components/settings/Settings';
import { useFetchDomains } from '../../../services/domains'; import { useFetchDomains } from '../../../services/domains';
import { useFetchKeywords } from '../../../services/keywords'; import { useFetchKeywords } from '../../../services/keywords';
import { useFetchSettings } from '../../../services/settings'; import { useFetchSettings } from '../../../services/settings';
import AddKeywords from '../../../components/keywords/AddKeywords';
import Footer from '../../../components/common/Footer';
const SingleDomain: NextPage = () => { const SingleDomain: NextPage = () => {
const router = useRouter(); const router = useRouter();
const [noScrapprtError, setNoScrapprtError] = useState(false);
const [showAddKeywords, setShowAddKeywords] = useState(false); const [showAddKeywords, setShowAddKeywords] = useState(false);
const [showAddDomain, setShowAddDomain] = useState(false); const [showAddDomain, setShowAddDomain] = useState(false);
const [showDomainSettings, setShowDomainSettings] = useState(false); const [showDomainSettings, setShowDomainSettings] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [keywordSPollInterval, setKeywordSPollInterval] = useState<undefined|number>(undefined); const [keywordSPollInterval, setKeywordSPollInterval] = useState<undefined|number>(undefined);
const { data: appSettings } = useFetchSettings(); const { data: appSettingsData, isLoading: isAppSettingsLoading } = useFetchSettings();
const { data: domainsData } = useFetchDomains(router); const { data: domainsData } = useFetchDomains(router);
const { keywordsData, keywordsLoading } = useFetchKeywords(router, setKeywordSPollInterval, keywordSPollInterval); const appSettings: SettingsType = appSettingsData?.settings || {};
const { scraper_type = '', available_scapers = [] } = appSettings;
const theDomains: DomainType[] = (domainsData && domainsData.domains) || []; const activeScraper = useMemo(() => available_scapers.find((scraper) => scraper.value === scraper_type), [scraper_type, available_scapers]);
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
const activDomain: DomainType|null = useMemo(() => { const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null; let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) { if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug); active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
} }
return active; return active;
}, [router.query.slug, domainsData]); }, [router.query.slug, domainsData]);
useEffect(() => { const domainHasScAPI = useMemo(() => {
// console.log('appSettings.settings: ', appSettings && appSettings.settings); const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) { return !!(doaminSc?.client_email && doaminSc?.private_key);
setNoScrapprtError(true); }, [activDomain]);
}
}, [appSettings]);
// 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 ( return (
<div className="Domain "> <div className="Domain ">
{noScrapprtError && ( {((!scraper_type || (scraper_type === 'none')) && !isAppSettingsLoading) && (
<div className=' p-3 bg-red-600 text-white text-sm text-center'> <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. A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app.
</div> </div>
@@ -66,13 +64,14 @@ const SingleDomain: NextPage = () => {
<Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} /> <Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} />
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full"> <div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full">
{activDomain && activDomain.domain {activDomain && activDomain.domain
&& <DomainHeader ? <DomainHeader
domain={activDomain} domain={activDomain}
domains={theDomains} domains={theDomains}
showAddModal={setShowAddKeywords} showAddModal={setShowAddKeywords}
showSettingsModal={setShowDomainSettings} showSettingsModal={setShowDomainSettings}
exportCsv={() => exportCSV(theKeywords, activDomain.domain)} exportCsv={() => exportCSV(theKeywords, activDomain.domain)}
/> />
: <div className='w-full lg:h-[100px]'></div>
} }
<KeywordsTable <KeywordsTable
isLoading={keywordsLoading} isLoading={keywordsLoading}
@@ -80,13 +79,14 @@ const SingleDomain: NextPage = () => {
keywords={theKeywords} keywords={theKeywords}
showAddModal={showAddKeywords} showAddModal={showAddKeywords}
setShowAddModal={setShowAddKeywords} setShowAddModal={setShowAddKeywords}
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) } isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || domainHasScAPI }
settings={appSettings}
/> />
</div> </div>
</div> </div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter> <CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} /> <AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition> </CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter> <CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
@@ -98,6 +98,16 @@ const SingleDomain: NextPage = () => {
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter> <CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} /> <Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition> </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>
<Footer currentVersion={appSettings?.version ? appSettings.version : ''} />
</div> </div>
); );
}; };

View File

@@ -10,12 +10,13 @@ import TopBar from '../../../../components/common/TopBar';
import DomainHeader from '../../../../components/domains/DomainHeader'; import DomainHeader from '../../../../components/domains/DomainHeader';
import AddDomain from '../../../../components/domains/AddDomain'; import AddDomain from '../../../../components/domains/AddDomain';
import DomainSettings from '../../../../components/domains/DomainSettings'; import DomainSettings from '../../../../components/domains/DomainSettings';
import exportCSV from '../../../../utils/exportcsv'; import exportCSV from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings'; import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains'; import { useFetchDomains } from '../../../../services/domains';
import { useFetchSCKeywords } from '../../../../services/searchConsole'; import { useFetchSCKeywords } from '../../../../services/searchConsole';
import SCKeywordsTable from '../../../../components/keywords/SCKeywordsTable'; import SCKeywordsTable from '../../../../components/keywords/SCKeywordsTable';
import { useFetchSettings } from '../../../../services/settings'; import { useFetchSettings } from '../../../../services/settings';
import Footer from '../../../../components/common/Footer';
const DiscoverPage: NextPage = () => { const DiscoverPage: NextPage = () => {
const router = useRouter(); const router = useRouter();
@@ -29,16 +30,63 @@ const DiscoverPage: NextPage = () => {
const { data: keywordsData, isLoading: keywordsLoading, isFetching } = useFetchSCKeywords(router, !!(domainsData?.domains?.length) && scConnected); const { data: keywordsData, isLoading: keywordsLoading, isFetching } = useFetchSCKeywords(router, !!(domainsData?.domains?.length) && scConnected);
const theDomains: DomainType[] = (domainsData && domainsData.domains) || []; const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
const theKeywords: SearchAnalyticsItem[] = keywordsData?.data && keywordsData.data[scDateFilter] ? keywordsData.data[scDateFilter] : []; const theKeywords: SearchAnalyticsItem[] = useMemo(() => {
return keywordsData?.data && keywordsData.data[scDateFilter] ? keywordsData.data[scDateFilter] : [];
}, [keywordsData, scDateFilter]);
const theKeywordsCount = useMemo(() => {
return theKeywords.reduce<Map<string, number>>((r, o) => {
const key = `${o.device}-${o.country}-${o.keyword}`;
const item = r.get(key) || 0;
return r.set(key, item + 1);
}, new Map()) || [];
}, [theKeywords]);
const theKeywordsReduced : SearchAnalyticsItem[] = useMemo(() => {
return [...theKeywords.reduce<Map<string, SearchAnalyticsItem>>((r, o) => {
const key = `${o.device}-${o.country}-${o.keyword}`;
const item = r.get(key) || { ...o,
...{
clicks: 0,
impressions: 0,
ctr: 0,
position: 0,
},
};
item.clicks += o.clicks;
item.impressions += o.impressions;
item.ctr = o.ctr + item.ctr;
item.position = o.position + item.position;
return r.set(key, item);
}, new Map()).values()];
}, [theKeywords]);
const theKeywordsGrouped : SearchAnalyticsItem[] = useMemo(() => {
return [...theKeywordsReduced.map<SearchAnalyticsItem>((o: SearchAnalyticsItem) => {
const key = `${o.device}-${o.country}-${o.keyword}`;
const count = theKeywordsCount?.get(key) || 0;
return { ...o,
...{
ctr: Math.round((o.ctr / count) * 100) / 100,
position: Math.round(o.position / count),
},
};
})];
}, [theKeywordsReduced, theKeywordsCount]);
const activDomain: DomainType|null = useMemo(() => { const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null; let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) { if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug); active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
} }
return active; return active;
}, [router.query.slug, domainsData]); }, [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 ( return (
<div className="Domain "> <div className="Domain ">
{activDomain && activDomain.domain {activDomain && activDomain.domain
@@ -51,27 +99,28 @@ const DiscoverPage: NextPage = () => {
<Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} /> <Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} />
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full"> <div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full">
{activDomain && activDomain.domain {activDomain && activDomain.domain
&& <DomainHeader ? <DomainHeader
domain={activDomain} domain={activDomain}
domains={theDomains} domains={theDomains}
showAddModal={() => console.log('XXXXX')} showAddModal={() => console.log('XXXXX')}
showSettingsModal={setShowDomainSettings} showSettingsModal={setShowDomainSettings}
exportCsv={() => exportCSV(theKeywords, activDomain.domain, scDateFilter)} exportCsv={() => exportCSV(theKeywordsGrouped, activDomain.domain, scDateFilter)}
scFilter={scDateFilter} scFilter={scDateFilter}
setScFilter={(item:string) => setSCDateFilter(item)} setScFilter={(item:string) => setSCDateFilter(item)}
/> />
: <div className='w-full lg:h-[100px]'></div>
} }
<SCKeywordsTable <SCKeywordsTable
isLoading={keywordsLoading || isFetching} isLoading={keywordsLoading || isFetching}
domain={activDomain} domain={activDomain}
keywords={theKeywords} keywords={theKeywordsGrouped}
isConsoleIntegrated={scConnected} isConsoleIntegrated={scConnected || domainHasScAPI}
/> />
</div> </div>
</div> </div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter> <CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} /> <AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition> </CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter> <CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
@@ -83,6 +132,7 @@ const DiscoverPage: NextPage = () => {
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter> <CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} /> <Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition> </CSSTransition>
<Footer currentVersion={appSettings?.settings?.version ? appSettings.settings.version : ''} />
</div> </div>
); );
}; };

View File

@@ -0,0 +1,113 @@
import React, { 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 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 { exportKeywordIdeas } from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains';
import { useFetchSettings } from '../../../../services/settings';
import KeywordIdeasTable from '../../../../components/ideas/KeywordIdeasTable';
import { useFetchKeywordIdeas } from '../../../../services/adwords';
import KeywordIdeasUpdater from '../../../../components/ideas/KeywordIdeasUpdater';
import Modal from '../../../../components/common/Modal';
import Footer from '../../../../components/common/Footer';
const DiscoverPage: NextPage = () => {
const router = useRouter();
const [showDomainSettings, setShowDomainSettings] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showAddDomain, setShowAddDomain] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [showFavorites, setShowFavorites] = useState(false);
const { data: appSettings } = useFetchSettings();
const { data: domainsData } = useFetchDomains(router);
const adwordsConnected = !!(appSettings && appSettings?.settings?.adwords_refresh_token
&& appSettings?.settings?.adwords_developer_token, appSettings?.settings?.adwords_account_id);
const searchConsoleConnected = !!(appSettings && appSettings?.settings?.search_console_integrated);
const { data: keywordIdeasData, isLoading: isLoadingIdeas, isError: errorLoadingIdeas } = useFetchKeywordIdeas(router, adwordsConnected);
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
const keywordIdeas:IdeaKeyword[] = keywordIdeasData?.data?.keywords || [];
const favorites:IdeaKeyword[] = keywordIdeasData?.data?.favorites || [];
const keywordIdeasSettings = keywordIdeasData?.data?.settings || undefined;
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]);
return (
<div className="Domain ">
{activDomain && activDomain.domain
&& <Head>
<title>{`${activDomain.domain} - Keyword Ideas` } </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={() => exportKeywordIdeas(showFavorites ? favorites : keywordIdeas, activDomain.domain)}
showIdeaUpdateModal={() => setShowUpdateModal(true)}
/>
) : <div className='w-full lg:h-[100px]'></div>}
<KeywordIdeasTable
isLoading={isLoadingIdeas}
noIdeasDatabase={errorLoadingIdeas}
domain={activDomain}
keywords={keywordIdeas}
favorites={favorites}
isAdwordsIntegrated={adwordsConnected}
showFavorites={showFavorites}
setShowFavorites={setShowFavorites}
/>
</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>
{showUpdateModal && activDomain?.domain && (
<Modal closeModal={() => setShowUpdateModal(false) } title={'Load Keyword Ideas from Google Ads'} verticalCenter={true}>
<KeywordIdeasUpdater
domain={activDomain}
onUpdate={() => setShowUpdateModal(false)}
settings={keywordIdeasSettings}
searchConsoleConnected={searchConsoleConnected}
adwordsConnected={adwordsConnected}
/>
</Modal>
)}
<Footer currentVersion={appSettings?.settings?.version ? appSettings.settings.version : ''} />
</div>
);
};
export default DiscoverPage;

View File

@@ -10,12 +10,13 @@ import TopBar from '../../../../components/common/TopBar';
import DomainHeader from '../../../../components/domains/DomainHeader'; import DomainHeader from '../../../../components/domains/DomainHeader';
import AddDomain from '../../../../components/domains/AddDomain'; import AddDomain from '../../../../components/domains/AddDomain';
import DomainSettings from '../../../../components/domains/DomainSettings'; import DomainSettings from '../../../../components/domains/DomainSettings';
import exportCSV from '../../../../utils/exportcsv'; import exportCSV from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings'; import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains'; import { useFetchDomains } from '../../../../services/domains';
import { useFetchSCInsight } from '../../../../services/searchConsole'; import { useFetchSCInsight } from '../../../../services/searchConsole';
import SCInsight from '../../../../components/insight/Insight'; import SCInsight from '../../../../components/insight/Insight';
import { useFetchSettings } from '../../../../services/settings'; import { useFetchSettings } from '../../../../services/settings';
import Footer from '../../../../components/common/Footer';
const InsightPage: NextPage = () => { const InsightPage: NextPage = () => {
const router = useRouter(); const router = useRouter();
@@ -34,11 +35,16 @@ const InsightPage: NextPage = () => {
const activDomain: DomainType|null = useMemo(() => { const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null; let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) { if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug); active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
} }
return active; return active;
}, [router.query.slug, domainsData]); }, [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 ( return (
<div className="Domain "> <div className="Domain ">
{activDomain && activDomain.domain {activDomain && activDomain.domain
@@ -51,7 +57,7 @@ const InsightPage: NextPage = () => {
<Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} /> <Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} />
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full"> <div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full">
{activDomain && activDomain.domain {activDomain && activDomain.domain
&& <DomainHeader ? <DomainHeader
domain={activDomain} domain={activDomain}
domains={theDomains} domains={theDomains}
showAddModal={() => console.log('XXXXX')} showAddModal={() => console.log('XXXXX')}
@@ -60,18 +66,19 @@ const InsightPage: NextPage = () => {
scFilter={scDateFilter} scFilter={scDateFilter}
setScFilter={(item:string) => setSCDateFilter(item)} setScFilter={(item:string) => setSCDateFilter(item)}
/> />
: <div className='w-full lg:h-[100px]'></div>
} }
<SCInsight <SCInsight
isLoading={false} isLoading={false}
domain={activDomain} domain={activDomain}
insight={theInsight} insight={theInsight}
isConsoleIntegrated={scConnected} isConsoleIntegrated={scConnected || domainHasScAPI}
/> />
</div> </div>
</div> </div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter> <CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} /> <AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition> </CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter> <CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
@@ -83,6 +90,7 @@ const InsightPage: NextPage = () => {
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter> <CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} /> <Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition> </CSSTransition>
<Footer currentVersion={appSettings?.settings?.version ? appSettings.settings.version : ''} />
</div> </div>
); );
}; };

View File

@@ -1,69 +1,92 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import toast, { Toaster } from 'react-hot-toast';
import TopBar from '../../components/common/TopBar'; import TopBar from '../../components/common/TopBar';
import AddDomain from '../../components/domains/AddDomain'; import AddDomain from '../../components/domains/AddDomain';
import Settings from '../../components/settings/Settings'; import Settings from '../../components/settings/Settings';
import { useFetchSettings } from '../../services/settings'; import { useCheckMigrationStatus, useFetchSettings } from '../../services/settings';
import { useFetchDomains } from '../../services/domains'; import { fetchDomainScreenshot, useFetchDomains } from '../../services/domains';
import DomainItem from '../../components/domains/DomainItem'; import DomainItem from '../../components/domains/DomainItem';
import Icon from '../../components/common/Icon'; import Icon from '../../components/common/Icon';
import Footer from '../../components/common/Footer';
type thumbImages = { [domain:string] : string } type thumbImages = { [domain:string] : string }
const SingleDomain: NextPage = () => { const Domains: NextPage = () => {
const router = useRouter(); const router = useRouter();
const [noScrapprtError, setNoScrapprtError] = useState(false); // const [noScrapprtError, setNoScrapprtError] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showAddDomain, setShowAddDomain] = useState(false); const [showAddDomain, setShowAddDomain] = useState(false);
const [domainThumbs, setDomainThumbs] = useState<thumbImages>({}); const [domainThumbs, setDomainThumbs] = useState<thumbImages>({});
const { data: appSettings } = useFetchSettings(); const { data: appSettingsData, isLoading: isAppSettingsLoading } = useFetchSettings();
const { data: domainsData, isLoading } = useFetchDomains(router, true); const { data: domainsData, isLoading } = useFetchDomains(router, true);
const { data: migrationStatus } = useCheckMigrationStatus();
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(() => { useEffect(() => {
// console.log('Domains Data: ', domainsData); if (domainsData?.domains && domainsData.domains.length > 0 && appSettings.screenshot_key) {
if (domainsData?.domains && domainsData.domains.length > 0) {
const domainThumbsRaw = localStorage.getItem('domainThumbs');
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
domainsData.domains.forEach(async (domain:DomainType) => { domainsData.domains.forEach(async (domain:DomainType) => {
if (domain.domain) { if (domain.domain) {
if (!domThumbs[domain.domain]) { const domainThumb = await fetchDomainScreenshot(domain.domain, appSettings.screenshot_key || '');
const domainImageBlob = await fetch(`https://image.thum.io/get/auth/66909-serpbear/maxAge/96/width/200/https://${domain.domain}`).then((res) => res.blob()); if (domainThumb) {
if (domainImageBlob) { setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domainThumb }));
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = resolve;
reader.onerror = reject;
reader.readAsDataURL(domainImageBlob);
});
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain.domain]: imageBase }));
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: imageBase }));
}
} else {
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domThumbs[domain.domain] }));
} }
} }
}); });
} }
}, [domainsData]); }, [domainsData, appSettings.screenshot_key]);
useEffect(() => { const manuallyUpdateThumb = async (domain: string) => {
// console.log('appSettings.settings: ', appSettings && appSettings.settings); if (domain && appSettings.screenshot_key) {
if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) { const domainThumb = await fetchDomainScreenshot(domain, appSettings.screenshot_key, true);
setNoScrapprtError(true); if (domainThumb) {
toast(`${domain} Screenshot Updated Successfully!`, { icon: '✔️' });
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain]: domainThumb }));
} else {
toast(`Failed to Fetch ${domain} Screenshot!`, { icon: '⚠️' });
}
} }
}, [appSettings]); };
return ( return (
<div className="Domain flex flex-col min-h-screen"> <div data-testid="domains" className="Domain flex flex-col min-h-screen">
{noScrapprtError && ( {((!scraper_type || (scraper_type === 'none')) && !isAppSettingsLoading) && (
<div className=' p-3 bg-red-600 text-white text-sm text-center'> <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. A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app.
</div> </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> <Head>
<title>Domains - SerpBear</title> <title>Domains - SerpBear</title>
</Head> </Head>
@@ -71,9 +94,12 @@ const SingleDomain: NextPage = () => {
<div className="flex flex-col w-full max-w-5xl mx-auto p-6 lg:mt-24 lg:p-0"> <div className="flex 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='flex justify-between mb-2 items-center'>
<div className=' text-sm'>{domainsData?.domains?.length || 0} Domains</div> <div className=' text-sm text-gray-600'>
{domainsData?.domains?.length || 0} Domains <span className=' text-gray-300 ml-1 mr-1'>|</span> {totalKeywords} keywords
</div>
<div> <div>
<button <button
data-testid="addDomainButton"
className={'ml-2 inline-block py-2 text-blue-700 font-bold text-sm'} className={'ml-2 inline-block py-2 text-blue-700 font-bold text-sm'}
onClick={() => setShowAddDomain(true)}> onClick={() => setShowAddDomain(true)}>
<span <span
@@ -88,8 +114,9 @@ const SingleDomain: NextPage = () => {
key={domain.ID} key={domain.ID}
domain={domain} domain={domain}
selected={false} selected={false}
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) } isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || !!domainSCAPiObj[domain.ID] }
thumb={domainThumbs[domain.domain]} thumb={domainThumbs[domain.domain]}
updateThumb={manuallyUpdateThumb}
// isConsoleIntegrated={false} // isConsoleIntegrated={false}
/>; />;
})} })}
@@ -107,16 +134,15 @@ const SingleDomain: NextPage = () => {
</div> </div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter> <CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} /> <AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition> </CSSTransition>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter> <CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} /> <Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition> </CSSTransition>
<footer className='text-center flex flex-1 justify-center pb-5 items-end'> <Footer currentVersion={appSettings?.version ? appSettings.version : ''} />
<span className='text-gray-500 text-xs'><a href='https://github.com/towfiqi/serpbear' target="_blank" rel='noreferrer'>SerpBear v{appSettings?.settings?.version || '0.0.0'}</a></span> <Toaster position='bottom-center' containerClassName="react_toaster" />
</footer>
</div> </div>
); );
}; };
export default SingleDomain; export default Domains;

View File

@@ -8,7 +8,7 @@ import Icon from '../components/common/Icon';
const Home: NextPage = () => { const Home: NextPage = () => {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
router.push('/domains'); if (router) router.push('/domains');
}, [router]); }, [router]);
return ( return (

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

@@ -0,0 +1,150 @@
import type { NextPage } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import Icon from '../../components/common/Icon';
import TopBar from '../../components/common/TopBar';
import KeywordIdeasTable from '../../components/ideas/KeywordIdeasTable';
import { exportKeywordIdeas } from '../../utils/client/exportcsv';
import { useFetchKeywordIdeas, useMutateKeywordIdeas } from '../../services/adwords';
import { useFetchSettings } from '../../services/settings';
import Settings from '../../components/settings/Settings';
import SelectField from '../../components/common/SelectField';
import allCountries, { adwordsLanguages } from '../../utils/countries';
import Footer from '../../components/common/Footer';
const Research: NextPage = () => {
const router = useRouter();
const [showSettings, setShowSettings] = useState(false);
const [showFavorites, setShowFavorites] = useState(false);
const [language, setLanguage] = useState('1000');
const [country, setCountry] = useState('US');
const [seedKeywords, setSeedKeywords] = useState('');
const { data: appSettings } = useFetchSettings();
const adwordsConnected = !!(appSettings && appSettings?.settings?.adwords_refresh_token
&& appSettings?.settings?.adwords_developer_token, appSettings?.settings?.adwords_account_id);
const { data: keywordIdeasData, isLoading: isLoadingIdeas, isError: errorLoadingIdeas } = useFetchKeywordIdeas(router, adwordsConnected);
const { mutate: updateKeywordIdeas, isLoading: isUpdatingIdeas } = useMutateKeywordIdeas(router);
const keywordIdeas:IdeaKeyword[] = keywordIdeasData?.data?.keywords || [];
const favorites:IdeaKeyword[] = keywordIdeasData?.data?.favorites || [];
const keywordIdeasSettings = keywordIdeasData?.data?.settings || undefined;
const { country: previousCountry, language: previousLang, keywords: previousSeedKeywords } = keywordIdeasSettings || {};
useEffect(() => {
if (previousCountry) { setCountry(previousCountry); }
if (previousLang) { setLanguage(previousLang.toString()); }
if (previousSeedKeywords) { setSeedKeywords(previousSeedKeywords.join(',')); }
}, [previousCountry, previousLang, previousSeedKeywords]);
const reloadKeywordIdeas = () => {
const keywordPaylod = seedKeywords ? seedKeywords.split(',').map((key) => key.trim()) : undefined;
updateKeywordIdeas({ seedType: 'custom', language, domain: 'research', keywords: keywordPaylod, country });
};
const countryOptions = useMemo(() => {
return Object.keys(allCountries)
.filter((countryISO) => allCountries[countryISO][3] !== 0)
.map((countryISO) => ({ label: allCountries[countryISO][0], value: countryISO }));
}, []);
const languageOPtions = useMemo(() => Object.entries(adwordsLanguages).map(([value, label]) => ({ label, value })), []);
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 labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize w-full';
return (
<div className={'Login'}>
<Head>
<title>Research Keywords - SerpBear</title>
</Head>
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => null } />
<div className=" w-full max-w-7xl mx-auto lg:flex lg:flex-row">
<div className="sidebar w-full p-6 lg:pt-44 lg:w-1/5 lg:block lg:pr-0" data-testid="sidebar">
<h3 className="hidden py-7 text-base font-bold text-blue-700 lg:block">
<span className=' relative top-[3px] mr-1'><Icon type="logo" size={24} color="#364AFF" /></span> SerpBear
</h3>
<div className={`sidebar_menu domKeywords max-h-96 overflow-auto styled-scrollbar p-4
bg-white border border-gray-200 rounded lg:rounded-none lg:rounded-s lg:border-r-0`}>
<div className={'mb-3'}>
<label className={labelStyle}>Generate Ideas from given Keywords (Max 20)</label>
<textarea
className='w-full border border-solid border-gray-300 focus:border-blue-100 p-3 rounded outline-none text-sm'
value={seedKeywords}
onChange={(event) => setSeedKeywords(event.target.value)}
placeholder="keyword1, keyword2.."
/>
</div>
<div className={'mb-3'}>
<label className={labelStyle}>Country</label>
<SelectField
selected={[country]}
options={countryOptions}
defaultLabel='All Countries'
updateField={(updated:string[]) => setCountry(updated[0])}
flags={true}
multiple={false}
fullWidth={true}
maxHeight={48}
rounded='rounded'
/>
</div>
<div className={'mb-3'}>
<label className={labelStyle}>Language</label>
<SelectField
selected={[language]}
options={languageOPtions}
defaultLabel='All Languages'
updateField={(updated:string[]) => setLanguage(updated[0])}
rounded='rounded'
multiple={false}
fullWidth={true}
maxHeight={48}
/>
</div>
<button
className={`w-full py-2 px-5 mt-2 rounded bg-blue-700 text-white
font-semibold ${!adwordsConnected ? ' cursor-not-allowed opacity-40' : 'cursor-pointer'}`}
title={!adwordsConnected ? 'Please Connect Google Ads account to generate Keyword Ideas..' : ''}
onClick={() => !isUpdatingIdeas && adwordsConnected && reloadKeywordIdeas()}>
<Icon type={isUpdatingIdeas ? 'loading' : 'download'} size={14} /> {isUpdatingIdeas ? 'Loading....' : 'Load Ideas'}
</button>
</div>
</div>
<div className="domain_kewywords px-5 lg:px-0 lg:pt-8 w-full">
<div className='domain_kewywords_head w-full '>
<div className=' flex mt-12 mb-0 justify-between'>
<h1 className=" font-bold mb-0 mt-0 pt-2 lg:text-xl lg:mb-6" data-testid="domain-header">Research Keywords</h1>
<button
className={`domheader_action_button relative mb-3
${buttonStyle} ${keywordIdeas.length === 0 ? 'cursor-not-allowed opacity-60' : ''}`}
aria-pressed="false"
onClick={() => exportKeywordIdeas(showFavorites ? favorites : keywordIdeas, 'research')}>
<Icon type='download' size={20} /><i className={`${buttonLabelStyle}`}>Export as csv</i>
</button>
</div>
</div>
<KeywordIdeasTable
isLoading={isLoadingIdeas}
noIdeasDatabase={errorLoadingIdeas}
domain={null}
keywords={keywordIdeas}
favorites={favorites}
isAdwordsIntegrated={adwordsConnected}
showFavorites={showFavorites}
setShowFavorites={setShowFavorites}
/>
</div>
</div>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition>
<Footer currentVersion={appSettings?.settings?.version ? appSettings.settings.version : ''} />
</div>
);
};
export default Research;

View File

@@ -4,6 +4,10 @@ import serpapi from './services/serpapi';
import serply from './services/serply'; import serply from './services/serply';
import spaceserp from './services/spaceserp'; import spaceserp from './services/spaceserp';
import proxy from './services/proxy'; import proxy from './services/proxy';
import searchapi from './services/searchapi';
import valueSerp from './services/valueserp';
import serper from './services/serper';
import hasdata from './services/hasdata';
export default [ export default [
scrapingRobot, scrapingRobot,
@@ -12,4 +16,8 @@ export default [
serply, serply,
spaceserp, spaceserp,
proxy, proxy,
searchapi,
valueSerp,
serper,
hasdata,
]; ];

View File

@@ -0,0 +1,44 @@
import countries from '../../utils/countries';
interface HasDataResult {
title: string,
link: string,
position: number,
}
const hasdata:ScraperSettings = {
id: 'hasdata',
name: 'HasData',
website: 'hasdata.com',
allowsCity: true,
headers: (keyword, settings) => {
return {
'Content-Type': 'application/json',
'x-api-key': settings.scaping_api,
};
},
scrapeURL: (keyword, settings) => {
const country = keyword.country || 'US';
const countryName = countries[country][0];
const location = keyword.city && countryName ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
return `https://api.scrape-it.cloud/scrape/google/serp?q=${encodeURIComponent(keyword.keyword)}${location}&num=100&gl=${country.toLowerCase()}&deviceType=${keyword.device}`;
},
resultObjectKey: 'organicResults',
serpExtractor: (content) => {
const extractedResult = [];
const results: HasDataResult[] = (typeof content === 'string') ? JSON.parse(content) : content as HasDataResult[];
for (const { link, title, position } of results) {
if (title && link) {
extractedResult.push({
title,
url: link,
position,
});
}
}
return extractedResult;
},
};
export default hasdata;

View File

@@ -1,4 +1,4 @@
import cheerio from 'cheerio'; import * as cheerio from 'cheerio';
const proxy:ScraperSettings = { const proxy:ScraperSettings = {
id: 'proxy', id: 'proxy',
@@ -16,6 +16,13 @@ const proxy:ScraperSettings = {
const $ = cheerio.load(content); const $ = cheerio.load(content);
let lastPosition = 0; let lastPosition = 0;
const hasValidContent = $('body').find('#main');
if (hasValidContent.length === 0) {
const msg = '[ERROR] Scraped search results from proxy do not adhere to expected format. Unable to parse results';
console.log(msg);
throw new Error(msg);
}
const mainContent = $('body').find('#main'); const mainContent = $('body').find('#main');
const children = $(mainContent).find('h3'); const children = $(mainContent).find('h3');

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