mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54522b2261 | ||
|
|
495d872bb9 | ||
|
|
07eb4bd94f | ||
|
|
36ed4cf800 | ||
|
|
c8601ebb84 | ||
|
|
56d8b660c5 | ||
|
|
c34c8260c7 | ||
|
|
cab8f518bb | ||
|
|
6e47a6fba7 | ||
|
|
bf911b4e45 | ||
|
|
d7279512cf | ||
|
|
4fef1a9abc | ||
|
|
aeed1f8559 | ||
|
|
12eac2b012 | ||
|
|
649f412303 | ||
|
|
a2edabbdf9 | ||
|
|
3690e97fe7 | ||
|
|
17fb2c40cc | ||
|
|
faa3519a29 | ||
|
|
bc02c929ba | ||
|
|
d9d7c6347e | ||
|
|
c5714c00ae | ||
|
|
1bef7587cc | ||
|
|
5507cac07f | ||
|
|
a74338fe15 | ||
|
|
4c2f900d85 | ||
|
|
040dab1517 | ||
|
|
29c455ea56 | ||
|
|
42c5e2be07 | ||
|
|
d3e3760527 | ||
|
|
7597210ca2 | ||
|
|
4b8730e416 | ||
|
|
34dce13143 | ||
|
|
3786438662 | ||
|
|
f48288473e | ||
|
|
01b1b7b9e9 | ||
|
|
a050536814 | ||
|
|
bc96dc7de5 | ||
|
|
b35d333bfc | ||
|
|
a09eb62f5a | ||
|
|
42a00dafad | ||
|
|
432fc6161c | ||
|
|
15a1224260 | ||
|
|
232507e1ff | ||
|
|
55fa7c0148 | ||
|
|
8152d81804 | ||
|
|
748dc8fc61 | ||
|
|
c24b63009c | ||
|
|
bf8fd5362b | ||
|
|
0c3068dc80 | ||
|
|
fde2f728aa | ||
|
|
40e027e1ec | ||
|
|
51da47f292 | ||
|
|
d58a716ec1 | ||
|
|
79fc6b935c | ||
|
|
3fc1024520 | ||
|
|
90f45fd1c9 | ||
|
|
3a05703921 | ||
|
|
6aa8900577 | ||
|
|
fda8692daf | ||
|
|
1d0a788810 | ||
|
|
75453d81de | ||
|
|
d48ae76103 | ||
|
|
4a87d229fe | ||
|
|
ec3cc9e12b | ||
|
|
f4ba8b0545 | ||
|
|
d3938007a9 | ||
|
|
252ae9aa84 | ||
|
|
50160f5b23 | ||
|
|
bb4a6844b5 | ||
|
|
407ab8db83 | ||
|
|
2a1fc0e43d | ||
|
|
4d15989b28 | ||
|
|
5650645b58 | ||
|
|
83c47452fc | ||
|
|
56ffbf59d1 | ||
|
|
9a7a43f051 | ||
|
|
724d3c8d43 | ||
|
|
7e8840c2e2 | ||
|
|
0e64b95cd5 | ||
|
|
e5ad7a3175 | ||
|
|
e5dd411aa9 | ||
|
|
c3ddb9d3c3 | ||
|
|
dbf540cfdb | ||
|
|
1f0831ed13 | ||
|
|
b4ad69baaa | ||
|
|
f04b10cf6b | ||
|
|
b2e97b2ebe | ||
|
|
1041cb3c0b | ||
|
|
3719f21d98 | ||
|
|
444ba5d461 | ||
|
|
dd54e535c9 | ||
|
|
34d121dac7 | ||
|
|
3c2a1b8a5b | ||
|
|
e2ecdef10e | ||
|
|
633ab2c467 | ||
|
|
7c6c7fc3d1 | ||
|
|
cca9f95358 | ||
|
|
faa88c9254 | ||
|
|
8b0ee562cf | ||
|
|
2f08bb3f62 | ||
|
|
897aa0b7d7 | ||
|
|
e166b588aa | ||
|
|
c897a52550 | ||
|
|
df3a738788 | ||
|
|
4a47cedad8 | ||
|
|
2783de5c65 |
@@ -13,6 +13,9 @@
|
||||
"arrow-body-style":"off",
|
||||
"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": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
|
||||
8
.sequelizerc
Normal file
8
.sequelizerc
Normal 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')
|
||||
};
|
||||
148
CHANGELOG.md
148
CHANGELOG.md
@@ -2,6 +2,154 @@
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -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
|
||||
|
||||
COPY package.json ./
|
||||
@@ -7,17 +8,22 @@ RUN npm install
|
||||
COPY . .
|
||||
|
||||
|
||||
FROM node:lts-alpine AS builder
|
||||
FROM node:22.11.0-alpine3.20 AS builder
|
||||
WORKDIR /app
|
||||
ENV NPM_VERSION=10.3.0
|
||||
RUN npm install -g npm@"${NPM_VERSION}"
|
||||
COPY --from=deps /app ./
|
||||
RUN rm -rf /app/data
|
||||
RUN rm -rf /app/__tests__
|
||||
RUN rm -rf /app/__mocks__
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM node:lts-alpine AS runner
|
||||
FROM node:22.11.0-alpine3.20 AS runner
|
||||
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 adduser --system --uid 1001 nextjs
|
||||
RUN set -xe && mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
@@ -29,13 +35,17 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# setup the cron
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/cron.js ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/email ./email
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/database ./database
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.sequelizerc ./.sequelizerc
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/entrypoint.sh ./entrypoint.sh
|
||||
RUN rm package.json
|
||||
RUN npm init -y
|
||||
RUN npm i cryptr dotenv 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
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["concurrently","node server.js", "node cron.js"]
|
||||
79
README.md
79
README.md
@@ -1,51 +1,60 @@
|
||||

|
||||

|
||||
|
||||
# SerpBear
|
||||
|
||||
   
|
||||
    [](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)
|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
#### Features
|
||||
- **Unlimited Keywords:** Add unlimited domains and unlimited keywords to track their SERP.
|
||||
- **Email Notification:** Get notified of your keyword position changes daily/weekly/monthly through email.
|
||||
- **SERP API:** SerpBear comes with built-in API that you can use for your marketing & data reporting tools.
|
||||
- **Google Search Console Integration:** Get the actual visit count, impressions & more for Each keyword.
|
||||
- **Mobile App:** Add the PWA app to your mobile for a better mobile experience.
|
||||
- **Zero Cost to RUN:** Run the App on mogenius.com or Fly.io for free.
|
||||
|
||||
- **Unlimited Keywords:** Add unlimited domains and unlimited keywords to track their SERP.
|
||||
- **Email Notification:** Get notified of your keyword position changes daily/weekly/monthly through email.
|
||||
- **SERP API:** SerpBear comes with built-in API that you can use for your marketing & data reporting tools.
|
||||
- **Keyword Research:** Ability to research keywords and auto-generate keyword ideas from your tracked website's content by integrating your Google Ads test account.
|
||||
- **Google Search Console Integration:** Get the actual visit count, impressions & more for Each keyword.
|
||||
- **Mobile App:** Add the PWA app to your mobile for a better mobile experience.
|
||||
- **Zero Cost to RUN:** Run the App on mogenius.com or Fly.io for free.
|
||||
|
||||
#### How it Works
|
||||
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SearchApi, SerpApi or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword. Also, When you connect your Googel Search Console account, the app shows actual search visits for each tracked keywords. You can also discover new keywords, and find the most performing keywords, countries, pages.
|
||||
|
||||
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
|
||||
- **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 | 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 |
|
||||
| SearchApi.io | From $40/mo | From 10,000/mo | Yes |
|
||||
#### SerpBear Integrates with popular SERP scraping services
|
||||
|
||||
(*) Free upto a limit. If you are using ScrapingAnt you can lookup 10,000 times per month for free.
|
||||
(**) Free up to 100 per month. Paid from 5,000 to 10,000,000+ per month.
|
||||
If you don't want to use proxies, you can use third party Scraping services to scrape Google Search results.
|
||||
|
||||
**Stack**
|
||||
- Next.js for Frontend & Backend.
|
||||
- Sqlite for Database.
|
||||
|
||||
| Service | Cost | SERP Lookup | API |
|
||||
| ----------------- | ------------- | -------------- | --- |
|
||||
| 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.
|
||||
|
||||
@@ -22,6 +22,7 @@ export const dummyKeywords = [
|
||||
lastUpdated: '2022-11-15T10:49:53.113',
|
||||
added: '2022-11-11T10:01:06.951',
|
||||
position: 19,
|
||||
volume: 10000,
|
||||
history: {
|
||||
'2022-11-11': 21,
|
||||
'2022-11-12': 24,
|
||||
@@ -45,6 +46,7 @@ export const dummyKeywords = [
|
||||
lastUpdated: '2022-11-15T10:49:53.119',
|
||||
added: '2022-11-15T10:01:06.951',
|
||||
position: 29,
|
||||
volume: 1200,
|
||||
history: {
|
||||
'2022-11-11': 33,
|
||||
'2022-11-12': 34,
|
||||
@@ -67,6 +69,7 @@ export const dummySettings = {
|
||||
notification_interval: 'never',
|
||||
notification_email: '',
|
||||
notification_email_from: '',
|
||||
notification_email_from_name: 'SerpBear',
|
||||
smtp_server: '',
|
||||
smtp_port: '',
|
||||
smtp_username: '',
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { rest } from 'msw';
|
||||
import { http } from 'msw';
|
||||
import * as React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
export const handlers = [
|
||||
rest.get(
|
||||
http.get(
|
||||
'*/react-query',
|
||||
(req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
({ request, params }) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name: 'mocked-react-query',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MockResponseInitFunction } from 'jest-fetch-mock';
|
||||
import SingleDomain from '../../pages/domain/[slug]';
|
||||
import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains';
|
||||
import { useAddKeywords, useDeleteKeywords, useFavKeywords, useFetchKeywords, useRefreshKeywords } from '../../services/keywords';
|
||||
import { useAddKeywords, useDeleteKeywords,
|
||||
useFavKeywords, useFetchKeywords, useRefreshKeywords, useFetchSingleKeyword } from '../../services/keywords';
|
||||
import { dummyDomain, dummyKeywords, dummySettings } from '../../__mocks__/data';
|
||||
import { useFetchSettings } from '../../services/settings';
|
||||
|
||||
@@ -31,6 +31,7 @@ const useAddKeywordsFunc = useAddKeywords as jest.Mock<any>;
|
||||
const useUpdateDomainFunc = useUpdateDomain as jest.Mock<any>;
|
||||
const useDeleteDomainFunc = useDeleteDomain as jest.Mock<any>;
|
||||
const useFetchSettingsFunc = useFetchSettings as jest.Mock<any>;
|
||||
const useFetchSingleKeywordFunc = useFetchSingleKeyword as jest.Mock<any>;
|
||||
|
||||
describe('SingleDomain Page', () => {
|
||||
const queryClient = new QueryClient();
|
||||
@@ -38,6 +39,8 @@ describe('SingleDomain Page', () => {
|
||||
useFetchSettingsFunc.mockImplementation(() => ({ data: { settings: dummySettings }, isLoading: false }));
|
||||
useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false }));
|
||||
useFetchKeywordsFunc.mockImplementation(() => ({ keywordsData: { keywords: dummyKeywords }, keywordsLoading: false }));
|
||||
const fetchPayload = { history: dummyKeywords[0].history || [], searchResult: dummyKeywords[0].lastResult || [] };
|
||||
useFetchSingleKeywordFunc.mockImplementation(() => ({ data: fetchPayload, isLoading: false }));
|
||||
useDeleteKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
|
||||
useFavKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
|
||||
useRefreshKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
|
||||
@@ -67,16 +70,7 @@ describe('SingleDomain Page', () => {
|
||||
const keywords = document.querySelectorAll('.keyword');
|
||||
const firstKeyword = keywords && keywords[0].querySelector('a');
|
||||
if (firstKeyword) fireEvent.click(firstKeyword);
|
||||
const fn: MockResponseInitFunction = async () => {
|
||||
return new Promise((resolve) => {
|
||||
resolve({
|
||||
body: JSON.stringify({ keyword: dummyKeywords[0] }),
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
};
|
||||
fetchMock.mockIf(`${window.location.origin}/api/keyword?id=${dummyKeywords[0].ID}`, fn);
|
||||
|
||||
expect(useFetchSingleKeyword).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('keywordDetails')).toBeVisible();
|
||||
});
|
||||
it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => {
|
||||
|
||||
@@ -8,9 +8,10 @@ type ChartProps ={
|
||||
labels: string[],
|
||||
sreies: number[],
|
||||
reverse? : boolean,
|
||||
noMaxLimit?: boolean
|
||||
}
|
||||
|
||||
const Chart = ({ labels, sreies, reverse = true }:ChartProps) => {
|
||||
const Chart = ({ labels, sreies, reverse = true, noMaxLimit = false }:ChartProps) => {
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -19,7 +20,7 @@ const Chart = ({ labels, sreies, reverse = true }:ChartProps) => {
|
||||
y: {
|
||||
reverse,
|
||||
min: 1,
|
||||
max: reverse ? 100 : undefined,
|
||||
max: !noMaxLimit && reverse ? 100 : undefined,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -6,10 +6,12 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler,
|
||||
|
||||
type ChartProps ={
|
||||
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 = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -17,9 +19,9 @@ const ChartSlim = ({ labels, sreies }:ChartProps) => {
|
||||
scales: {
|
||||
y: {
|
||||
display: false,
|
||||
reverse: true,
|
||||
reverse,
|
||||
min: 1,
|
||||
max: 100,
|
||||
max: noMaxLimit ? undefined : 100,
|
||||
},
|
||||
x: {
|
||||
display: false,
|
||||
@@ -35,7 +37,7 @@ const ChartSlim = ({ labels, sreies }:ChartProps) => {
|
||||
},
|
||||
};
|
||||
|
||||
return <div className='w-[100px] h-[30px] rounded border border-gray-200'>
|
||||
return <div className='w-[80px] h-[30px] rounded border border-gray-200'>
|
||||
<Line
|
||||
datasetIdKey='XXX'
|
||||
options={options}
|
||||
|
||||
32
components/common/Footer.tsx
Normal file
32
components/common/Footer.tsx
Normal 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;
|
||||
@@ -10,10 +10,10 @@ type 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 (
|
||||
<span className={`icon inline-block relative top-[2px] ${classes}`} title={title}>
|
||||
<span className={`icon inline-block relative top-[2px] ${classes}`}>
|
||||
{type === 'logo'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 1484.32 1348.5">
|
||||
<path fill={color} d="M1406.23,604.17s-44-158.18,40.43-192.67,195,97.52,195,97.52,314-65.41,534,0c0,0,122.16-105.61,214.68-80.28,99.9,27.36,32.7,181.38,32.7,181.38s228.36,384.15,239.06,737.38c0,0-346.1,346.09-746.9,406.75,0,0-527.47-106.44-737.38-449.57C1177.88,1304.68,1169.55,1008.54,1406.23,604.17Z" transform="translate(-1177.84 -405.75)"/>
|
||||
@@ -131,13 +131,13 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
|
||||
}
|
||||
{type === 'star'
|
||||
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
|
||||
<path fill={color} d="M10 1L7 7l-6 .75l4.13 4.62L4 19l6-3l6 3l-1.12-6.63L19 7.75L13 7zm0 2.24l2.34 4.69l4.65.58l-3.18 3.56l.87 5.15L10 14.88l-4.68 2.34l.87-5.15l-3.18-3.56l4.65-.58z"/>
|
||||
</svg>
|
||||
<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>
|
||||
}
|
||||
{type === 'star-filled'
|
||||
&& <svg width={size} viewBox="0 0 24 24" {...xmlnsProps}>
|
||||
<path fill={color} d="M10 1l3 6l6 .75l-4.12 4.62L16 19l-6-3l-6 3l1.13-6.63L1 7.75L7 7z"/>
|
||||
</svg>
|
||||
<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>
|
||||
}
|
||||
{type === 'link'
|
||||
&& <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" />
|
||||
</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'
|
||||
&& <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"/>
|
||||
@@ -221,6 +241,14 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
|
||||
</g>
|
||||
</svg>
|
||||
}
|
||||
{type === 'eye-closed'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<g fill="none" stroke={color} strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
|
||||
<path d="M6.873 17.129c-1.845-1.31-3.305-3.014-4.13-4.09a1.693 1.693 0 0 1 0-2.077C4.236 9.013 7.818 5 12 5c1.876 0 3.63.807 5.13 1.874"/>
|
||||
<path d="M14.13 9.887a3 3 0 1 0-4.243 4.242M4 20L20 4M10 18.704A7.124 7.124 0 0 0 12 19c4.182 0 7.764-4.013 9.257-5.962a1.694 1.694 0 0 0-.001-2.078A22.939 22.939 0 0 0 19.57 9"/>
|
||||
</g>
|
||||
</svg>
|
||||
}
|
||||
{type === 'target'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<path d="M19.938 13A8.004 8.004 0 0 1 13 19.938V22h-2v-2.062A8.004 8.004 0 0 1 4.062 13H2v-2h2.062A8.004 8.004 0 0 1 11 4.062V2h2v2.062A8.004 8.004 0 0 1 19.938 11H22v2h-2.062zM12 18a6 6 0 1 0 0-12a6 6 0 0 0 0 12zm0-3a3 3 0 1 0 0-6a3 3 0 0 0 0 6z" fill={color} fillRule="nonzero"/>
|
||||
@@ -245,6 +273,46 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
|
||||
<path d="M15.75 9h3v2.25h-3z" fill={color} />
|
||||
</svg>
|
||||
}
|
||||
{type === 'email'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<path fill={color} d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z" />
|
||||
</svg>
|
||||
}
|
||||
{type === 'scraper'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 16 16">
|
||||
<path fill={color} d="M1 3.5A2.5 2.5 0 0 1 3.5 1h7A2.5 2.5 0 0 1 13 3.5v1.53a4.538 4.538 0 0 0-1-.004V5H2v5.5A1.5 1.5 0 0 0 3.5 12h2.954l-.72.72a2.52 2.52 0 0 0-.242.28H3.5A2.5 2.5 0 0 1 1 10.5zm7.931 3.224l-.577-.578a.5.5 0 1 0-.708.708l.745.744c.144-.306.324-.6.54-.874M2 4h10v-.5A1.5 1.5 0 0 0 10.5 2h-7A1.5 1.5 0 0 0 2 3.5zm4.354 2.854a.5.5 0 1 0-.708-.708l-2 2a.5.5 0 0 0 0 .708l2 2a.5.5 0 0 0 .708-.708L4.707 8.5zm6.538-.83c.366.042.471.48.21.742l-.975.975a1.507 1.507 0 1 0 2.132 2.132l.975-.975c.261-.261.7-.156.742.21a3.518 3.518 0 0 1-4.676 3.723l-2.726 2.727a1.507 1.507 0 1 1-2.132-2.132L9.168 10.7a3.518 3.518 0 0 1 3.724-4.676" />
|
||||
</svg>
|
||||
}
|
||||
{type === 'city'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 48 48">
|
||||
<g fill="none">
|
||||
<path stroke={color} strokeLinecap="round" strokeLinejoin="round" strokeWidth={4} d="M4 42h40"></path>
|
||||
<rect width={8} height={16} x={8} y={26} stroke={color} strokeLinejoin="round" strokeWidth={4} rx={2}></rect>
|
||||
<path stroke={color} strokeLinecap="square" strokeLinejoin="round" strokeWidth={4} d="M12 34h1"></path>
|
||||
<rect width={24} height={38} x={16} y={4} stroke={color} strokeLinejoin="round" strokeWidth={4} rx={2}></rect>
|
||||
<path fill={color} d="M22 10h4v4h-4zm8 0h4v4h-4zm-8 7h4v4h-4zm8 0h4v4h-4zm0 7h4v4h-4zm0 7h4v4h-4z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
28
components/common/InputField.tsx
Normal file
28
components/common/InputField.tsx
Normal 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;
|
||||
@@ -1,25 +1,17 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import useOnKey from '../../hooks/useOnKey';
|
||||
|
||||
type ModalProps = {
|
||||
children: React.ReactNode,
|
||||
width?: string,
|
||||
title?: string,
|
||||
verticalCenter?: boolean,
|
||||
closeModal: Function,
|
||||
}
|
||||
|
||||
const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
|
||||
useEffect(() => {
|
||||
const closeModalonEsc = (event:KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', closeModalonEsc, false);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', closeModalonEsc, false);
|
||||
};
|
||||
}, [closeModal]);
|
||||
const Modal = ({ children, width = '1/2', closeModal, title, verticalCenter = false }:ModalProps) => {
|
||||
useOnKey('Escape', closeModal);
|
||||
|
||||
const closeOnBGClick = (e:React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -30,8 +22,9 @@ const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
|
||||
return (
|
||||
<div className='modal fixed top-0 left-0 bg-white/[.7] w-full h-screen z-50' onClick={closeOnBGClick}>
|
||||
<div
|
||||
className={`modal__content max-w-[340px] absolute top-1/4 left-0 right-0 ml-auto mr-auto w-${width}
|
||||
lg:max-w-md bg-white shadow-md rounded-md p-5 border-t-[1px] border-gray-100 text-base`}>
|
||||
className={`modal__content max-w-[340px] absolute left-0 right-0 ml-auto mr-auto w-${width}
|
||||
lg:max-w-md bg-white shadow-md rounded-md p-5 border-t-[1px] border-gray-100 text-base
|
||||
${verticalCenter ? ' top-1/2 translate-y-[-50%]' : 'top-1/4'}`}>
|
||||
{title && <h3 className=' font-semibold mb-3'>{title}</h3>}
|
||||
<button
|
||||
className='modal-close absolute right-2 top-2 p-2 cursor-pointer text-gray-400 transition-all
|
||||
|
||||
37
components/common/SecretField.tsx
Normal file
37
components/common/SecretField.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react';
|
||||
import Icon from './Icon';
|
||||
|
||||
type SecretFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: Function;
|
||||
placeholder?: string;
|
||||
classNames?: string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
const SecretField = ({ label = '', value = '', placeholder = '', onChange, hasError = false }: SecretFieldProps) => {
|
||||
const [showValue, setShowValue] = useState(false);
|
||||
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
|
||||
return (
|
||||
<div className="settings__section__secret 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;
|
||||
@@ -9,12 +9,15 @@ type SelectFieldProps = {
|
||||
defaultLabel: string,
|
||||
options: SelectionOption[],
|
||||
selected: string[],
|
||||
label?: string,
|
||||
multiple?: boolean,
|
||||
updateField: Function,
|
||||
fullWidth?: boolean,
|
||||
minWidth?: number,
|
||||
maxHeight?: number|string,
|
||||
rounded?: string,
|
||||
flags?: boolean,
|
||||
inline?: boolean,
|
||||
emptyMsg?: string
|
||||
}
|
||||
const SelectField = (props: SelectFieldProps) => {
|
||||
@@ -26,8 +29,11 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
updateField,
|
||||
minWidth = 180,
|
||||
maxHeight = 96,
|
||||
fullWidth = false,
|
||||
rounded = 'rounded-3xl',
|
||||
inline = false,
|
||||
flags = false,
|
||||
label = '',
|
||||
emptyMsg = '' } = props;
|
||||
|
||||
const [showOptions, setShowOptions] = useState<boolean>(false);
|
||||
@@ -66,12 +72,13 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
};
|
||||
|
||||
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
|
||||
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[180px] min-w-[${minWidth}px]
|
||||
${showOptions ? 'border-indigo-200' : ''}`}
|
||||
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none ${fullWidth ? 'w-full' : 'w-[210px]'}
|
||||
min-w-[${minWidth}px] ${showOptions ? 'border-indigo-200' : ''}`}
|
||||
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}
|
||||
</span>
|
||||
{multiple && selected.length > 2
|
||||
@@ -80,7 +87,7 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
</div>
|
||||
{showOptions && (
|
||||
<div
|
||||
className={`select_list mt-1 border absolute min-w-[${minWidth}px]
|
||||
className={`select_list mt-1 border absolute min-w-[${minWidth}px] top-[30px] right-0 ${fullWidth ? 'w-full' : 'w-[210px]'}
|
||||
${rounded === 'rounded-3xl' ? 'rounded-lg' : rounded} max-h-${maxHeight} bg-white z-50 overflow-y-auto styled-scrollbar`}>
|
||||
{options.length > 20 && (
|
||||
<div className=''>
|
||||
@@ -99,7 +106,7 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
return (
|
||||
<li
|
||||
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' : ''} `}
|
||||
onClick={() => selectItem(opt)}
|
||||
>
|
||||
|
||||
37
components/common/SidePanel.tsx
Normal file
37
components/common/SidePanel.tsx
Normal 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;
|
||||
@@ -25,7 +25,7 @@ const Sidebar = ({ domains, showAddModal } : SidebarProps) => {
|
||||
<Link href={`/domain/${d.slug}`} passHref={true}>
|
||||
<a className={`block cursor-pointer px-4 text-ellipsis max-w-[215px] overflow-hidden whitespace-nowrap rounded
|
||||
rounded-r-none ${((`/domain/${d.slug}` === router.asPath || `/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')}`}>
|
||||
<img
|
||||
className={' inline-block mr-1'}
|
||||
|
||||
32
components/common/ToggleField.tsx
Normal file
32
components/common/ToggleField.tsx
Normal 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;
|
||||
@@ -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
|
||||
<button className='px-3 py-1 font-bold text-blue-700 lg:hidden ml-3 text-lg' onClick={() => showAddModal()}>+</button>
|
||||
</h3>
|
||||
{!isDomainsPage && (
|
||||
{!isDomainsPage && router.asPath !== '/research' && (
|
||||
<Link href={'/domains'} passHref={true}>
|
||||
<a className=' right-14 top-2 px-2 py-1 cursor-pointer bg-[#ecf2ff] hover:bg-indigo-100 transition-all
|
||||
absolute lg:top-3 lg:right-auto lg:left-8 lg:px-3 lg:py-2 rounded-full'>
|
||||
@@ -52,16 +52,30 @@ const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
|
||||
<ul
|
||||
className={`text-sm font-semibold text-gray-500 absolute mt-[-10px] right-3 bg-white
|
||||
border border-gray-200 lg:mt-2 lg:relative lg:block lg:border-0 lg:bg-transparent ${showMobileMenu ? 'block' : 'hidden'}`}>
|
||||
<li className='block lg:inline-block lg:ml-5'>
|
||||
<a className='block px-3 py-2 cursor-pointer' href='https://docs.serpbear.com/' target="_blank" rel='noreferrer'>
|
||||
<Icon type="question" color={'#888'} size={14} /> Help
|
||||
</a>
|
||||
<li className={`block lg:inline-block lg:ml-5 ${router.asPath === '/domains' ? ' text-blue-700' : ''}`}>
|
||||
<Link href={'/domains'} passHref={true}>
|
||||
<a className='block px-3 py-2 cursor-pointer'>
|
||||
<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 className='block lg:inline-block lg:ml-5'>
|
||||
<a className='block px-3 py-2 cursor-pointer' onClick={() => showSettings()}>
|
||||
<Icon type="settings-alt" color={'#888'} size={14} /> Settings
|
||||
</a>
|
||||
</li>
|
||||
<li className='block lg:inline-block lg:ml-5'>
|
||||
<a className='block px-3 py-2 cursor-pointer' 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'>
|
||||
<a className='block px-3 py-2 cursor-pointer' onClick={() => logoutUser()}>
|
||||
<Icon type="logout" color={'#888'} size={14} /> Logout
|
||||
|
||||
@@ -1,54 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from '../common/Modal';
|
||||
import { useAddDomain } from '../../services/domains';
|
||||
import { isValidDomain } from '../../utils/validators';
|
||||
import { isValidUrl } from '../../utils/client/validators';
|
||||
|
||||
type AddDomainProps = {
|
||||
domains: DomainType[],
|
||||
closeModal: Function
|
||||
}
|
||||
|
||||
const AddDomain = ({ closeModal }: AddDomainProps) => {
|
||||
const AddDomain = ({ closeModal, domains = [] }: AddDomainProps) => {
|
||||
const [newDomain, setNewDomain] = useState<string>('');
|
||||
const [newDomainError, setNewDomainError] = useState<boolean>(false);
|
||||
const [newDomainError, setNewDomainError] = useState('');
|
||||
const { mutate: addMutate, isLoading: isAdding } = useAddDomain(() => closeModal());
|
||||
|
||||
const addDomain = () => {
|
||||
// console.log('ADD NEW DOMAIN', newDomain);
|
||||
if (isValidDomain(newDomain.trim())) {
|
||||
setNewDomainError(false);
|
||||
// TODO: Domain Action
|
||||
addMutate(newDomain.trim());
|
||||
} else {
|
||||
setNewDomainError(true);
|
||||
setNewDomainError('');
|
||||
const existingDomains = domains.map((d) => d.domain);
|
||||
const insertedURLs = newDomain.split('\n');
|
||||
const domainsTobeAdded:string[] = [];
|
||||
const invalidDomains:string[] = [];
|
||||
insertedURLs.forEach((url) => {
|
||||
const theURL = url.trim();
|
||||
if (isValidUrl(theURL)) {
|
||||
const domURL = new URL(theURL);
|
||||
const isDomain = domURL.pathname === '/';
|
||||
if (isDomain && !existingDomains.includes(domURL.host)) {
|
||||
domainsTobeAdded.push(domURL.host);
|
||||
}
|
||||
if (!isDomain && !existingDomains.includes(domURL.href)) {
|
||||
const cleanedURL = domURL.href.replace('https://', '').replace('http://', '').replace(/^\/+|\/+$/g, '');
|
||||
domainsTobeAdded.push(cleanedURL);
|
||||
}
|
||||
} else {
|
||||
invalidDomains.push(theURL);
|
||||
}
|
||||
});
|
||||
if (invalidDomains.length > 0) {
|
||||
setNewDomainError(`Please Insert Valid 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>) => {
|
||||
if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(false); }
|
||||
const handleDomainInput = (e:React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(''); }
|
||||
setNewDomain(e.currentTarget.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal closeModal={() => { closeModal(false); }} title={'Add New Domain'}>
|
||||
<div data-testid="adddomain_modal">
|
||||
<h4 className='text-sm mt-4'>
|
||||
Domain Name {newDomainError && <span className=' ml-2 block float-right text-red-500 text-xs font-semibold'>Not a Valid Domain</span>}
|
||||
</h4>
|
||||
<input
|
||||
className={`w-full p-2 border border-gray-200 rounded mt-2 mb-3 focus:outline-none focus:border-blue-200
|
||||
${newDomainError ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type="text"
|
||||
<h4 className='text-sm mt-4 pb-2'>Website URL(s)</h4>
|
||||
<textarea
|
||||
className={`w-full h-40 border rounded border-gray-200 p-4 outline-none
|
||||
focus:border-indigo-300 ${newDomainError ? ' border-red-400 focus:border-red-400' : ''}`}
|
||||
placeholder={'Type or Paste URLs here. Insert Each URL in a New line. eg: \nhttps://mysite.com/ \nhttps://anothersite.com/ '}
|
||||
value={newDomain}
|
||||
placeholder={'example.com'}
|
||||
onChange={handleDomainInput}
|
||||
autoFocus={true}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === 'Enter') {
|
||||
e.preventDefault();
|
||||
addDomain();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
onChange={handleDomainInput}>
|
||||
</textarea>
|
||||
{newDomainError && <div><span className=' ml-2 block float-right text-red-500 text-xs font-semibold'>{newDomainError}</span></div>}
|
||||
<div className='mt-6 text-right text-sm font-semibold'>
|
||||
<button className='py-2 px-5 rounded cursor-pointer bg-indigo-50 text-slate-500 mr-3' onClick={() => closeModal(false)}>Cancel</button>
|
||||
<button className='py-2 px-5 rounded cursor-pointer bg-blue-700 text-white' onClick={() => !isAdding && addDomain() }>
|
||||
|
||||
@@ -13,15 +13,19 @@ type DomainHeaderProps = {
|
||||
exportCsv:Function,
|
||||
scFilter?: string
|
||||
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 [showOptions, setShowOptions] = useState<boolean>(false);
|
||||
const [ShowSCDates, setShowSCDates] = useState<boolean>(false);
|
||||
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
|
||||
const isConsole = router.pathname === '/domain/console/[slug]';
|
||||
const isInsight = router.pathname === '/domain/insight/[slug]';
|
||||
const isIdeas = router.pathname === '/domain/ideas/[slug]';
|
||||
|
||||
const daysName = (dayKey:string) => dayKey.replace('three', '3').replace('seven', '7').replace('thirty', '30').replace('Days', ' Days');
|
||||
const buttonStyle = 'leading-6 inline-block px-2 py-2 text-gray-500 hover:text-gray-700';
|
||||
@@ -45,8 +49,8 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full justify-between'>
|
||||
<ul className=' flex items-end text-sm relative top-[2px]'>
|
||||
<div className='flex w-full justify-between mt-4 lg:mt-0'>
|
||||
<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' : ''}`}>
|
||||
<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' />
|
||||
@@ -70,8 +74,22 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
|
||||
</a>
|
||||
</Link>
|
||||
</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>
|
||||
<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)}>
|
||||
<Icon type='dots' size={20} />
|
||||
</button>
|
||||
@@ -89,7 +107,7 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
|
||||
<Icon type='download' size={20} /><i className={`${buttonLabelStyle}`}>Export as csv</i>
|
||||
</button>
|
||||
)}
|
||||
{!isConsole && !isInsight && (
|
||||
{!isConsole && !isInsight && !isIdeas && (
|
||||
<button
|
||||
className={`domheader_action_button relative ${buttonStyle} lg:ml-3`}
|
||||
aria-pressed="false"
|
||||
@@ -105,10 +123,10 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
|
||||
<i className={`${buttonLabelStyle}`}>Domain Settings</i>
|
||||
</button>
|
||||
</div>
|
||||
{!isConsole && !isInsight && (
|
||||
{!isConsole && !isInsight && !isIdeas && (
|
||||
<button
|
||||
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)}>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
@@ -38,7 +38,7 @@ const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb, upda
|
||||
/>
|
||||
</div>
|
||||
<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 && (
|
||||
<span className=' text-gray-600 text-xs'>
|
||||
Updated <TimeAgo title={dayjs(keywordsUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={keywordsUpdated} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import Modal from '../common/Modal';
|
||||
import { useDeleteDomain, useUpdateDomain } from '../../services/domains';
|
||||
import { useDeleteDomain, useFetchDomain, useUpdateDomain } from '../../services/domains';
|
||||
import InputField from '../common/InputField';
|
||||
import SelectField from '../common/SelectField';
|
||||
|
||||
type DomainSettingsProps = {
|
||||
domain:DomainType|false,
|
||||
@@ -16,28 +18,27 @@ type DomainSettingsError = {
|
||||
|
||||
const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
const router = useRouter();
|
||||
const [currentTab, setCurrentTab] = useState<'notification'|'searchconsole'>('notification');
|
||||
const [showRemoveDomain, setShowRemoveDomain] = useState<boolean>(false);
|
||||
const [settingsError, setSettingsError] = useState<DomainSettingsError>({ type: '', msg: '' });
|
||||
const [domainSettings, setDomainSettings] = useState<DomainSettings>({ notification_interval: 'never', notification_emails: '' });
|
||||
const [domainSettings, setDomainSettings] = useState<DomainSettings>(() => ({
|
||||
notification_interval: domain && domain.notification_interval ? domain.notification_interval : 'never',
|
||||
notification_emails: domain && domain.notification_emails ? domain.notification_emails : '',
|
||||
search_console: domain && domain.search_console ? JSON.parse(domain.search_console) : {
|
||||
property_type: 'domain', url: '', client_email: '', private_key: '',
|
||||
},
|
||||
}));
|
||||
|
||||
const { mutate: updateMutate } = useUpdateDomain(() => closeModal(false));
|
||||
const { mutate: deleteMutate } = useDeleteDomain(() => {
|
||||
closeModal(false);
|
||||
router.push('/domains');
|
||||
const { mutate: updateMutate, error: domainUpdateError, isLoading: isUpdating } = useUpdateDomain(() => closeModal(false));
|
||||
const { mutate: deleteMutate } = useDeleteDomain(() => { closeModal(false); router.push('/domains'); });
|
||||
|
||||
// Get the Full Domain Data along with the Search Console API Data.
|
||||
useFetchDomain(router, domain && domain.domain ? domain.domain : '', (domainObj:DomainType) => {
|
||||
const currentSearchConsoleSettings = domainObj.search_console && JSON.parse(domainObj.search_console);
|
||||
setDomainSettings({ ...domainSettings, search_console: currentSearchConsoleSettings || domainSettings.search_console });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (domain) {
|
||||
setDomainSettings({ notification_interval: domain.notification_interval, notification_emails: domain.notification_emails });
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
const updateNotiEmails = (event:React.FormEvent<HTMLInputElement>) => {
|
||||
setDomainSettings({ ...domainSettings, notification_emails: event.currentTarget.value });
|
||||
};
|
||||
|
||||
const updateDomain = () => {
|
||||
console.log('Domain: ');
|
||||
let error: DomainSettingsError | null = null;
|
||||
if (domainSettings.notification_emails) {
|
||||
const notification_emails = domainSettings.notification_emails.split(',');
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]">
|
||||
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]" verticalCenter={currentTab === 'searchconsole'} >
|
||||
<div data-testid="domain_settings" className=" text-sm">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h4>Notification Emails
|
||||
{settingsError.type === 'email' && <span className="text-red-500 font-semibold ml-2">{settingsError.msg}</span>}
|
||||
</h4>
|
||||
<input
|
||||
className={`border w-46 text-sm transition-all rounded p-1.5 px-4 outline-none ring-0
|
||||
${settingsError.type === 'email' ? ' border-red-300' : ''}`}
|
||||
type="text"
|
||||
placeholder='Your Emails'
|
||||
onChange={updateNotiEmails}
|
||||
value={domainSettings.notification_emails || ''}
|
||||
/>
|
||||
<div className=' mt-3 mb-5 border border-slate-200 px-2 py-4 pb-0
|
||||
relative left-[-20px] w-[calc(100%+40px)] border-l-0 border-r-0 bg-[#f8f9ff]'>
|
||||
<ul>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'} `}
|
||||
onClick={() => setCurrentTab('notification')}>
|
||||
<Icon type='email' /> Notification
|
||||
</li>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'}`}
|
||||
onClick={() => setCurrentTab('searchconsole')}>
|
||||
<Icon type='google' /> Search Console
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{currentTab === 'notification' && (
|
||||
<div className="mb-4 flex justify-between items-center w-full">
|
||||
<InputField
|
||||
label='Notification Emails'
|
||||
onChange={(emails:string) => setDomainSettings({ ...domainSettings, notification_emails: emails })}
|
||||
value={domainSettings.notification_emails || ''}
|
||||
placeholder='Your Emails'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'searchconsole' && (
|
||||
<>
|
||||
<div className="mb-4 flex justify-between items-center w-full">
|
||||
<label className='mb-2 font-semibold inline-block text-sm text-gray-700 capitalize'>Property Type</label>
|
||||
<SelectField
|
||||
options={[{ label: 'Domain', value: 'domain' }, { label: 'URL', value: 'url' }]}
|
||||
selected={[domainSettings.search_console?.property_type || 'domain']}
|
||||
defaultLabel="Select Search Console Property Type"
|
||||
updateField={(updated:['domain'|'url']) => setDomainSettings({
|
||||
...domainSettings,
|
||||
search_console: { ...(domainSettings.search_console as DomainSearchConsole), property_type: updated[0] || 'domain' },
|
||||
})}
|
||||
multiple={false}
|
||||
rounded={'rounded'}
|
||||
/>
|
||||
</div>
|
||||
{domainSettings?.search_console?.property_type === 'url' && (
|
||||
<div className="mb-4 flex justify-between items-center w-full">
|
||||
<InputField
|
||||
label='Property URL (Required)'
|
||||
onChange={(url:string) => setDomainSettings({
|
||||
...domainSettings,
|
||||
search_console: { ...(domainSettings.search_console as DomainSearchConsole), url },
|
||||
})}
|
||||
value={domainSettings?.search_console?.url || ''}
|
||||
placeholder='Search Console Property URL. eg: https://mywebsite.com/'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex justify-between items-center w-full">
|
||||
<InputField
|
||||
label='Search Console Client Email'
|
||||
onChange={(client_email:string) => setDomainSettings({
|
||||
...domainSettings,
|
||||
search_console: { ...(domainSettings.search_console as DomainSearchConsole), client_email },
|
||||
})}
|
||||
value={domainSettings?.search_console?.client_email || ''}
|
||||
placeholder='myapp@appspot.gserviceaccount.com'
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-col justify-between items-center w-full">
|
||||
<label className='mb-2 font-semibold block text-sm text-gray-700 capitalize w-full'>Search Console Private Key</label>
|
||||
<textarea
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
|
||||
focus:outline-none h-[100px] focus:border-blue-200`}
|
||||
value={domainSettings?.search_console?.private_key || ''}
|
||||
placeholder={'-----BEGIN PRIVATE KEY-----/ssssaswdkihad....'}
|
||||
onChange={(event) => setDomainSettings({
|
||||
...domainSettings,
|
||||
search_console: { ...(domainSettings.search_console as DomainSearchConsole), private_key: event.target.value },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isUpdating && (domainUpdateError as Error)?.message && (
|
||||
<div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{(domainUpdateError as Error).message}</div>
|
||||
)}
|
||||
{!isUpdating && settingsError?.msg && (
|
||||
<div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{settingsError.msg}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t-[1px] border-gray-100 mt-8 pt-4 pb-0">
|
||||
<button
|
||||
className="text-sm font-semibold text-red-500"
|
||||
@@ -83,9 +163,9 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
<Icon type="trash" /> Remove Domain
|
||||
</button>
|
||||
<button
|
||||
className='text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white'
|
||||
onClick={() => updateDomain()}>
|
||||
Update Settings
|
||||
className={`text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white ${isUpdating ? 'cursor-not-allowed' : ''}`}
|
||||
onClick={() => !isUpdating && updateDomain()}>
|
||||
{isUpdating && <Icon type='loading' />} Update Settings
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
139
components/ideas/IdeaDetails.tsx
Normal file
139
components/ideas/IdeaDetails.tsx
Normal 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 "{keyword.keyword}"</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;
|
||||
135
components/ideas/IdeasFilter.tsx
Normal file
135
components/ideas/IdeasFilter.tsx
Normal 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;
|
||||
77
components/ideas/KeywordIdea.tsx
Normal file
77
components/ideas/KeywordIdea.tsx
Normal 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;
|
||||
247
components/ideas/KeywordIdeasTable.tsx
Normal file
247
components/ideas/KeywordIdeasTable.tsx
Normal 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;
|
||||
139
components/ideas/KeywordIdeasUpdater.tsx
Normal file
139
components/ideas/KeywordIdeasUpdater.tsx
Normal 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;
|
||||
@@ -51,7 +51,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true, doma
|
||||
|
||||
return (
|
||||
<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
|
||||
text-sm text-gray-500 font-semibold border-b-[1px] lg:border-0 lg:flex-row'>
|
||||
<div>
|
||||
@@ -117,7 +117,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true, doma
|
||||
)}
|
||||
{!isConsoleIntegrated && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import countries from '../../utils/countries';
|
||||
import Icon from '../common/Icon';
|
||||
import { formattedNum } from '../../utils/client/helpers';
|
||||
|
||||
type InsightItemProps = {
|
||||
item: SCInsightItem,
|
||||
@@ -16,7 +17,6 @@ const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
|
||||
firstItem = date && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(date));
|
||||
}
|
||||
if (type === 'countries') { firstItem = countries[country] && countries[country][0]; }
|
||||
const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -35,7 +35,6 @@ const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
|
||||
{Math.round(position)}
|
||||
</div>
|
||||
|
||||
{/* <div className='keyword_imp text-center inline-block lg:flex-1'>{formattedNum(clicks)}</div> */}
|
||||
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-14 p-2 text-base mt-[-55px] rounded right-5 lg:relative
|
||||
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
|
||||
{formattedNum(clicks)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { formattedNum } from '../../utils/client/helpers';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
||||
|
||||
@@ -12,22 +13,20 @@ type InsightStatsProps = {
|
||||
}
|
||||
|
||||
const InsightStats = ({ stats = [], totalKeywords = 0, totalPages = 0 }:InsightStatsProps) => {
|
||||
const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);
|
||||
const [totalStat, setTotalStat] = useState({ impressions: 0, clicks: 0, ctr: 0, position: 0 });
|
||||
const totalStat = useMemo(() => {
|
||||
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(() => {
|
||||
if (stats.length > 0) {
|
||||
const totalStats = stats.reduce((acc, item) => {
|
||||
return {
|
||||
impressions: item.impressions + acc.impressions,
|
||||
clicks: item.clicks + acc.clicks,
|
||||
ctr: item.ctr + acc.ctr,
|
||||
position: item.position + acc.position,
|
||||
};
|
||||
}, { impressions: 0, clicks: 0, ctr: 0, position: 0 });
|
||||
setTotalStat(totalStats);
|
||||
}
|
||||
}, [stats]);
|
||||
return {
|
||||
...totals,
|
||||
ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0,
|
||||
};
|
||||
}, [stats]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import Modal from '../common/Modal';
|
||||
import SelectField from '../common/SelectField';
|
||||
@@ -7,6 +7,8 @@ import { useAddKeywords } from '../../services/keywords';
|
||||
|
||||
type AddKeywordsProps = {
|
||||
keywords: KeywordType[],
|
||||
scraperName: string,
|
||||
allowsCity: boolean,
|
||||
closeModal: Function,
|
||||
domain: string
|
||||
}
|
||||
@@ -17,27 +19,62 @@ type KeywordsInput = {
|
||||
country: string,
|
||||
domain: string,
|
||||
tags: string,
|
||||
city?:string,
|
||||
}
|
||||
|
||||
const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
|
||||
const [error, setError] = useState<string>('');
|
||||
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 [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 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 = () => {
|
||||
if (newKeywordsData.keywords) {
|
||||
const keywordsArray = [...new Set(newKeywordsData.keywords.split('\n').map((item) => item.trim()).filter((item) => !!item))];
|
||||
const currentKeywords = keywords.map((k) => `${k.keyword}-${k.device}-${k.country}`);
|
||||
const keywordExist = keywordsArray.filter((k) => currentKeywords.includes(`${k}-${newKeywordsData.device}-${newKeywordsData.country}`));
|
||||
if (keywordExist.length > 0) {
|
||||
const nkwrds = newKeywordsData;
|
||||
if (nkwrds.keywords) {
|
||||
const devices = nkwrds.device.split(',');
|
||||
const multiDevice = nkwrds.device.includes(',') && devices.length > 1;
|
||||
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`);
|
||||
setTimeout(() => { setError(''); }, 3000);
|
||||
} else {
|
||||
const { device, country, domain: kDomain, tags } = newKeywordsData;
|
||||
const newKeywordsArray = keywordsArray.map((nItem) => ({ keyword: nItem, device, country, domain: kDomain, tags }));
|
||||
addMutate(newKeywordsArray);
|
||||
const newKeywords = keywordsArray.flatMap((k) =>
|
||||
devices.filter((device) =>
|
||||
!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 {
|
||||
setError('Please Insert a Keyword');
|
||||
@@ -45,6 +82,8 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const deviceTabStyle = 'cursor-pointer px-2 py-2 rounded';
|
||||
|
||||
return (
|
||||
<Modal closeModal={() => { closeModal(false); }} title={'Add New Keywords'} width="[420px]">
|
||||
<div data-testid="addkeywords_modal">
|
||||
@@ -76,25 +115,63 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
|
||||
</div>
|
||||
<ul className='flex text-xs font-semibold text-gray-500'>
|
||||
<li
|
||||
className={`${deviceTabStyle} ${newKeywordsData.device === 'desktop' ? ' bg-indigo-50 text-gray-700' : ''}`}
|
||||
onClick={() => setNewKeywordsData({ ...newKeywordsData, device: 'desktop' })}
|
||||
><Icon type='desktop' classes={'top-[3px]'} size={15} /> <i className='not-italic hidden lg:inline-block'>Desktop</i></li>
|
||||
className={`${deviceTabStyle} mr-2 ${newKeywordsData.device.includes('desktop') ? ' bg-indigo-50 text-indigo-700' : ''}`}
|
||||
onClick={() => setDeviceType('desktop')}>
|
||||
<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
|
||||
className={`${deviceTabStyle} ${newKeywordsData.device === 'mobile' ? ' bg-indigo-50 text-gray-700' : ''}`}
|
||||
onClick={() => setNewKeywordsData({ ...newKeywordsData, device: 'mobile' })}
|
||||
><Icon type='mobile' /> <i className='not-italic hidden lg:inline-block'>Mobile</i></li>
|
||||
className={`${deviceTabStyle} ${newKeywordsData.device.includes('mobile') ? ' bg-indigo-50 text-indigo-700' : ''}`}
|
||||
onClick={() => setDeviceType('mobile')}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className='relative'>
|
||||
{/* TODO: Insert Existing Tags as Suggestions */}
|
||||
<input
|
||||
className='w-full border rounded border-gray-200 py-2 px-4 pl-8 outline-none focus:border-indigo-300'
|
||||
placeholder='Insert Tags'
|
||||
className='w-full border rounded border-gray-200 py-2 px-4 pl-12 outline-none focus:border-indigo-300'
|
||||
placeholder='Insert Tags (Optional)'
|
||||
value={newKeywordsData.tags}
|
||||
onChange={(e) => setNewKeywordsData({ ...newKeywordsData, tags: e.target.value })}
|
||||
/>
|
||||
<span className='absolute text-gray-400 top-2 left-2'><Icon type="tags" size={16} /></span>
|
||||
<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>
|
||||
{error && <div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{error}</div>}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useUpdateKeywordTags } from '../../services/keywords';
|
||||
import Icon from '../common/Icon';
|
||||
import Modal from '../common/Modal';
|
||||
|
||||
type AddTagsProps = {
|
||||
keywords: KeywordType[],
|
||||
existingTags: string[],
|
||||
closeModal: Function
|
||||
}
|
||||
|
||||
const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => {
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const AddTags = ({ keywords = [], existingTags = [], closeModal }: AddTagsProps) => {
|
||||
const [tagInput, setTagInput] = useState(() => (keywords.length === 1 ? keywords[0].tags.join(', ') : ''));
|
||||
const [inputError, setInputError] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); });
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const addTag = () => {
|
||||
if (keywords.length === 0) { return; }
|
||||
if (!tagInput) {
|
||||
if (!tagInput && keywords.length > 1) {
|
||||
setInputError('Please Insert a Tag!');
|
||||
setTimeout(() => { setInputError(''); }, 3000);
|
||||
return;
|
||||
@@ -24,7 +27,7 @@ const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => {
|
||||
const tagsArray = tagInput.split(',').map((t) => t.trim());
|
||||
const tagsPayload:any = {};
|
||||
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 });
|
||||
};
|
||||
@@ -33,9 +36,13 @@ const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => {
|
||||
<Modal closeModal={() => { closeModal(false); }} title={`Add New Tags to ${keywords.length} Selected Keyword`}>
|
||||
<div className="relative">
|
||||
{inputError && <span className="absolute top-[-24px] text-red-400 text-sm font-semibold">{inputError}</span>}
|
||||
<span className='absolute text-gray-400 top-3 left-2'><Icon type="tags" size={16} /></span>
|
||||
<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
|
||||
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'
|
||||
value={tagInput}
|
||||
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
|
||||
className=" absolute right-2 top-2 cursor-pointer rounded p-2 px-4 bg-indigo-600 text-white font-semibold text-sm"
|
||||
onClick={addTag}>
|
||||
|
||||
@@ -4,7 +4,9 @@ import dayjs from 'dayjs';
|
||||
import Icon from '../common/Icon';
|
||||
import countries from '../../utils/countries';
|
||||
import ChartSlim from '../common/ChartSlim';
|
||||
import { generateTheChartData } from '../common/generateChartData';
|
||||
import KeywordPosition from './KeywordPosition';
|
||||
import { generateTheChartData } from '../../utils/client/generateChartData';
|
||||
import { formattedNum } from '../../utils/client/helpers';
|
||||
|
||||
type KeywordProps = {
|
||||
keywordData: KeywordType,
|
||||
@@ -19,7 +21,9 @@ type KeywordProps = {
|
||||
lastItem?:boolean,
|
||||
showSCData: boolean,
|
||||
scDataType: string,
|
||||
style: Object
|
||||
style: Object,
|
||||
maxTitleColumnWidth: number,
|
||||
tableColumns? : string[]
|
||||
}
|
||||
|
||||
const Keyword = (props: KeywordProps) => {
|
||||
@@ -37,12 +41,16 @@ const Keyword = (props: KeywordProps) => {
|
||||
style,
|
||||
index,
|
||||
scDataType = 'threeDays',
|
||||
tableColumns = [],
|
||||
maxTitleColumnWidth,
|
||||
} = props;
|
||||
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;
|
||||
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [showPositionError, setPositionError] = useState(false);
|
||||
|
||||
const turncatedURL = useMemo(() => {
|
||||
return url.replace(`https://${domain}`, '').replace(`https://www.${domain}`, '').replace(`http://${domain}`, '');
|
||||
}, [url, domain]);
|
||||
@@ -71,7 +79,7 @@ const Keyword = (props: KeywordProps) => {
|
||||
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);
|
||||
.sort((a, b) => a.position - b.position).filter((el) => (el.position > 0));
|
||||
if (historyArray[0]) {
|
||||
bestPos = { ...historyArray[0] };
|
||||
}
|
||||
@@ -82,24 +90,14 @@ const Keyword = (props: KeywordProps) => {
|
||||
|
||||
const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
|
||||
|
||||
const renderPosition = (pos:number, type?:string) => {
|
||||
if (!updating && pos === 0) {
|
||||
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
|
||||
}
|
||||
if (updating && type !== 'sc') {
|
||||
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
|
||||
}
|
||||
return pos;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={keyword}
|
||||
key={keyword + ID}
|
||||
style={style}
|
||||
className={`keyword relative py-5 px-4 text-gray-600 border-b-[1px] border-gray-200 lg:py-4 lg:px-6 lg:border-0
|
||||
lg:flex lg:justify-between lg:items-center ${selected ? ' bg-indigo-50 keyword--selected' : ''} ${lastItem ? 'border-b-0' : ''}`}>
|
||||
|
||||
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold cursor-pointer'>
|
||||
<div className=' w-3/4 font-semibold cursor-pointer lg:flex-1 lg:shrink-0 lg:basis-28 lg:w-auto lg:flex lg:items-center'>
|
||||
<button
|
||||
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border
|
||||
${selected ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}`}
|
||||
@@ -108,9 +106,15 @@ const Keyword = (props: KeywordProps) => {
|
||||
<Icon type="check" size={10} />
|
||||
</button>
|
||||
<a
|
||||
className='py-2 hover:text-blue-600'
|
||||
onClick={() => showKeywordDetails()}>
|
||||
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />{keyword}
|
||||
style={{ maxWidth: `${maxTitleColumnWidth - 35}px` }}
|
||||
className={'py-2 hover:text-blue-600 lg:flex lg:items-center w-full'}
|
||||
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>
|
||||
{sticky && <button className='ml-2 relative top-[2px]' title='Favorite'><Icon type="star-filled" size={16} color="#fbd346" /></button>}
|
||||
{lastUpdateError && lastUpdateError.date
|
||||
@@ -123,7 +127,7 @@ const Keyword = (props: KeywordProps) => {
|
||||
<div
|
||||
className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative
|
||||
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-24 lg:grow-0 lg:right-0 text-center font-semibold`}>
|
||||
{renderPosition(position)}
|
||||
<KeywordPosition position={position} updating={updating} />
|
||||
{!updating && positionChange > 0 && <i className=' not-italic ml-1 text-xs text-[#5ed7c3]'>▲ {positionChange}</i>}
|
||||
{!updating && positionChange < 0 && <i className=' not-italic ml-1 text-xs text-red-300'>▼ {positionChange}</i>}
|
||||
</div>
|
||||
@@ -132,39 +136,52 @@ const Keyword = (props: KeywordProps) => {
|
||||
title={bestPosition && bestPosition.date
|
||||
? new Date(bestPosition.date).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' }) : ''
|
||||
}
|
||||
className={`keyword_best hidden bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative lg:block
|
||||
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-16 lg:grow-0 lg:right-0 text-center font-semibold`}>
|
||||
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 && (
|
||||
<div
|
||||
className='hidden basis-32 grow-0 cursor-pointer lg:block'
|
||||
className={`hidden basis-20 grow-0 cursor-pointer lg:block ${!tableColumns.includes('History') ? 'lg:hidden' : ''}`}
|
||||
onClick={() => showKeywordDetails()}>
|
||||
<ChartSlim labels={chartData.labels} sreies={chartData.sreies} />
|
||||
</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
|
||||
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'>
|
||||
<Icon type="link-alt" size={14} color="#999" /></span>{turncatedURL || '-'}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='inline-block mt-[4] top-[-5px] relative lg:flex-1 lg:m-0'>
|
||||
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>
|
||||
<TimeAgo title={dayjs(lastUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={lastUpdated} />
|
||||
</div>
|
||||
|
||||
{showSCData && (
|
||||
<div className='keyword_sc_data min-w-[170px] text-xs mt-4 pt-2 border-t border-gray-100 top-[6px]
|
||||
{showSCData && tableColumns.includes('Search Console') && (
|
||||
<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'>
|
||||
<span className='min-w-[40px]'>
|
||||
<span className='lg:hidden'>SC Position: </span>
|
||||
{renderPosition(keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0, 'sc')}
|
||||
<KeywordPosition
|
||||
position={keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0}
|
||||
type='sc'
|
||||
/>
|
||||
</span>
|
||||
<span className='min-w-[40px]'>
|
||||
<span className='lg:hidden'>Impressions: </span>{keywordData?.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import Icon from '../common/Icon';
|
||||
import countries from '../../utils/countries';
|
||||
import Chart from '../common/Chart';
|
||||
import SelectField from '../common/SelectField';
|
||||
import { generateTheChartData } from '../common/generateChartData';
|
||||
import { useFetchSingleKeyword } from '../../services/keywords';
|
||||
import useOnKey from '../../hooks/useOnKey';
|
||||
import { generateTheChartData } from '../../utils/client/generateChartData';
|
||||
|
||||
type KeywordDetailsProps = {
|
||||
keyword: KeywordType,
|
||||
@@ -13,11 +15,12 @@ type KeywordDetailsProps = {
|
||||
|
||||
const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
|
||||
const updatedDate = new Date(keyword.lastUpdated);
|
||||
const [keywordHistory, setKeywordHistory] = useState<KeywordHistory>(keyword.history);
|
||||
const [keywordSearchResult, setKeywordSearchResult] = useState<KeywordLastResult[]>([]);
|
||||
const [chartTime, setChartTime] = useState<string>('30');
|
||||
const searchResultContainer = useRef<HTMLDivElement>(null);
|
||||
const searchResultFound = useRef<HTMLDivElement>(null);
|
||||
const { data: keywordData } = useFetchSingleKeyword(keyword.ID);
|
||||
const keywordHistory: KeywordHistory = keywordData?.history || keyword.history;
|
||||
const keywordSearchResult: KeywordLastResult = keywordData?.searchResult || keyword.history;
|
||||
const dateOptions = [
|
||||
{ label: 'Last 7 Days', value: '7' },
|
||||
{ label: 'Last 30 Days', value: '30' },
|
||||
@@ -26,39 +29,9 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
|
||||
{ label: 'All Time', value: 'all' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFullKeyword = async () => {
|
||||
try {
|
||||
const fetchURL = `${window.location.origin}/api/keyword?id=${keyword.ID}`;
|
||||
const res = await fetch(fetchURL, { method: 'GET' }).then((result) => result.json());
|
||||
if (res.keyword) {
|
||||
console.log(res.keyword, new Date().getTime());
|
||||
setKeywordHistory(res.keyword.history || []);
|
||||
setKeywordSearchResult(res.keyword.lastResult || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
if (keyword.lastResult.length === 0) {
|
||||
fetchFullKeyword();
|
||||
}
|
||||
}, [keyword]);
|
||||
useOnKey('Escape', closeDetails);
|
||||
|
||||
useEffect(() => {
|
||||
const closeModalonEsc = (event:KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
console.log(event.key);
|
||||
closeDetails();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', closeModalonEsc, false);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', closeModalonEsc, false);
|
||||
};
|
||||
}, [closeDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (keyword.position < 100 && keyword.position > 0 && searchResultFound?.current) {
|
||||
searchResultFound.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
@@ -81,11 +54,14 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
|
||||
return (
|
||||
<div className="keywordDetails fixed w-full h-screen top-0 left-0 z-[99999]" onClick={closeOnBGClick} data-testid="keywordDetails">
|
||||
<div className="keywordDetails absolute w-full lg:w-5/12 bg-white customShadow top-0 right-0 h-screen" >
|
||||
<div className='keywordDetails__header p-6 border-b border-b-slate-200 text-slate-500'>
|
||||
<div className='keywordDetails__header p-6 border-b border-b-slate-200 text-slate-600'>
|
||||
<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'>{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>
|
||||
<button
|
||||
className='absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import SelectField, { SelectionOption } from '../common/SelectField';
|
||||
import countries from '../../utils/countries';
|
||||
@@ -15,11 +15,8 @@ type KeywordFilterProps = {
|
||||
integratedConsole?: boolean,
|
||||
isConsole?: boolean,
|
||||
SCcountries?: string[];
|
||||
}
|
||||
|
||||
type KeywordCountState = {
|
||||
desktop: number,
|
||||
mobile: number
|
||||
updateColumns?: Function,
|
||||
tableColumns?: string[]
|
||||
}
|
||||
|
||||
const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
@@ -28,28 +25,32 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
setDevice,
|
||||
filterKeywords,
|
||||
allTags = [],
|
||||
keywords,
|
||||
keywords = [],
|
||||
updateSort,
|
||||
sortBy,
|
||||
filterParams,
|
||||
isConsole = false,
|
||||
integratedConsole = false,
|
||||
updateColumns,
|
||||
SCcountries = [],
|
||||
tableColumns = [],
|
||||
} = props;
|
||||
const [keywordCounts, setKeywordCounts] = useState<KeywordCountState>({ desktop: 0, mobile: 0 });
|
||||
const [sortOptions, showSortOptions] = useState(false);
|
||||
const [filterOptions, showFilterOptions] = useState(false);
|
||||
const [columnOptions, showColumnOptions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const keyWordCount = { desktop: 0, mobile: 0 };
|
||||
keywords.forEach((k) => {
|
||||
if (k.device === 'desktop') {
|
||||
keyWordCount.desktop += 1;
|
||||
} else {
|
||||
keyWordCount.mobile += 1;
|
||||
}
|
||||
});
|
||||
setKeywordCounts(keyWordCount);
|
||||
const keywordCounts = useMemo(() => {
|
||||
const counts = { desktop: 0, mobile: 0 };
|
||||
if (keywords && keywords.length > 0) {
|
||||
keywords.forEach((k) => {
|
||||
if (k.device === 'desktop') {
|
||||
counts.desktop += 1;
|
||||
} else {
|
||||
counts.mobile += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
return counts;
|
||||
}, [keywords]);
|
||||
|
||||
const filterCountry = (cntrs:string[]) => filterKeywords({ ...filterParams, countries: cntrs });
|
||||
@@ -64,14 +65,22 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
const countryOptions = useMemo(() => {
|
||||
const optionObject:{label:string, value:string}[] = [];
|
||||
|
||||
Object.keys(countries).forEach((countryISO:string) => {
|
||||
if (!isConsole || (isConsole && SCcountries.includes(countryISO))) {
|
||||
optionObject.push({ label: countries[countryISO][0], value: countryISO });
|
||||
}
|
||||
});
|
||||
if (!isConsole) {
|
||||
const allCountries = Array.from(keywords as KeywordType[])
|
||||
.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;
|
||||
}, [SCcountries, isConsole]);
|
||||
}, [SCcountries, isConsole, keywords]);
|
||||
|
||||
const sortOptionChoices: SelectionOption[] = [
|
||||
{ value: 'pos_asc', label: 'Top Position' },
|
||||
@@ -80,6 +89,19 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
{ value: 'date_desc', label: 'Oldest' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
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) {
|
||||
sortOptionChoices.push({ value: 'imp_desc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` });
|
||||
@@ -174,8 +196,8 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
{sortOptions && (
|
||||
<ul
|
||||
data-testid="sort_options"
|
||||
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg
|
||||
max-h-96 bg-white z-[9999] w-44 overflow-y-auto styled-scrollbar'>
|
||||
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'>
|
||||
{sortOptionChoices.map((sortOption) => {
|
||||
return <li
|
||||
key={sortOption.value}
|
||||
@@ -187,6 +209,43 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
</ul>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
19
components/keywords/KeywordPosition.tsx
Normal file
19
components/keywords/KeywordPosition.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Icon from '../common/Icon';
|
||||
|
||||
type KeywordPositionProps = {
|
||||
position: number,
|
||||
updating?: boolean,
|
||||
type?: string,
|
||||
}
|
||||
|
||||
const KeywordPosition = ({ position = 0, type = '', updating = false }:KeywordPositionProps) => {
|
||||
if (!updating && position === 0) {
|
||||
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
|
||||
}
|
||||
if (updating && type !== 'sc') {
|
||||
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
|
||||
}
|
||||
return <>{Math.round(position)}</>;
|
||||
};
|
||||
|
||||
export default KeywordPosition;
|
||||
@@ -10,7 +10,7 @@ type keywordTagManagerProps = {
|
||||
allTags: string[]
|
||||
}
|
||||
|
||||
const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
|
||||
const KeywordTagManager = ({ keyword, allTags = [], closeModal }: keywordTagManagerProps) => {
|
||||
const [showAddTag, setShowAddTag] = useState<boolean>(false);
|
||||
const { mutate: updateMutate } = useUpdateKeywordTags(() => { });
|
||||
|
||||
@@ -51,6 +51,7 @@ const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
|
||||
</div>
|
||||
{showAddTag && keyword && (
|
||||
<AddTags
|
||||
existingTags={allTags}
|
||||
keywords={[keyword]}
|
||||
closeModal={() => setShowAddTag(false)}
|
||||
/>
|
||||
|
||||
@@ -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 { CSSTransition } from 'react-transition-group';
|
||||
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
|
||||
import AddKeywords from './AddKeywords';
|
||||
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/sortFilter';
|
||||
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/client/sortFilter';
|
||||
import Icon from '../common/Icon';
|
||||
import Keyword from './Keyword';
|
||||
import KeywordDetails from './KeywordDetails';
|
||||
@@ -12,6 +10,10 @@ import Modal from '../common/Modal';
|
||||
import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords';
|
||||
import KeywordTagManager from './KeywordTagManager';
|
||||
import AddTags from './AddTags';
|
||||
import useWindowResize from '../../hooks/useWindowResize';
|
||||
import useIsMobile from '../../hooks/useIsMobile';
|
||||
import { useUpdateSettings } from '../../services/settings';
|
||||
import { defaultSettings } from '../settings/Settings';
|
||||
|
||||
type KeywordsTableProps = {
|
||||
domain: DomainType | null,
|
||||
@@ -20,10 +22,12 @@ type KeywordsTableProps = {
|
||||
showAddModal: boolean,
|
||||
setShowAddModal: Function,
|
||||
isConsoleIntegrated: boolean,
|
||||
settings?: SettingsType
|
||||
}
|
||||
|
||||
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 [device, setDevice] = useState<string>('desktop');
|
||||
const [selectedKeywords, setSelectedKeywords] = useState<number[]>([]);
|
||||
@@ -31,35 +35,42 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
|
||||
const [showTagManager, setShowTagManager] = useState<null|number>(null);
|
||||
const [showAddTags, setShowAddTags] = useState<boolean>(false);
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||
const [SCListHeight, setSCListHeight] = useState(500);
|
||||
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
|
||||
const [sortBy, setSortBy] = useState<string>('date_asc');
|
||||
const [scDataType, setScDataType] = useState<string>('threeDays');
|
||||
const [showScDataTypes, setShowScDataTypes] = useState<boolean>(false);
|
||||
const [maxTitleColumnWidth, setMaxTitleColumnWidth] = useState(235);
|
||||
const { mutate: deleteMutate } = useDeleteKeywords(() => {});
|
||||
const { mutate: favoriteMutate } = useFavKeywords(() => {});
|
||||
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} = {
|
||||
threeDays: 'Last Three Days',
|
||||
sevenDays: 'Last Seven Days',
|
||||
thirtyDays: 'Last Thirty Days',
|
||||
avgSevenDays: 'Last Three Days Avg',
|
||||
avgThreeDays: 'Last Seven Days Avg',
|
||||
avgThreeDays: 'Last Three Days Avg',
|
||||
avgSevenDays: 'Last Seven Days Avg',
|
||||
avgThirtyDays: 'Last Thirty Days Avg',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
|
||||
const resizeList = () => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
|
||||
resizeList();
|
||||
window.addEventListener('resize', resizeList);
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeList);
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
const processedKeywords: {[key:string] : KeywordType[]} = useMemo(() => {
|
||||
const procKeywords = keywords.filter((x) => x.device === device);
|
||||
const filteredKeywords = filterKeywords(procKeywords, filterParams);
|
||||
@@ -68,7 +79,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
}, [keywords, device, sortBy, filterParams, scDataType]);
|
||||
|
||||
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)];
|
||||
}, [keywords]);
|
||||
|
||||
@@ -80,6 +91,16 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
}
|
||||
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 keyword = data[index];
|
||||
return (
|
||||
@@ -98,6 +119,8 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
lastItem={index === (processedKeywords[device].length - 1)}
|
||||
showSCData={showSCData}
|
||||
scDataType={scDataType}
|
||||
tableColumns={tableColumns}
|
||||
maxTitleColumnWidth={maxTitleColumnWidth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -106,7 +129,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
|
||||
<ul className=''>
|
||||
@@ -145,15 +168,19 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
keywords={keywords}
|
||||
device={device}
|
||||
setDevice={setDevice}
|
||||
updateColumns={updateColumns}
|
||||
tableColumns={tableColumns}
|
||||
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]`}>
|
||||
<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-[4rem] 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 && (
|
||||
<button
|
||||
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border border-slate-300
|
||||
@@ -163,15 +190,20 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
<Icon type="check" size={10} />
|
||||
</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 className='domKeywords_head_position flex-1 basis-24 grow-0 text-center'>Position</span>
|
||||
<span className='domKeywords_head_best flex-1 basis-16 grow-0 text-center'>Best</span>
|
||||
<span className='domKeywords_head_history flex-1 basis-32 grow-0 '>History (7d)</span>
|
||||
<span className={`domKeywords_head_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_updated flex-1'>Updated</span>
|
||||
{showSCData && (
|
||||
<div className='domKeywords_head_sc flex-1 min-w-[170px] mr-7 text-center'>
|
||||
<span className='domKeywords_head_updated flex-1 relative left-3 max-w-[150px]'>Updated</span>
|
||||
{showSCData && tableColumns.includes('Search Console') && (
|
||||
<div className='domKeywords_head_sc flex-1 min-w-[170px] lg:max-w-[170px] mr-7 text-center'>
|
||||
{/* Search Console */}
|
||||
<div>
|
||||
<div
|
||||
@@ -250,13 +282,6 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
<CSSTransition in={showAddModal} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddKeywords
|
||||
domain={domain?.domain || ''}
|
||||
keywords={keywords}
|
||||
closeModal={() => setShowAddModal(false)}
|
||||
/>
|
||||
</CSSTransition>
|
||||
{showTagManager && (
|
||||
<KeywordTagManager
|
||||
allTags={allDomainTags}
|
||||
@@ -266,6 +291,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
)}
|
||||
{showAddTags && (
|
||||
<AddTags
|
||||
existingTags={allDomainTags}
|
||||
keywords={keywords.filter((k) => selectedKeywords.includes(k.ID))}
|
||||
closeModal={() => setShowAddTags(false)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import countries from '../../utils/countries';
|
||||
import KeywordPosition from './KeywordPosition';
|
||||
import { formattedNum } from '../../utils/client/helpers';
|
||||
|
||||
type SCKeywordProps = {
|
||||
keywordData: SearchAnalyticsItem,
|
||||
@@ -15,13 +17,6 @@ const SCKeyword = (props: SCKeywordProps) => {
|
||||
const { keywordData, selected, lastItem, selectKeyword, style, isTracked = false } = props;
|
||||
const { keyword, uid, position, country, impressions, ctr, clicks } = keywordData;
|
||||
|
||||
const renderPosition = () => {
|
||||
if (position === 0) {
|
||||
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
|
||||
}
|
||||
return Math.round(position);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={keyword}
|
||||
@@ -45,7 +40,7 @@ const SCKeyword = (props: SCKeywordProps) => {
|
||||
|
||||
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-15 p-2 text-base mt-[-20px] rounded right-5 lg:relative
|
||||
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
|
||||
{renderPosition()}
|
||||
<KeywordPosition position={position} />
|
||||
<span className='block text-xs text-gray-500 lg:hidden'>Position</span>
|
||||
</div>
|
||||
|
||||
@@ -53,14 +48,14 @@ const SCKeyword = (props: SCKeywordProps) => {
|
||||
<span className='mr-3 lg:hidden'>
|
||||
<Icon type="eye" size={14} color="#999" />
|
||||
</span>
|
||||
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(impressions)}
|
||||
{formattedNum(impressions)}
|
||||
</div>
|
||||
|
||||
<div className={'keyword_visits text-center inline-block mt-4 mr-5 ml-5 lg:flex-1 lg:m-0 max-w-[70px] lg:max-w-none lg:pr-5'}>
|
||||
<span className='mr-3 lg:hidden'>
|
||||
<Icon type="cursor" size={14} color="#999" />
|
||||
</span>
|
||||
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(clicks)}
|
||||
{formattedNum(clicks)}
|
||||
</div>
|
||||
|
||||
<div className='keyword_ctr text-center inline-block mt-4 relative lg:flex-1 lg:m-0 '>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
|
||||
import { useAddKeywords, useFetchKeywords } from '../../services/keywords';
|
||||
import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/SCsortFilter';
|
||||
import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/client/SCsortFilter';
|
||||
import Icon from '../common/Icon';
|
||||
import KeywordFilters from './KeywordFilter';
|
||||
import SCKeyword from './SCKeyword';
|
||||
import useWindowResize from '../../hooks/useWindowResize';
|
||||
import useIsMobile from '../../hooks/useIsMobile';
|
||||
import { formattedNum } from '../../utils/client/helpers';
|
||||
|
||||
type SCKeywordsTableProps = {
|
||||
domain: DomainType | null,
|
||||
@@ -27,11 +30,13 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
|
||||
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
|
||||
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
|
||||
const [sortBy, setSortBy] = useState<string>('imp_desc');
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||
const [SCListHeight, setSCListHeight] = useState(500);
|
||||
const { keywordsData } = useFetchKeywords(router);
|
||||
const { keywordsData } = useFetchKeywords(router, domain?.domain || '');
|
||||
const addedkeywords: string[] = keywordsData?.keywords?.map((key: KeywordType) => `${key.keyword}:${key.country}:${key.device}`) || [];
|
||||
const { mutate: addKeywords } = useAddKeywords(() => { if (domain && domain.slug) router.push(`/domain/${domain.slug}`); });
|
||||
const [isMobile] = useIsMobile();
|
||||
useWindowResize(() => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400)));
|
||||
|
||||
const finalKeywords: {[key:string] : SCKeywordType[] } = useMemo(() => {
|
||||
const procKeywords = keywords.filter((x) => x.device === device);
|
||||
const filteredKeywords = SCfilterKeywords(procKeywords, filterParams);
|
||||
@@ -71,16 +76,6 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
|
||||
};
|
||||
}, [finalKeywords, device]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
|
||||
const resizeList = () => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
|
||||
resizeList();
|
||||
window.addEventListener('resize', resizeList);
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeList);
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
const selectKeyword = (keywordID: string) => {
|
||||
console.log('Select Keyword: ', keywordID);
|
||||
let updatedSelectd = [...selectedKeywords, keywordID];
|
||||
@@ -121,7 +116,7 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
|
||||
<ul className=''>
|
||||
@@ -194,10 +189,10 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
|
||||
</span>
|
||||
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>{viewSummary.position}</span>
|
||||
<span className='domKeywords_head_imp flex-1 text-center'>
|
||||
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.impressions)}
|
||||
{formattedNum(viewSummary.impressions)}
|
||||
</span>
|
||||
<span className='domKeywords_head_visits flex-1 text-center'>
|
||||
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.visits)}
|
||||
{formattedNum(viewSummary.visits)}
|
||||
</span>
|
||||
<span className='domKeywords_head_ctr flex-1 text-center'>
|
||||
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(viewSummary.ctr)}%
|
||||
@@ -214,7 +209,7 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
|
||||
)}
|
||||
{!isConsoleIntegrated && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
146
components/settings/AdWordsSettings.tsx
Normal file
146
components/settings/AdWordsSettings.tsx
Normal 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;
|
||||
80
components/settings/Changelog.tsx
Normal file
80
components/settings/Changelog.tsx
Normal 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;
|
||||
53
components/settings/IntegrationSettings.tsx
Normal file
53
components/settings/IntegrationSettings.tsx
Normal 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;
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import SelectField from '../common/SelectField';
|
||||
import SecretField from '../common/SecretField';
|
||||
import InputField from '../common/InputField';
|
||||
|
||||
type NotificationSettingsProps = {
|
||||
settings: SettingsType,
|
||||
@@ -17,8 +19,8 @@ const NotificationSettings = ({ settings, settingsError, updateSettings }:Notifi
|
||||
<div>
|
||||
<div className='settings__content styled-scrollbar p-6 text-sm'>
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>Notification Frequency</label>
|
||||
<SelectField
|
||||
label='Notification Frequency'
|
||||
multiple={false}
|
||||
selected={[settings.notification_interval]}
|
||||
options={[
|
||||
@@ -31,70 +33,70 @@ const NotificationSettings = ({ settings, settingsError, updateSettings }:Notifi
|
||||
updateField={(updated:string[]) => updated[0] && updateSettings('notification_interval', updated[0])}
|
||||
rounded='rounded'
|
||||
maxHeight={48}
|
||||
minWidth={270}
|
||||
minWidth={220}
|
||||
/>
|
||||
</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?.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)}
|
||||
<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">
|
||||
<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?.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)}
|
||||
<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">
|
||||
<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)}
|
||||
<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">
|
||||
<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"
|
||||
<InputField
|
||||
label='SMTP Username'
|
||||
hasError={settingsError?.type === 'no_smtp_port'}
|
||||
value={settings?.smtp_username || ''}
|
||||
onChange={(event) => updateSettings('smtp_username', event.target.value)}
|
||||
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">
|
||||
<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?.type === 'no_smtp_from' ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type="text"
|
||||
<InputField
|
||||
label='From Email Address'
|
||||
hasError={settingsError?.type === 'no_smtp_from'}
|
||||
value={settings?.notification_email_from || ''}
|
||||
placeholder="no-reply@mydomain.com"
|
||||
onChange={(event) => updateSettings('notification_email_from', event.target.value)}
|
||||
/>
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,8 @@ 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,
|
||||
@@ -43,27 +45,25 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
<div className='settings__content styled-scrollbar p-6 text-sm'>
|
||||
|
||||
<div className="settings__section__select mb-5">
|
||||
<label className={labelStyle}>Scraping Method</label>
|
||||
<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={270}
|
||||
minWidth={220}
|
||||
/>
|
||||
</div>
|
||||
{['scrapingant', 'scrapingrobot', 'serply', 'serpapi', 'spaceSerp', 'searchapi'].includes(settings.scraper_type) && (
|
||||
<div className="settings__section__input mr-3">
|
||||
<label className={labelStyle}>Scraper API Key or Token</label>
|
||||
<input
|
||||
className={`w-full p-2 border border-gray-200 rounded mt-2 mb-3 focus:outline-none focus:border-blue-200
|
||||
${settingsError?.type === 'no_api_key' ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type="text"
|
||||
value={settings?.scaping_api || ''}
|
||||
placeholder={'API Key/Token'}
|
||||
onChange={(event) => updateSettings('scaping_api', event.target.value)}
|
||||
{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>
|
||||
)}
|
||||
@@ -82,8 +82,8 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
)}
|
||||
{settings.scraper_type !== 'none' && (
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>Scraping Frequency</label>
|
||||
<SelectField
|
||||
label='Scraping Frequency'
|
||||
multiple={false}
|
||||
selected={[settings?.scrape_interval || 'daily']}
|
||||
options={scrapingOptions}
|
||||
@@ -91,14 +91,14 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_interval', updated[0])}
|
||||
rounded='rounded'
|
||||
maxHeight={48}
|
||||
minWidth={270}
|
||||
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">
|
||||
<label className={labelStyle}>Delay Between Each keyword Scrape</label>
|
||||
<SelectField
|
||||
label='keyword Scrape Delay'
|
||||
multiple={false}
|
||||
selected={[settings?.scrape_delay || '0']}
|
||||
options={delayOptions}
|
||||
@@ -106,28 +106,16 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_delay', updated[0])}
|
||||
rounded='rounded'
|
||||
maxHeight={48}
|
||||
minWidth={270}
|
||||
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">
|
||||
<label className="relative inline-flex items-center cursor-pointer w-full justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-300 w-56">Auto Retry Failed Keyword Scrape</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={settings?.scrape_retry ? 'true' : '' }
|
||||
checked={settings.scrape_retry || false}
|
||||
className="sr-only peer"
|
||||
onChange={() => updateSettings('scrape_retry', !settings.scrape_retry)}
|
||||
/>
|
||||
<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>
|
||||
<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">
|
||||
|
||||
48
components/settings/SearchConsoleSettings.tsx
Normal file
48
components/settings/SearchConsoleSettings.tsx
Normal 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;
|
||||
@@ -4,6 +4,8 @@ import { useFetchSettings, useUpdateSettings } from '../../services/settings';
|
||||
import Icon from '../common/Icon';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import ScraperSettings from './ScraperSettings';
|
||||
import useOnKey from '../../hooks/useOnKey';
|
||||
import IntegrationSettings from './IntegrationSettings';
|
||||
|
||||
type SettingsProps = {
|
||||
closeSettings: Function,
|
||||
@@ -15,7 +17,7 @@ type SettingsError = {
|
||||
msg: string
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
export const defaultSettings: SettingsType = {
|
||||
scraper_type: 'none',
|
||||
scrape_delay: 'none',
|
||||
scrape_retry: false,
|
||||
@@ -26,6 +28,11 @@ const defaultSettings = {
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
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) => {
|
||||
@@ -34,6 +41,7 @@ const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
const [settingsError, setSettingsError] = useState<SettingsError|null>(null);
|
||||
const { mutate: updateMutate, isLoading: isUpdating } = useUpdateSettings(() => console.log(''));
|
||||
const { data: appSettings, isLoading } = useFetchSettings();
|
||||
useOnKey('Escape', closeSettings);
|
||||
|
||||
useEffect(() => {
|
||||
if (appSettings && appSettings.settings) {
|
||||
@@ -41,19 +49,6 @@ const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
}
|
||||
}, [appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const closeModalonEsc = (event:KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
console.log(event.key);
|
||||
closeSettings();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', closeModalonEsc, false);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', closeModalonEsc, false);
|
||||
};
|
||||
}, [closeSettings]);
|
||||
|
||||
const closeOnBGClick = (e:React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
@@ -89,33 +84,45 @@ const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
} else {
|
||||
// Perform Update
|
||||
updateMutate(settings);
|
||||
// If Scraper is updated, refresh the page.
|
||||
if (appSettings.settings === 'none' && scraper_type !== 'none') {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm';
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className="settings fixed w-full h-screen top-0 left-0 z-50" onClick={closeOnBGClick}>
|
||||
<div className="absolute w-full max-w-xs bg-white customShadow top-0 right-0 h-screen" data-loading={isLoading} >
|
||||
<div className="absolute w-full max-w-md bg-white customShadow top-0 right-0 h-screen" data-loading={isLoading} >
|
||||
{isLoading && <div className='absolute flex content-center items-center h-full'><Icon type="loading" size={24} /></div>}
|
||||
<div className='settings__header p-6 border-b border-b-slate-200 text-slate-500'>
|
||||
<div className='settings__header px-5 py-4 text-slate-500'>
|
||||
<h3 className=' text-black text-lg font-bold'>Settings</h3>
|
||||
<button
|
||||
className=' absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
|
||||
className=' absolute top-2 right-2 p-2 px- text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
|
||||
onClick={() => closeSettings()}>
|
||||
<Icon type='close' size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className=' px-4 mt-4 '>
|
||||
<div className='border border-slate-200 px-3 py-4 pb-0 border-l-0 border-r-0 bg-[#f8f9ff]'>
|
||||
<ul>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'scraper' ? ' bg-blue-50 text-blue-600' : ''}`}
|
||||
className={`${tabStyle} ${currentTab === 'scraper' ? tabStyleActive : 'border-transparent '}`}
|
||||
onClick={() => setCurrentTab('scraper')}>
|
||||
Scraper
|
||||
<Icon type='scraper' /> Scraper
|
||||
</li>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-blue-50 text-blue-600' : ''}`}
|
||||
className={`${tabStyle} ${currentTab === 'notification' ? tabStyleActive : 'border-transparent'}`}
|
||||
onClick={() => setCurrentTab('notification')}>
|
||||
Notification
|
||||
<Icon type='email' /> Notification
|
||||
</li>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'integrations' ? tabStyleActive : 'border-transparent'}`}
|
||||
onClick={() => setCurrentTab('integrations')}>
|
||||
<Icon type='integration' size={14} /> Integrations
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -126,6 +133,15 @@ const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
{currentTab === 'notification' && settings && (
|
||||
<NotificationSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
|
||||
)}
|
||||
{currentTab === 'integrations' && settings && (
|
||||
<IntegrationSettings
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
settingsError={settingsError}
|
||||
performUpdate={performUpdate}
|
||||
closeSettings={closeSettings}
|
||||
/>
|
||||
)}
|
||||
<div className=' border-t-[1px] border-gray-200 p-2 px-3'>
|
||||
<button
|
||||
onClick={() => performUpdate()}
|
||||
|
||||
11
cron.js
11
cron.js
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable no-new */
|
||||
const Cryptr = require('cryptr');
|
||||
const { promises } = require('fs');
|
||||
const { readFile } = require('fs');
|
||||
const Cron = require('croner');
|
||||
const { Cron } = require('croner');
|
||||
require('dotenv').config({ path: './.env.local' });
|
||||
|
||||
const getAppSettings = async () => {
|
||||
@@ -71,7 +72,7 @@ const runAppCronJobs = () => {
|
||||
const scrape_interval = settings.scrape_interval || 'daily';
|
||||
if (scrape_interval !== 'never') {
|
||||
const scrapeCronTime = generateCronTime(scrape_interval);
|
||||
Cron(scrapeCronTime, () => {
|
||||
new Cron(scrapeCronTime, () => {
|
||||
// console.log('### Running Keyword Position Cron Job!');
|
||||
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
|
||||
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/cron`, fetchOpts)
|
||||
@@ -89,7 +90,7 @@ const runAppCronJobs = () => {
|
||||
if (notif_interval) {
|
||||
const cronTime = generateCronTime(notif_interval === 'daily' ? 'daily_morning' : notif_interval);
|
||||
if (cronTime) {
|
||||
Cron(cronTime, () => {
|
||||
new Cron(cronTime, () => {
|
||||
// console.log('### Sending Notification Email...');
|
||||
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
|
||||
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/notify`, fetchOpts)
|
||||
@@ -106,7 +107,7 @@ const runAppCronJobs = () => {
|
||||
|
||||
// Run Failed scraping CRON (Every Hour)
|
||||
const failedCronTime = generateCronTime('hourly');
|
||||
Cron(failedCronTime, () => {
|
||||
new Cron(failedCronTime, () => {
|
||||
// console.log('### Retrying Failed Scrapes...');
|
||||
|
||||
readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => {
|
||||
@@ -135,7 +136,7 @@ const runAppCronJobs = () => {
|
||||
// Run Google Search Console Scraper Daily
|
||||
if (process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) {
|
||||
const searchConsoleCRONTime = generateCronTime('daily');
|
||||
Cron(searchConsoleCRONTime, () => {
|
||||
new Cron(searchConsoleCRONTime, () => {
|
||||
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
|
||||
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/searchconsole`, fetchOpts)
|
||||
.then((res) => res.json())
|
||||
|
||||
14
database/config.js
Normal file
14
database/config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
45
database/migrations/1707068556345-add-new-keyword-fields.js
Normal file
45
database/migrations/1707068556345-add-new-keyword-fields.js
Normal 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);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -38,6 +38,9 @@ class Domain extends Model {
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: '' })
|
||||
notification_emails!: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
search_console!: string;
|
||||
}
|
||||
|
||||
export default Domain;
|
||||
|
||||
@@ -19,7 +19,13 @@ class Keyword extends Model {
|
||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'US' })
|
||||
country!: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: false })
|
||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: '' })
|
||||
city!: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: '' })
|
||||
latlong!: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: false, defaultValue: '{}' })
|
||||
domain!: string;
|
||||
|
||||
// @ForeignKey(() => Domain)
|
||||
@@ -41,6 +47,9 @@ class Keyword extends Model {
|
||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
|
||||
history!: string;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0 })
|
||||
volume!: number;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
|
||||
url!: string;
|
||||
|
||||
@@ -58,6 +67,9 @@ class Keyword extends Model {
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: 'false' })
|
||||
lastUpdateError!: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
settings!: string;
|
||||
}
|
||||
|
||||
export default Keyword;
|
||||
|
||||
@@ -442,6 +442,7 @@
|
||||
<tr align="left">
|
||||
<th>Keyword</th>
|
||||
<th>Position</th>
|
||||
<th>Best</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
{{keywordsTable}}
|
||||
|
||||
3
entrypoint.sh
Normal file
3
entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
npx sequelize-cli db:migrate --env production
|
||||
exec "$@"
|
||||
12
hooks/useIsMobile.tsx
Normal file
12
hooks/useIsMobile.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const useIsMobile = () => {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
|
||||
}, []);
|
||||
|
||||
return [isMobile];
|
||||
};
|
||||
|
||||
export default useIsMobile;
|
||||
17
hooks/useOnKey.tsx
Normal file
17
hooks/useOnKey.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useOnKey = (key:string, onPress: Function) => {
|
||||
useEffect(() => {
|
||||
const closeModalonEsc = (event:KeyboardEvent) => {
|
||||
if (event.key === key) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', closeModalonEsc, false);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', closeModalonEsc, false);
|
||||
};
|
||||
}, [key, onPress]);
|
||||
};
|
||||
|
||||
export default useOnKey;
|
||||
13
hooks/useWindowResize.tsx
Normal file
13
hooks/useWindowResize.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useWindowResize = (onResize: () => void) => {
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [onResize]);
|
||||
};
|
||||
|
||||
export default useWindowResize;
|
||||
4692
package-lock.json
generated
4692
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "serpbear",
|
||||
"version": "0.3.3",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -13,42 +13,47 @@
|
||||
"test": "jest --watch --verbose",
|
||||
"test:ci": "jest --ci",
|
||||
"test:cv": "jest --coverage --coverageDirectory='coverage'",
|
||||
"db:migrate": "sequelize-cli db:migrate --env production",
|
||||
"db:revert": "sequelize-cli db:migrate:undo --env production",
|
||||
"release": "standard-version"
|
||||
},
|
||||
"dependencies": {
|
||||
"@googleapis/searchconsole": "^1.0.0",
|
||||
"@googleapis/searchconsole": "^1.0.5",
|
||||
"@isaacs/ttlcache": "^1.4.1",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"axios": "^1.1.3",
|
||||
"axios": "^1.7.7",
|
||||
"axios-retry": "^3.3.1",
|
||||
"chart.js": "^3.9.1",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cheerio": "^1.0.0",
|
||||
"concurrently": "^7.6.0",
|
||||
"cookies": "^0.8.0",
|
||||
"croner": "^5.3.5",
|
||||
"croner": "^9.0.0",
|
||||
"cryptr": "^6.0.3",
|
||||
"dayjs": "^1.11.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"google-auth-library": "^9.6.3",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"msw": "^0.49.0",
|
||||
"next": "^12.3.4",
|
||||
"nodemailer": "^6.8.0",
|
||||
"nodemailer": "^6.9.9",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^4.3.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-query": "^3.39.2",
|
||||
"react-timeago": "^7.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-window": "^1.8.8",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sequelize": "^6.34.0",
|
||||
"sequelize-typescript": "^2.1.5",
|
||||
"sqlite3": "^5.1.6"
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"sqlite3": "^5.1.7",
|
||||
"umzug": "^3.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.1.4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/cookies": "^0.7.7",
|
||||
"@types/cryptr": "^4.0.1",
|
||||
@@ -57,8 +62,8 @@
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/node": "18.11.0",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
"@types/react": "18.0.21",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@types/react-timeago": "^4.1.3",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"autoprefixer": "^10.4.12",
|
||||
@@ -68,14 +73,16 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^2.6.4",
|
||||
"next-router-mock": "^0.9.10",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^2.7.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",
|
||||
"stylelint-config-standard": "^29.0.0",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"typescript": "4.8.4"
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
101
pages/api/adwords.ts
Normal file
101
pages/api/adwords.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
53
pages/api/dbmigrate.ts
Normal file
53
pages/api/dbmigrate.ts
Normal 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
48
pages/api/domain.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import Cryptr from 'cryptr';
|
||||
import db from '../../database/database';
|
||||
import Domain from '../../database/models/domain';
|
||||
import Keyword from '../../database/models/keyword';
|
||||
import getdomainStats from '../../utils/domains';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
import { checkSerchConsoleIntegration, removeLocalSCData } from '../../utils/searchConsole';
|
||||
|
||||
type DomainsGetRes = {
|
||||
domains: DomainType[]
|
||||
@@ -11,13 +13,14 @@ type DomainsGetRes = {
|
||||
}
|
||||
|
||||
type DomainsAddResponse = {
|
||||
domain: Domain|null,
|
||||
domains: DomainType[]|null,
|
||||
error?: string|null,
|
||||
}
|
||||
|
||||
type DomainsDeleteRes = {
|
||||
domainRemoved: number,
|
||||
keywordsRemoved: number,
|
||||
SCDataRemoved: boolean,
|
||||
error?: string|null,
|
||||
}
|
||||
|
||||
@@ -51,7 +54,13 @@ export const getDomains = async (req: NextApiRequest, res: NextApiResponse<Domai
|
||||
const withStats = !!req?.query?.withstats;
|
||||
try {
|
||||
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;
|
||||
return res.status(200).json({ domains: theDomains });
|
||||
} catch (error) {
|
||||
@@ -59,41 +68,45 @@ export const getDomains = async (req: NextApiRequest, res: NextApiResponse<Domai
|
||||
}
|
||||
};
|
||||
|
||||
export const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => {
|
||||
if (!req.body.domain) {
|
||||
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' });
|
||||
}
|
||||
const { domain } = req.body || {};
|
||||
const domainData = {
|
||||
domain: domain.trim(),
|
||||
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-'),
|
||||
lastUpdated: new Date().toJSON(),
|
||||
added: new Date().toJSON(),
|
||||
};
|
||||
const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => {
|
||||
const { domains } = req.body;
|
||||
if (domains && Array.isArray(domains) && domains.length > 0) {
|
||||
const domainsToAdd: any = [];
|
||||
|
||||
try {
|
||||
const addedDomain = await Domain.create(domainData);
|
||||
return res.status(201).json({ domain: addedDomain });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' });
|
||||
domains.forEach((domain: string) => {
|
||||
domainsToAdd.push({
|
||||
domain: domain.trim(),
|
||||
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-').replaceAll('/', '-'),
|
||||
lastUpdated: new Date().toJSON(),
|
||||
added: new Date().toJSON(),
|
||||
});
|
||||
});
|
||||
try {
|
||||
const newDomains:Domain[] = await Domain.bulkCreate(domainsToAdd);
|
||||
const formattedDomains = newDomains.map((el) => el.get({ plain: true }));
|
||||
return res.status(201).json({ domains: formattedDomains });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Adding New Domain ', error);
|
||||
return res.status(400).json({ domains: [], error: 'Error Adding Domain.' });
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({ domains: [], error: 'Necessary data missing.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsDeleteRes>) => {
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') {
|
||||
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Domain is Required!' });
|
||||
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Domain is Required!' });
|
||||
}
|
||||
try {
|
||||
const { domain } = req.query || {};
|
||||
const removedDomCount: number = await Domain.destroy({ where: { domain } });
|
||||
const removedKeywordCount: number = await Keyword.destroy({ where: { domain } });
|
||||
return res.status(200).json({
|
||||
domainRemoved: removedDomCount,
|
||||
keywordsRemoved: removedKeywordCount,
|
||||
});
|
||||
const SCDataRemoved = await removeLocalSCData(domain as string);
|
||||
return res.status(200).json({ domainRemoved: removedDomCount, keywordsRemoved: removedKeywordCount, SCDataRemoved });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Deleting Domain: ', req.query.domain, error);
|
||||
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Error Deleting Domain' });
|
||||
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Error Deleting Domain' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,17 +115,28 @@ export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
|
||||
return res.status(400).json({ domain: null, error: 'Domain is Required!' });
|
||||
}
|
||||
const { domain } = req.query || {};
|
||||
const { notification_interval, notification_emails } = req.body;
|
||||
const { notification_interval, notification_emails, search_console } = req.body as DomainSettings;
|
||||
|
||||
try {
|
||||
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } });
|
||||
// Validate Search Console API Data
|
||||
if (domainToUpdate && search_console?.client_email && search_console?.private_key) {
|
||||
const theDomainObj = domainToUpdate.get({ plain: true });
|
||||
const isSearchConsoleAPIValid = await checkSerchConsoleIntegration({ ...theDomainObj, search_console: JSON.stringify(search_console) });
|
||||
if (!isSearchConsoleAPIValid.isValid) {
|
||||
return res.status(400).json({ domain: null, error: isSearchConsoleAPIValid.error });
|
||||
}
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
search_console.client_email = search_console.client_email ? cryptr.encrypt(search_console.client_email.trim()) : '';
|
||||
search_console.private_key = search_console.private_key ? cryptr.encrypt(search_console.private_key.trim()) : '';
|
||||
}
|
||||
if (domainToUpdate) {
|
||||
domainToUpdate.set({ notification_interval, notification_emails });
|
||||
domainToUpdate.set({ notification_interval, notification_emails, search_console: JSON.stringify(search_console) });
|
||||
await domainToUpdate.save();
|
||||
}
|
||||
return res.status(200).json({ domain: domainToUpdate });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Updating Domain: ', req.query.domain, error);
|
||||
return res.status(400).json({ domain: null, error: 'Error Updating Domain' });
|
||||
return res.status(400).json({ domain: null, error: 'Error Updating Domain. An Unknown Error Occured.' });
|
||||
}
|
||||
};
|
||||
|
||||
111
pages/api/ideas.ts
Normal file
111
pages/api/ideas.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import db from '../../database/database';
|
||||
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 Domain from '../../database/models/domain';
|
||||
|
||||
type SCInsightRes = {
|
||||
data: InsightDataType | null,
|
||||
@@ -23,9 +24,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
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 (!!(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 getInsightFromSCData = (localSCData: SCDomainDataType): InsightDataType => {
|
||||
const { stats = [] } = localSCData;
|
||||
@@ -37,17 +35,26 @@ const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiRe
|
||||
|
||||
// First try and read the Local SC Domain Data file.
|
||||
const localSCData = await readLocalSCData(domainname);
|
||||
const oldFetchedDate = localSCData.lastFetched;
|
||||
const fetchTimeDiff = new Date().getTime() - (oldFetchedDate ? new Date(oldFetchedDate as string).getTime() : 0);
|
||||
|
||||
if (localSCData && localSCData.stats && localSCData.stats.length && fetchTimeDiff <= 86400000) {
|
||||
const response = getInsightFromSCData(localSCData);
|
||||
return res.status(200).json({ data: response });
|
||||
if (localSCData) {
|
||||
const oldFetchedDate = localSCData.lastFetched;
|
||||
const fetchTimeDiff = new Date().getTime() - (oldFetchedDate ? new Date(oldFetchedDate as string).getTime() : 0);
|
||||
if (localSCData.stats && localSCData.stats.length && fetchTimeDiff <= 86400000) {
|
||||
const response = getInsightFromSCData(localSCData);
|
||||
return res.status(200).json({ data: response });
|
||||
}
|
||||
}
|
||||
|
||||
// If the Local SC Domain Data file does not exist, fetch from Googel Search Console.
|
||||
try {
|
||||
const 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);
|
||||
return res.status(200).json({ data: response });
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import verifyUser from '../../utils/verifyUser';
|
||||
import parseKeywords from '../../utils/parseKeywords';
|
||||
import { integrateKeywordSCData, readLocalSCData } from '../../utils/searchConsole';
|
||||
import refreshAndUpdateKeywords from '../../utils/refresh';
|
||||
import { getKeywordsVolume, updateKeywordsVolumeData } from '../../utils/adwords';
|
||||
|
||||
type KeywordsGetResponse = {
|
||||
keywords?: KeywordType[],
|
||||
@@ -45,9 +46,11 @@ const getKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') {
|
||||
return res.status(400).json({ error: 'Domain is Required!' });
|
||||
}
|
||||
const domain = (req.query.domain as string).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 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 {
|
||||
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
|
||||
|
||||
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 newKeyword = {
|
||||
keyword,
|
||||
device,
|
||||
domain,
|
||||
country,
|
||||
city,
|
||||
position: 0,
|
||||
updating: true,
|
||||
history: JSON.stringify({}),
|
||||
@@ -102,8 +106,20 @@ const addKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
|
||||
const newKeywords:Keyword[] = await Keyword.bulkCreate(keywordsToAdd);
|
||||
const formattedkeywords = newKeywords.map((el) => el.get({ plain: true }));
|
||||
const keywordsParsed: KeywordType[] = parseKeywords(formattedkeywords);
|
||||
|
||||
// Queue the SERP Scraping Process
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Adding New Keywords ', error);
|
||||
|
||||
@@ -62,9 +62,10 @@ const sendNotificationEmail = async (domain: Domain, settings: SettingsType) =>
|
||||
smtp_password = '',
|
||||
notification_email = '',
|
||||
notification_email_from = '',
|
||||
notification_email_from_name = 'SerpBear',
|
||||
} = 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) };
|
||||
if (smtp_username || smtp_password) {
|
||||
mailerSettings.auth = {};
|
||||
@@ -77,7 +78,7 @@ const sendNotificationEmail = async (domain: Domain, settings: SettingsType) =>
|
||||
const domainKeywords:Keyword[] = await Keyword.findAll(query);
|
||||
const keywordsArray = domainKeywords.map((el) => el.get({ plain: true }));
|
||||
const keywords: KeywordType[] = parseKeywords(keywordsArray);
|
||||
const emailHTML = await generateEmail(domainName, keywords);
|
||||
const emailHTML = await generateEmail(domainName, keywords, settings);
|
||||
await transporter.sendMail({
|
||||
from: fromEmail,
|
||||
to: domain.notification_emails || notification_email,
|
||||
|
||||
@@ -6,18 +6,32 @@ import refreshAndUpdateKeywords from '../../utils/refresh';
|
||||
import { getAppSettings } from './settings';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
import parseKeywords from '../../utils/parseKeywords';
|
||||
import { scrapeKeywordFromGoogle } from '../../utils/scraper';
|
||||
|
||||
type KeywordsRefreshRes = {
|
||||
keywords?: KeywordType[]
|
||||
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) {
|
||||
await db.sync();
|
||||
const authorized = verifyUser(req, res);
|
||||
if (authorized !== 'authorized') {
|
||||
return res.status(401).json({ error: authorized });
|
||||
}
|
||||
if (req.method === 'GET') {
|
||||
return getKeywordSearchResults(req, res);
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
return refresTheKeywords(req, res);
|
||||
}
|
||||
@@ -62,3 +76,47 @@ const refresTheKeywords = async (req: NextApiRequest, res: NextApiResponse<Keywo
|
||||
return res.status(400).json({ error: 'Error refreshing keywords!' });
|
||||
}
|
||||
};
|
||||
|
||||
const getKeywordSearchResults = async (req: NextApiRequest, res: NextApiResponse<KeywordSearchResultRes>) => {
|
||||
if (!req.query.keyword || !req.query.country || !req.query.device) {
|
||||
return res.status(400).json({ error: 'A Valid keyword, Country Code, and device is Required!' });
|
||||
}
|
||||
try {
|
||||
const settings = await getAppSettings();
|
||||
if (!settings || (settings && settings.scraper_type === 'never')) {
|
||||
return res.status(400).json({ error: 'Scraper has not been set up yet.' });
|
||||
}
|
||||
const dummyKeyword:KeywordType = {
|
||||
ID: 99999999999999,
|
||||
keyword: req.query.keyword as string,
|
||||
device: 'desktop',
|
||||
country: req.query.country as string,
|
||||
domain: '',
|
||||
lastUpdated: '',
|
||||
volume: 0,
|
||||
added: '',
|
||||
position: 111,
|
||||
sticky: false,
|
||||
history: {},
|
||||
lastResult: [],
|
||||
url: '',
|
||||
tags: [],
|
||||
updating: false,
|
||||
lastUpdateError: false,
|
||||
};
|
||||
const scrapeResult = await scrapeKeywordFromGoogle(dummyKeyword, settings);
|
||||
if (scrapeResult && !scrapeResult.error) {
|
||||
const searchResult = {
|
||||
results: scrapeResult.result,
|
||||
keyword: scrapeResult.keyword,
|
||||
position: scrapeResult.position !== 111 ? scrapeResult.position : 0,
|
||||
country: req.query.country as string,
|
||||
};
|
||||
return res.status(200).json({ error: '', searchResult });
|
||||
}
|
||||
return res.status(400).json({ error: 'Error Scraping Search Results for the given keyword!' });
|
||||
} catch (error) {
|
||||
console.log('ERROR refresThehKeywords: ', error);
|
||||
return res.status(400).json({ error: 'Error refreshing keywords!' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import db from '../../database/database';
|
||||
import Domain from '../../database/models/domain';
|
||||
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole';
|
||||
import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
|
||||
type searchConsoleRes = {
|
||||
@@ -31,18 +31,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
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 (!!(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 localSCData = await readLocalSCData(domainname);
|
||||
console.log(localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length);
|
||||
|
||||
if (localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length) {
|
||||
return res.status(200).json({ data: localSCData });
|
||||
}
|
||||
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 });
|
||||
} catch (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>) => {
|
||||
try {
|
||||
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) {
|
||||
await fetchDomainSCData(domain.domain);
|
||||
await fetchDomainSCData(domain);
|
||||
}
|
||||
return res.status(200).json({ status: 'completed' });
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,9 +42,26 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
|
||||
}
|
||||
try {
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api) : '';
|
||||
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password) : '';
|
||||
const securedSettings = { ...settings, scaping_api, smtp_password };
|
||||
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api.trim()) : '';
|
||||
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password.trim()) : '';
|
||||
const search_console_client_email = settings.search_console_client_email ? cryptr.encrypt(settings.search_console_client_email.trim()) : '';
|
||||
const search_console_private_key = settings.search_console_private_key ? cryptr.encrypt(settings.search_console_private_key.trim()) : '';
|
||||
const 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' });
|
||||
return res.status(200).json({ settings });
|
||||
@@ -67,14 +84,28 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : '';
|
||||
const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : '';
|
||||
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 = {
|
||||
...settings,
|
||||
scaping_api,
|
||||
smtp_password,
|
||||
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL),
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
|
||||
search_console_client_email,
|
||||
search_console_private_key,
|
||||
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL)
|
||||
|| !!(search_console_client_email && search_console_private_key),
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id, allowsCity: !!scraper.allowsCity })),
|
||||
failed_queue: failedQueue,
|
||||
screenshot_key: screenshotAPIKey,
|
||||
adwords_client_id,
|
||||
adwords_client_secret,
|
||||
adwords_developer_token,
|
||||
adwords_account_id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('Error Decrypting Settings API Keys!');
|
||||
@@ -88,12 +119,17 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
notification_interval: 'never',
|
||||
notification_email: '',
|
||||
notification_email_from: '',
|
||||
notification_email_from_name: 'SerpBear',
|
||||
smtp_server: '',
|
||||
smtp_port: '',
|
||||
smtp_username: '',
|
||||
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 })),
|
||||
|
||||
66
pages/api/volume.ts
Normal file
66
pages/api/volume.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
// import { useQuery } from 'react-query';
|
||||
// import toast from 'react-hot-toast';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import Sidebar from '../../../components/common/Sidebar';
|
||||
import TopBar from '../../../components/common/TopBar';
|
||||
@@ -11,47 +9,47 @@ import DomainHeader from '../../../components/domains/DomainHeader';
|
||||
import KeywordsTable from '../../../components/keywords/KeywordsTable';
|
||||
import AddDomain from '../../../components/domains/AddDomain';
|
||||
import DomainSettings from '../../../components/domains/DomainSettings';
|
||||
import exportCSV from '../../../utils/exportcsv';
|
||||
import exportCSV from '../../../utils/client/exportcsv';
|
||||
import Settings from '../../../components/settings/Settings';
|
||||
import { useFetchDomains } from '../../../services/domains';
|
||||
import { useFetchKeywords } from '../../../services/keywords';
|
||||
import { useFetchSettings } from '../../../services/settings';
|
||||
import AddKeywords from '../../../components/keywords/AddKeywords';
|
||||
import Footer from '../../../components/common/Footer';
|
||||
|
||||
const SingleDomain: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const [noScrapprtError, setNoScrapprtError] = useState(false);
|
||||
const [showAddKeywords, setShowAddKeywords] = useState(false);
|
||||
const [showAddDomain, setShowAddDomain] = useState(false);
|
||||
const [showDomainSettings, setShowDomainSettings] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [keywordSPollInterval, setKeywordSPollInterval] = useState<undefined|number>(undefined);
|
||||
const { data: appSettings } = useFetchSettings();
|
||||
const { data: appSettingsData, isLoading: isAppSettingsLoading } = useFetchSettings();
|
||||
const { data: domainsData } = useFetchDomains(router);
|
||||
const { keywordsData, keywordsLoading } = useFetchKeywords(router, setKeywordSPollInterval, keywordSPollInterval);
|
||||
|
||||
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
|
||||
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
|
||||
const appSettings: SettingsType = appSettingsData?.settings || {};
|
||||
const { scraper_type = '', available_scapers = [] } = appSettings;
|
||||
const activeScraper = useMemo(() => available_scapers.find((scraper) => scraper.value === scraper_type), [scraper_type, available_scapers]);
|
||||
|
||||
const activDomain: DomainType|null = useMemo(() => {
|
||||
let active:DomainType|null = null;
|
||||
if (domainsData?.domains && router.query?.slug) {
|
||||
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
|
||||
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
|
||||
}
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log('appSettings.settings: ', appSettings && appSettings.settings);
|
||||
if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) {
|
||||
setNoScrapprtError(true);
|
||||
}
|
||||
}, [appSettings]);
|
||||
const domainHasScAPI = useMemo(() => {
|
||||
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
|
||||
return !!(doaminSc?.client_email && doaminSc?.private_key);
|
||||
}, [activDomain]);
|
||||
|
||||
// console.log('Domains Data:', router, activDomain, theKeywords);
|
||||
const { keywordsData, keywordsLoading } = useFetchKeywords(router, activDomain?.domain || '', setKeywordSPollInterval, keywordSPollInterval);
|
||||
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
|
||||
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
|
||||
|
||||
return (
|
||||
<div className="Domain ">
|
||||
{noScrapprtError && (
|
||||
{((!scraper_type || (scraper_type === 'none')) && !isAppSettingsLoading) && (
|
||||
<div className=' p-3 bg-red-600 text-white text-sm text-center'>
|
||||
A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app.
|
||||
</div>
|
||||
@@ -66,13 +64,14 @@ const SingleDomain: NextPage = () => {
|
||||
<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
|
||||
? <DomainHeader
|
||||
domain={activDomain}
|
||||
domains={theDomains}
|
||||
showAddModal={setShowAddKeywords}
|
||||
showSettingsModal={setShowDomainSettings}
|
||||
exportCsv={() => exportCSV(theKeywords, activDomain.domain)}
|
||||
/>
|
||||
: <div className='w-full lg:h-[100px]'></div>
|
||||
}
|
||||
<KeywordsTable
|
||||
isLoading={keywordsLoading}
|
||||
@@ -80,13 +79,14 @@ const SingleDomain: NextPage = () => {
|
||||
keywords={theKeywords}
|
||||
showAddModal={showAddKeywords}
|
||||
setShowAddModal={setShowAddKeywords}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) }
|
||||
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || domainHasScAPI }
|
||||
settings={appSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} />
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
|
||||
</CSSTransition>
|
||||
|
||||
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
@@ -98,6 +98,16 @@ const SingleDomain: NextPage = () => {
|
||||
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
|
||||
<Settings closeSettings={() => setShowSettings(false)} />
|
||||
</CSSTransition>
|
||||
<CSSTransition in={showAddKeywords} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddKeywords
|
||||
domain={activDomain?.domain || ''}
|
||||
scraperName={activeScraper?.label || ''}
|
||||
keywords={theKeywords}
|
||||
allowsCity={!!activeScraper?.allowsCity}
|
||||
closeModal={() => setShowAddKeywords(false)}
|
||||
/>
|
||||
</CSSTransition>
|
||||
<Footer currentVersion={appSettings?.version ? appSettings.version : ''} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,12 +10,13 @@ import TopBar from '../../../../components/common/TopBar';
|
||||
import DomainHeader from '../../../../components/domains/DomainHeader';
|
||||
import AddDomain from '../../../../components/domains/AddDomain';
|
||||
import DomainSettings from '../../../../components/domains/DomainSettings';
|
||||
import exportCSV from '../../../../utils/exportcsv';
|
||||
import exportCSV from '../../../../utils/client/exportcsv';
|
||||
import Settings from '../../../../components/settings/Settings';
|
||||
import { useFetchDomains } from '../../../../services/domains';
|
||||
import { useFetchSCKeywords } from '../../../../services/searchConsole';
|
||||
import SCKeywordsTable from '../../../../components/keywords/SCKeywordsTable';
|
||||
import { useFetchSettings } from '../../../../services/settings';
|
||||
import Footer from '../../../../components/common/Footer';
|
||||
|
||||
const DiscoverPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
@@ -29,16 +30,63 @@ const DiscoverPage: NextPage = () => {
|
||||
const { data: keywordsData, isLoading: keywordsLoading, isFetching } = useFetchSCKeywords(router, !!(domainsData?.domains?.length) && scConnected);
|
||||
|
||||
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
|
||||
const theKeywords: SearchAnalyticsItem[] = keywordsData?.data && keywordsData.data[scDateFilter] ? keywordsData.data[scDateFilter] : [];
|
||||
const 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(() => {
|
||||
let active:DomainType|null = null;
|
||||
if (domainsData?.domains && router.query?.slug) {
|
||||
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
|
||||
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
|
||||
}
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
const domainHasScAPI = useMemo(() => {
|
||||
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
|
||||
return !!(doaminSc?.client_email && doaminSc?.private_key);
|
||||
}, [activDomain]);
|
||||
|
||||
return (
|
||||
<div className="Domain ">
|
||||
{activDomain && activDomain.domain
|
||||
@@ -51,27 +99,28 @@ const DiscoverPage: NextPage = () => {
|
||||
<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
|
||||
? <DomainHeader
|
||||
domain={activDomain}
|
||||
domains={theDomains}
|
||||
showAddModal={() => console.log('XXXXX')}
|
||||
showSettingsModal={setShowDomainSettings}
|
||||
exportCsv={() => exportCSV(theKeywords, activDomain.domain, scDateFilter)}
|
||||
exportCsv={() => exportCSV(theKeywordsGrouped, activDomain.domain, scDateFilter)}
|
||||
scFilter={scDateFilter}
|
||||
setScFilter={(item:string) => setSCDateFilter(item)}
|
||||
/>
|
||||
: <div className='w-full lg:h-[100px]'></div>
|
||||
}
|
||||
<SCKeywordsTable
|
||||
isLoading={keywordsLoading || isFetching}
|
||||
domain={activDomain}
|
||||
keywords={theKeywords}
|
||||
isConsoleIntegrated={scConnected}
|
||||
keywords={theKeywordsGrouped}
|
||||
isConsoleIntegrated={scConnected || domainHasScAPI}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} />
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
|
||||
</CSSTransition>
|
||||
|
||||
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
@@ -83,6 +132,7 @@ const DiscoverPage: NextPage = () => {
|
||||
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
|
||||
<Settings closeSettings={() => setShowSettings(false)} />
|
||||
</CSSTransition>
|
||||
<Footer currentVersion={appSettings?.settings?.version ? appSettings.settings.version : ''} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
113
pages/domain/ideas/[slug]/index.tsx
Normal file
113
pages/domain/ideas/[slug]/index.tsx
Normal 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;
|
||||
@@ -10,12 +10,13 @@ import TopBar from '../../../../components/common/TopBar';
|
||||
import DomainHeader from '../../../../components/domains/DomainHeader';
|
||||
import AddDomain from '../../../../components/domains/AddDomain';
|
||||
import DomainSettings from '../../../../components/domains/DomainSettings';
|
||||
import exportCSV from '../../../../utils/exportcsv';
|
||||
import exportCSV from '../../../../utils/client/exportcsv';
|
||||
import Settings from '../../../../components/settings/Settings';
|
||||
import { useFetchDomains } from '../../../../services/domains';
|
||||
import { useFetchSCInsight } from '../../../../services/searchConsole';
|
||||
import SCInsight from '../../../../components/insight/Insight';
|
||||
import { useFetchSettings } from '../../../../services/settings';
|
||||
import Footer from '../../../../components/common/Footer';
|
||||
|
||||
const InsightPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
@@ -34,11 +35,16 @@ const InsightPage: NextPage = () => {
|
||||
const activDomain: DomainType|null = useMemo(() => {
|
||||
let active:DomainType|null = null;
|
||||
if (domainsData?.domains && router.query?.slug) {
|
||||
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
|
||||
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
|
||||
}
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
const domainHasScAPI = useMemo(() => {
|
||||
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
|
||||
return !!(doaminSc?.client_email && doaminSc?.private_key);
|
||||
}, [activDomain]);
|
||||
|
||||
return (
|
||||
<div className="Domain ">
|
||||
{activDomain && activDomain.domain
|
||||
@@ -51,7 +57,7 @@ const InsightPage: NextPage = () => {
|
||||
<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
|
||||
? <DomainHeader
|
||||
domain={activDomain}
|
||||
domains={theDomains}
|
||||
showAddModal={() => console.log('XXXXX')}
|
||||
@@ -60,18 +66,19 @@ const InsightPage: NextPage = () => {
|
||||
scFilter={scDateFilter}
|
||||
setScFilter={(item:string) => setSCDateFilter(item)}
|
||||
/>
|
||||
: <div className='w-full lg:h-[100px]'></div>
|
||||
}
|
||||
<SCInsight
|
||||
isLoading={false}
|
||||
domain={activDomain}
|
||||
insight={theInsight}
|
||||
isConsoleIntegrated={scConnected}
|
||||
isConsoleIntegrated={scConnected || domainHasScAPI}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} />
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
|
||||
</CSSTransition>
|
||||
|
||||
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
@@ -83,6 +90,7 @@ const InsightPage: NextPage = () => {
|
||||
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
|
||||
<Settings closeSettings={() => setShowSettings(false)} />
|
||||
</CSSTransition>
|
||||
<Footer currentVersion={appSettings?.settings?.version ? appSettings.settings.version : ''} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,21 +7,26 @@ import toast, { Toaster } from 'react-hot-toast';
|
||||
import TopBar from '../../components/common/TopBar';
|
||||
import AddDomain from '../../components/domains/AddDomain';
|
||||
import Settings from '../../components/settings/Settings';
|
||||
import { useFetchSettings } from '../../services/settings';
|
||||
import { useCheckMigrationStatus, useFetchSettings } from '../../services/settings';
|
||||
import { fetchDomainScreenshot, useFetchDomains } from '../../services/domains';
|
||||
import DomainItem from '../../components/domains/DomainItem';
|
||||
import Icon from '../../components/common/Icon';
|
||||
import Footer from '../../components/common/Footer';
|
||||
|
||||
type thumbImages = { [domain:string] : string }
|
||||
|
||||
const Domains: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const [noScrapprtError, setNoScrapprtError] = useState(false);
|
||||
// const [noScrapprtError, setNoScrapprtError] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAddDomain, setShowAddDomain] = useState(false);
|
||||
const [domainThumbs, setDomainThumbs] = useState<thumbImages>({});
|
||||
const { data: appSettings } = useFetchSettings();
|
||||
const { data: appSettingsData, isLoading: isAppSettingsLoading } = useFetchSettings();
|
||||
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;
|
||||
@@ -33,29 +38,33 @@ const Domains: NextPage = () => {
|
||||
return keywords;
|
||||
}, [domainsData]);
|
||||
|
||||
const domainSCAPiObj = useMemo(() => {
|
||||
const domainsSCAPI:{ [ID:string] : boolean } = {};
|
||||
if (domainsData?.domains) {
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
const doaminSc = domain?.search_console ? JSON.parse(domain.search_console) : {};
|
||||
domainsSCAPI[domain.ID] = doaminSc.client_email && doaminSc.private_key;
|
||||
});
|
||||
}
|
||||
return domainsSCAPI;
|
||||
}, [domainsData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings?.settings?.screenshot_key) {
|
||||
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings.screenshot_key) {
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
if (domain.domain) {
|
||||
const domainThumb = await fetchDomainScreenshot(domain.domain, appSettings.settings.screenshot_key);
|
||||
const domainThumb = await fetchDomainScreenshot(domain.domain, appSettings.screenshot_key || '');
|
||||
if (domainThumb) {
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domainThumb }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [domainsData, appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log('appSettings.settings: ', appSettings && appSettings.settings);
|
||||
if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) {
|
||||
setNoScrapprtError(true);
|
||||
}
|
||||
}, [appSettings]);
|
||||
}, [domainsData, appSettings.screenshot_key]);
|
||||
|
||||
const manuallyUpdateThumb = async (domain: string) => {
|
||||
if (domain && appSettings?.settings?.screenshot_key) {
|
||||
const domainThumb = await fetchDomainScreenshot(domain, appSettings.settings.screenshot_key, true);
|
||||
if (domain && appSettings.screenshot_key) {
|
||||
const domainThumb = await fetchDomainScreenshot(domain, appSettings.screenshot_key, true);
|
||||
if (domainThumb) {
|
||||
toast(`${domain} Screenshot Updated Successfully!`, { icon: '✔️' });
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain]: domainThumb }));
|
||||
@@ -67,11 +76,17 @@ const Domains: NextPage = () => {
|
||||
|
||||
return (
|
||||
<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'>
|
||||
A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app.
|
||||
</div>
|
||||
)}
|
||||
{migrationStatus?.hasMigrations && (
|
||||
<div className=' p-3 bg-black text-white text-sm text-center'>
|
||||
You need to Update your database. Stop Serpbear and run this command to update your database:
|
||||
<code className=' bg-gray-700 px-2 py-0 ml-1'>npm run db:migrate</code>
|
||||
</div>
|
||||
)}
|
||||
<Head>
|
||||
<title>Domains - SerpBear</title>
|
||||
</Head>
|
||||
@@ -99,7 +114,7 @@ const Domains: NextPage = () => {
|
||||
key={domain.ID}
|
||||
domain={domain}
|
||||
selected={false}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) }
|
||||
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || !!domainSCAPiObj[domain.ID] }
|
||||
thumb={domainThumbs[domain.domain]}
|
||||
updateThumb={manuallyUpdateThumb}
|
||||
// isConsoleIntegrated={false}
|
||||
@@ -119,14 +134,12 @@ const Domains: NextPage = () => {
|
||||
</div>
|
||||
|
||||
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} />
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
|
||||
</CSSTransition>
|
||||
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
|
||||
<Settings closeSettings={() => setShowSettings(false)} />
|
||||
</CSSTransition>
|
||||
<footer className='text-center flex flex-1 justify-center pb-5 items-end'>
|
||||
<span className='text-gray-500 text-xs'><a href='https://github.com/towfiqi/serpbear' target="_blank" rel='noreferrer'>SerpBear v{appSettings?.settings?.version || '0.0.0'}</a></span>
|
||||
</footer>
|
||||
<Footer currentVersion={appSettings?.version ? appSettings.version : ''} />
|
||||
<Toaster position='bottom-center' containerClassName="react_toaster" />
|
||||
</div>
|
||||
);
|
||||
|
||||
150
pages/research/index.tsx
Normal file
150
pages/research/index.tsx
Normal 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;
|
||||
@@ -5,6 +5,9 @@ import serply from './services/serply';
|
||||
import spaceserp from './services/spaceserp';
|
||||
import proxy from './services/proxy';
|
||||
import searchapi from './services/searchapi';
|
||||
import valueSerp from './services/valueserp';
|
||||
import serper from './services/serper';
|
||||
import hasdata from './services/hasdata';
|
||||
|
||||
export default [
|
||||
scrapingRobot,
|
||||
@@ -14,4 +17,7 @@ export default [
|
||||
spaceserp,
|
||||
proxy,
|
||||
searchapi,
|
||||
valueSerp,
|
||||
serper,
|
||||
hasdata,
|
||||
];
|
||||
|
||||
44
scrapers/services/hasdata.ts
Normal file
44
scrapers/services/hasdata.ts
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import cheerio from 'cheerio';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
const proxy:ScraperSettings = {
|
||||
id: 'proxy',
|
||||
@@ -16,6 +16,13 @@ const proxy:ScraperSettings = {
|
||||
|
||||
const $ = cheerio.load(content);
|
||||
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 children = $(mainContent).find('h3');
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import countries from '../../utils/countries';
|
||||
|
||||
interface SearchApiResult {
|
||||
title: string,
|
||||
link: string,
|
||||
position: number,
|
||||
}
|
||||
|
||||
const searchapi:ScraperSettings = {
|
||||
id: 'searchapi',
|
||||
name: 'SearchApi.io',
|
||||
website: 'searchapi.io',
|
||||
allowsCity: true,
|
||||
headers: (keyword, settings) => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -9,7 +18,10 @@ const searchapi:ScraperSettings = {
|
||||
};
|
||||
},
|
||||
scrapeURL: (keyword) => {
|
||||
return `https://www.searchapi.io/api/v1/search?engine=google&q=${encodeURI(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}`;
|
||||
const country = keyword.country || 'US';
|
||||
const countryName = countries[country][0];
|
||||
const location = keyword.city && countryName ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
return `https://www.searchapi.io/api/v1/search?engine=google&q=${encodeURIComponent(keyword.keyword)}&num=100&gl=${country}&device=${keyword.device}${location}`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
@@ -29,10 +41,4 @@ const searchapi:ScraperSettings = {
|
||||
},
|
||||
};
|
||||
|
||||
interface SearchApiResult {
|
||||
title: string,
|
||||
link: string,
|
||||
position: number,
|
||||
}
|
||||
|
||||
export default searchapi;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import countries from '../../utils/countries';
|
||||
|
||||
interface SerpApiResult {
|
||||
title: string,
|
||||
link: string,
|
||||
@@ -8,6 +10,7 @@ const serpapi:ScraperSettings = {
|
||||
id: 'serpapi',
|
||||
name: 'SerpApi.com',
|
||||
website: 'serpapi.com',
|
||||
allowsCity: true,
|
||||
headers: (keyword, settings) => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -15,7 +18,9 @@ const serpapi:ScraperSettings = {
|
||||
};
|
||||
},
|
||||
scrapeURL: (keyword, settings) => {
|
||||
return `https://serpapi.com/search?q=${encodeURI(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}&api_key=${settings.scaping_api}`;
|
||||
const countryName = countries[keyword.country || 'US'][0];
|
||||
const location = keyword.city && keyword.country ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
return `https://serpapi.com/search?q=${encodeURIComponent(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}${location}&api_key=${settings.scaping_api}`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
36
scrapers/services/serper.ts
Normal file
36
scrapers/services/serper.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
interface SerperResult {
|
||||
title: string,
|
||||
link: string,
|
||||
position: number,
|
||||
}
|
||||
|
||||
const serper:ScraperSettings = {
|
||||
id: 'serper',
|
||||
name: 'Serper.dev',
|
||||
website: 'serper.dev',
|
||||
allowsCity: true,
|
||||
scrapeURL: (keyword, settings, countryData) => {
|
||||
const country = keyword.country || 'US';
|
||||
const lang = countryData[country][2];
|
||||
console.log('Serper URL :', `https://google.serper.dev/search?q=${encodeURIComponent(keyword.keyword)}&gl=${country}&hl=${lang}&num=100&apiKey=${settings.scaping_api}`);
|
||||
return `https://google.serper.dev/search?q=${encodeURIComponent(keyword.keyword)}&gl=${country}&hl=${lang}&num=100&apiKey=${settings.scaping_api}`;
|
||||
},
|
||||
resultObjectKey: 'organic',
|
||||
serpExtractor: (content) => {
|
||||
const extractedResult = [];
|
||||
const results: SerperResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SerperResult[];
|
||||
|
||||
for (const { link, title, position } of results) {
|
||||
if (title && link) {
|
||||
extractedResult.push({
|
||||
title,
|
||||
url: link,
|
||||
position,
|
||||
});
|
||||
}
|
||||
}
|
||||
return extractedResult;
|
||||
},
|
||||
};
|
||||
|
||||
export default serper;
|
||||
@@ -20,7 +20,7 @@ const serply:ScraperSettings = {
|
||||
},
|
||||
scrapeURL: (keyword) => {
|
||||
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
|
||||
return `https://api.serply.io/v1/search/q=${encodeURI(keyword.keyword)}&num=100&hl=${country}`;
|
||||
return `https://api.serply.io/v1/search/q=${encodeURIComponent(keyword.keyword)}&num=100&hl=${country}`;
|
||||
},
|
||||
resultObjectKey: 'result',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import countries from '../../utils/countries';
|
||||
|
||||
interface SpaceSerpResult {
|
||||
title: string,
|
||||
link: string,
|
||||
@@ -9,10 +11,14 @@ const spaceSerp:ScraperSettings = {
|
||||
id: 'spaceSerp',
|
||||
name: 'Space Serp',
|
||||
website: 'spaceserp.com',
|
||||
allowsCity: true,
|
||||
scrapeURL: (keyword, settings, countryData) => {
|
||||
const country = keyword.country || 'US';
|
||||
const countryName = countries[country][0];
|
||||
const location = keyword.city ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
const device = keyword.device === 'mobile' ? '&device=mobile' : '';
|
||||
const lang = countryData[country][2];
|
||||
return `https://api.spaceserp.com/google/search?apiKey=${settings.scaping_api}&q=${encodeURI(keyword.keyword)}&pageSize=100&gl=${country}&hl=${lang}${keyword.device === 'mobile' ? '&device=mobile' : ''}&resultBlocks=`;
|
||||
return `https://api.spaceserp.com/google/search?apiKey=${settings.scaping_api}&q=${encodeURIComponent(keyword.keyword)}&pageSize=100&gl=${country}&hl=${lang}${location}${device}&resultBlocks=`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
41
scrapers/services/valueserp.ts
Normal file
41
scrapers/services/valueserp.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import countries from '../../utils/countries';
|
||||
|
||||
interface ValueSerpResult {
|
||||
title: string,
|
||||
link: string,
|
||||
position: number,
|
||||
domain: string,
|
||||
}
|
||||
|
||||
const valueSerp:ScraperSettings = {
|
||||
id: 'valueserp',
|
||||
name: 'Value Serp',
|
||||
website: 'valueserp.com',
|
||||
allowsCity: true,
|
||||
scrapeURL: (keyword, settings, countryData) => {
|
||||
const country = keyword.country || 'US';
|
||||
const countryName = countries[country][0];
|
||||
const location = keyword.city ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
const device = keyword.device === 'mobile' ? '&device=mobile' : '';
|
||||
const lang = countryData[country][2];
|
||||
console.log(`https://api.valueserp.com/search?api_key=${settings.scaping_api}&q=${encodeURIComponent(keyword.keyword)}&gl=${country}&hl=${lang}${device}${location}&num=100&output=json&include_answer_box=false&include_advertiser_info=false`);
|
||||
return `https://api.valueserp.com/search?api_key=${settings.scaping_api}&q=${encodeURIComponent(keyword.keyword)}&gl=${country}&hl=${lang}${device}${location}&num=100&output=json&include_answer_box=false&include_advertiser_info=false`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
const extractedResult = [];
|
||||
const results: ValueSerpResult[] = (typeof content === 'string') ? JSON.parse(content) : content as ValueSerpResult[];
|
||||
for (const result of results) {
|
||||
if (result.title && result.link) {
|
||||
extractedResult.push({
|
||||
title: result.title,
|
||||
url: result.link,
|
||||
position: result.position,
|
||||
});
|
||||
}
|
||||
}
|
||||
return extractedResult;
|
||||
},
|
||||
};
|
||||
|
||||
export default valueSerp;
|
||||
128
services/adwords.tsx
Normal file
128
services/adwords.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { NextRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
export function useTestAdwordsIntegration(onSuccess?: Function) {
|
||||
return useMutation(async (payload:{developer_token:string, account_id:string}) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ ...payload }) };
|
||||
const res = await fetch(`${window.location.origin}/api/adwords`, fetchOpts);
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}, {
|
||||
onSuccess: async (data) => {
|
||||
console.log('Ideas Added:', data);
|
||||
toast('Google Ads has been integrated successfully!', { icon: '✔️' });
|
||||
if (onSuccess) {
|
||||
onSuccess(false);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log('Error Loading Keyword Ideas!!!', error);
|
||||
toast('Failed to connect to Google Ads. Please make sure you have provided the correct API info.', { icon: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAdwordsKeywordIdeas(router: NextRouter, domainSlug: string) {
|
||||
// if (!router.query.slug) { throw new Error('Invalid Domain Name'); }
|
||||
const res = await fetch(`${window.location.origin}/api/ideas?domain=${domainSlug}`, { method: 'GET' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
if (res.status === 401) {
|
||||
console.log('Unauthorized!!');
|
||||
router.push('/login');
|
||||
}
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useFetchKeywordIdeas(router: NextRouter, adwordsConnected = false) {
|
||||
const isResearch = router.pathname === '/research';
|
||||
const domainSlug = isResearch ? 'research' : (router.query.slug as string);
|
||||
const enabled = !!(adwordsConnected && domainSlug);
|
||||
return useQuery(`keywordIdeas-${domainSlug}`, () => domainSlug && fetchAdwordsKeywordIdeas(router, domainSlug), { enabled, retry: false });
|
||||
}
|
||||
|
||||
export function useMutateKeywordIdeas(router:NextRouter, onSuccess?: Function) {
|
||||
const queryClient = useQueryClient();
|
||||
const domainSlug = router.pathname === '/research' ? 'research' : router.query.slug as string;
|
||||
return useMutation(async (data:Record<string, any>) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ ...data }) };
|
||||
const res = await fetch(`${window.location.origin}/api/ideas`, fetchOpts);
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}, {
|
||||
onSuccess: async (data) => {
|
||||
console.log('Ideas Added:', data);
|
||||
toast('Keyword Ideas Loaded Successfully!', { icon: '✔️' });
|
||||
if (onSuccess) {
|
||||
onSuccess(false);
|
||||
}
|
||||
queryClient.invalidateQueries([`keywordIdeas-${domainSlug}`]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log('Error Loading Keyword Ideas!!!', error);
|
||||
toast('Error Loading Keyword Ideas', { icon: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMutateFavKeywordIdeas(router:NextRouter, onSuccess?: Function) {
|
||||
const queryClient = useQueryClient();
|
||||
const domainSlug = router.pathname === '/research' ? 'research' : router.query.slug as string;
|
||||
return useMutation(async (payload:Record<string, any>) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify({ ...payload }) };
|
||||
const res = await fetch(`${window.location.origin}/api/ideas`, fetchOpts);
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}, {
|
||||
onSuccess: async (data) => {
|
||||
console.log('Ideas Added:', data);
|
||||
// toast('Keyword Updated!', { icon: '✔️' });
|
||||
if (onSuccess) {
|
||||
onSuccess(false);
|
||||
}
|
||||
queryClient.invalidateQueries([`keywordIdeas-${domainSlug}`]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log('Error Favorating Keywords', error);
|
||||
toast('Error Favorating Keywords', { icon: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMutateKeywordsVolume(onSuccess?: Function) {
|
||||
return useMutation(async (data:Record<string, any>) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ ...data }) };
|
||||
const res = await fetch(`${window.location.origin}/api/volume`, fetchOpts);
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData?.error ? errorData.error : 'Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}, {
|
||||
onSuccess: async (data) => {
|
||||
toast('Keyword Volume Data Loaded Successfully! Reloading Page...', { icon: '✔️' });
|
||||
if (onSuccess) {
|
||||
onSuccess(false);
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log('Error Loading Keyword Volume Data!!!', error);
|
||||
toast('Error Loading Keyword Volume Data', { icon: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ type UpdatePayload = {
|
||||
domain: DomainType
|
||||
}
|
||||
|
||||
export async function fetchDomains(router: NextRouter, withStats:boolean) {
|
||||
export async function fetchDomains(router: NextRouter, withStats:boolean): Promise<{domains: DomainType[]}> {
|
||||
const res = await fetch(`${window.location.origin}/api/domains${withStats ? '?withstats=true' : ''}`, { method: 'GET' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
if (res.status === 401) {
|
||||
@@ -19,6 +19,19 @@ export async function fetchDomains(router: NextRouter, withStats:boolean) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchDomain(router: NextRouter, domainName: string): Promise<{domain: DomainType}> {
|
||||
if (!domainName) { throw new Error('No Domain Name Provided!'); }
|
||||
const res = await fetch(`${window.location.origin}/api/domain?domain=${domainName}`, { method: 'GET' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
if (res.status === 401) {
|
||||
console.log('Unauthorized!!');
|
||||
router.push('/login');
|
||||
}
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchDomainScreenshot(domain: string, screenshot_key:string, forceFetch = false): Promise<string | false> {
|
||||
const domainThumbsRaw = localStorage.getItem('domainThumbs');
|
||||
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
|
||||
@@ -53,12 +66,20 @@ export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
|
||||
return useQuery('domains', () => fetchDomains(router, withStats));
|
||||
}
|
||||
|
||||
export function useFetchDomain(router: NextRouter, domainName:string, onSuccess: Function) {
|
||||
return useQuery('domain', () => fetchDomain(router, domainName), {
|
||||
onSuccess: async (data) => {
|
||||
console.log('Domain Loaded!!!', data.domain);
|
||||
onSuccess(data.domain);
|
||||
} });
|
||||
}
|
||||
|
||||
export function useAddDomain(onSuccess:Function) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(async (domainName:string) => {
|
||||
return useMutation(async (domains:string[]) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domain: domainName }) };
|
||||
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domains }) };
|
||||
const res = await fetch(`${window.location.origin}/api/domains`, fetchOpts);
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
@@ -67,11 +88,12 @@ export function useAddDomain(onSuccess:Function) {
|
||||
}, {
|
||||
onSuccess: async (data) => {
|
||||
console.log('Domain Added!!!', data);
|
||||
const newDomain:DomainType = data.domain;
|
||||
toast(`${newDomain.domain} Added Successfully!`, { icon: '✔️' });
|
||||
const newDomain:DomainType[] = data.domains;
|
||||
const singleDomain = newDomain.length === 1;
|
||||
toast(`${singleDomain ? newDomain[0].domain : `${newDomain.length} domains`} Added Successfully!`, { icon: '✔️' });
|
||||
onSuccess(false);
|
||||
if (newDomain && newDomain.slug) {
|
||||
router.push(`/domain/${data.domain.slug}`);
|
||||
if (singleDomain) {
|
||||
router.push(`/domain/${newDomain[0].slug}`);
|
||||
}
|
||||
queryClient.invalidateQueries(['domains']);
|
||||
},
|
||||
@@ -88,10 +110,11 @@ export function useUpdateDomain(onSuccess:Function) {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify(domainSettings) };
|
||||
const res = await fetch(`${window.location.origin}/api/domains?domain=${domain.domain}`, fetchOpts);
|
||||
const responseObj = await res.json();
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
throw new Error(responseObj?.error || 'Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
return responseObj;
|
||||
}, {
|
||||
onSuccess: async () => {
|
||||
console.log('Settings Updated!!!');
|
||||
@@ -99,8 +122,8 @@ export function useUpdateDomain(onSuccess:Function) {
|
||||
onSuccess();
|
||||
queryClient.invalidateQueries(['domains']);
|
||||
},
|
||||
onError: () => {
|
||||
console.log('Error Updating Domain Settings!!!');
|
||||
onError: (error) => {
|
||||
console.log('Error Updating Domain Settings!!!', error);
|
||||
toast('Error Updating Domain Settings', { icon: '⚠️' });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,16 +2,21 @@ import toast from 'react-hot-toast';
|
||||
import { NextRouter } from 'next/router';
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
export const fetchKeywords = async (router: NextRouter) => {
|
||||
if (!router.query.slug) { return []; }
|
||||
const res = await fetch(`${window.location.origin}/api/keywords?domain=${router.query.slug}`, { method: 'GET' });
|
||||
export const fetchKeywords = async (router: NextRouter, domain: string) => {
|
||||
if (!domain) { return []; }
|
||||
const res = await fetch(`${window.location.origin}/api/keywords?domain=${domain}`, { method: 'GET' });
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export function useFetchKeywords(router: NextRouter, setKeywordSPollInterval?:Function, keywordSPollInterval:undefined|number = undefined) {
|
||||
export function useFetchKeywords(
|
||||
router: NextRouter,
|
||||
domain: string,
|
||||
setKeywordSPollInterval?:Function,
|
||||
keywordSPollInterval:undefined|number = undefined,
|
||||
) {
|
||||
const { data: keywordsData, isLoading: keywordsLoading, isError } = useQuery(
|
||||
['keywords', router.query.slug],
|
||||
() => fetchKeywords(router),
|
||||
['keywords', domain],
|
||||
() => fetchKeywords(router, domain),
|
||||
{
|
||||
refetchInterval: keywordSPollInterval,
|
||||
onSuccess: (data) => {
|
||||
@@ -153,3 +158,36 @@ export function useRefreshKeywords(onSuccess:Function) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFetchSingleKeyword(keywordID:number) {
|
||||
return useQuery(['keyword', keywordID], async () => {
|
||||
try {
|
||||
const fetchURL = `${window.location.origin}/api/keyword?id=${keywordID}`;
|
||||
const res = await fetch(fetchURL, { method: 'GET' }).then((result) => result.json());
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return { history: res.keyword.history || [], searchResult: res.keyword.lastResult || [] };
|
||||
} catch (error) {
|
||||
throw new Error('Error Loading Keyword Details');
|
||||
}
|
||||
}, {
|
||||
onError: () => {
|
||||
console.log('Error Loading Keyword Data!!!');
|
||||
toast('Error Loading Keyword Details.', { icon: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSearchResults(router:NextRouter, keywordData: Record<string, string>) {
|
||||
const { keyword, country, device } = keywordData;
|
||||
const res = await fetch(`${window.location.origin}/api/refresh?keyword=${keyword}&country=${country}&device=${device}`, { method: 'GET' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
if (res.status === 401) {
|
||||
console.log('Unauthorized!!');
|
||||
router.push('/login');
|
||||
}
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
10
services/misc.tsx
Normal file
10
services/misc.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
export async function fetchChangelog() {
|
||||
const res = await fetch('https://api.github.com/repos/towfiqi/serpbear/releases', { method: 'GET' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useFetchChangelog() {
|
||||
return useQuery('changelog', () => fetchChangelog(), { cacheTime: 60 * 60 * 1000 });
|
||||
}
|
||||
@@ -60,3 +60,37 @@ export function useClearFailedQueue(onSuccess:Function) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMigrationStatus() {
|
||||
const res = await fetch(`${window.location.origin}/api/dbmigrate`, { method: 'GET' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useCheckMigrationStatus() {
|
||||
return useQuery('dbmigrate', () => fetchMigrationStatus());
|
||||
}
|
||||
|
||||
export const useMigrateDatabase = (onSuccess:Function|undefined) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(async () => {
|
||||
// console.log('settings: ', JSON.stringify(settings));
|
||||
const res = await fetch(`${window.location.origin}/api/dbmigrate`, { method: 'POST' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}, {
|
||||
onSuccess: async (res) => {
|
||||
if (onSuccess) {
|
||||
onSuccess(res);
|
||||
}
|
||||
toast('Database Updated!', { icon: '✔️' });
|
||||
queryClient.invalidateQueries(['settings']);
|
||||
},
|
||||
onError: () => {
|
||||
console.log('Error Updating Database!!!');
|
||||
toast('Error Updating Database.', { icon: '⚠️' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
52
styles/changelog.css
Normal file
52
styles/changelog.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.changelog-body{
|
||||
max-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.changelog-content h1{
|
||||
margin: 1.2rem 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.changelog-content h2{
|
||||
margin: 1.2rem 0;
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.changelog-content h2:after {
|
||||
content: "🌟";
|
||||
font-size: 1.2rem;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.changelog-content h3{
|
||||
margin: 1.2rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.changelog-content h4{
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.changelog-content ul{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
line-height: 1.7em;
|
||||
}
|
||||
|
||||
.changelog-content a:link{
|
||||
color: rgb(75, 75, 241)
|
||||
}
|
||||
.changelog-content a:visited{
|
||||
color: rgb(116, 116, 121)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@import url("./fflag.css");
|
||||
|
||||
@import url("./changelog.css");
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -8,6 +8,10 @@ body {
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.max-w-7xl {
|
||||
max-width: 90rem;
|
||||
}
|
||||
|
||||
.domKeywords {
|
||||
/* min-height: 70vh; */
|
||||
border-color: #e9ebff;
|
||||
@@ -282,3 +286,33 @@ body {
|
||||
right: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.idea_competiton{
|
||||
color: #0000008c;
|
||||
}
|
||||
|
||||
.idea_competiton--LOW span:last-child{
|
||||
background-color: #ccf5ee;
|
||||
color: #39a895;
|
||||
}
|
||||
.idea_competiton--MEDIUM span:last-child{
|
||||
color: #6e5d1f;
|
||||
background-color: #F6F0D8;
|
||||
}
|
||||
|
||||
.idea_competiton--HIGH span:last-child{
|
||||
color: #ae513c;
|
||||
background-color: #f9ded7;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 760px){
|
||||
.keyword_best:before{
|
||||
content: "Best: ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Disable LastPass Icon for Secret Field */
|
||||
[autocomplete="off"] + div[data-lastpass-icon-root="true"], [autocomplete="off"] + div[data-lastpass-infield="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ module.exports = {
|
||||
'w-[240px]',
|
||||
'min-w-[270px]',
|
||||
'min-w-[180px]',
|
||||
'max-w-[180px]',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
|
||||
74
types.d.ts
vendored
74
types.d.ts
vendored
@@ -15,6 +15,8 @@ type DomainType = {
|
||||
scVisits?: number,
|
||||
scImpressions?: number,
|
||||
scPosition?: number,
|
||||
search_console?: string,
|
||||
ideas_settings?: string,
|
||||
}
|
||||
|
||||
type KeywordHistory = {
|
||||
@@ -30,6 +32,7 @@ type KeywordType = {
|
||||
lastUpdated: string,
|
||||
added: string,
|
||||
position: number,
|
||||
volume: number,
|
||||
sticky: boolean,
|
||||
history: KeywordHistory,
|
||||
lastResult: KeywordLastResult[],
|
||||
@@ -39,6 +42,7 @@ type KeywordType = {
|
||||
lastUpdateError: {date: string, error: string, scraper: string} | false,
|
||||
scData?: KeywordSCData,
|
||||
uid?: string
|
||||
city?: string
|
||||
}
|
||||
|
||||
type KeywordLastResult = {
|
||||
@@ -54,16 +58,24 @@ type KeywordFilters = {
|
||||
}
|
||||
|
||||
type countryData = {
|
||||
[ISO:string] : [countryName:string, cityName:string, language:string]
|
||||
[ISO:string] : [countryName:string, cityName:string, language:string, AdWordsID: number]
|
||||
}
|
||||
|
||||
type countryCodeData = {
|
||||
[ISO:string] : string
|
||||
}
|
||||
|
||||
type DomainSearchConsole = {
|
||||
property_type: 'domain' | 'url',
|
||||
url: string,
|
||||
client_email:string,
|
||||
private_key:string,
|
||||
}
|
||||
|
||||
type DomainSettings = {
|
||||
notification_interval: string,
|
||||
notification_emails: string,
|
||||
search_console?: DomainSearchConsole
|
||||
}
|
||||
|
||||
type SettingsType = {
|
||||
@@ -73,18 +85,28 @@ type SettingsType = {
|
||||
notification_interval: string,
|
||||
notification_email: string,
|
||||
notification_email_from: string,
|
||||
notification_email_from_name: string,
|
||||
smtp_server: string,
|
||||
smtp_port: string,
|
||||
smtp_username?: string,
|
||||
smtp_password?: string,
|
||||
search_console_integrated?: boolean,
|
||||
available_scapers?: Array,
|
||||
available_scapers?: { label: string, value: string, allowsCity?: boolean }[],
|
||||
scrape_interval?: string,
|
||||
scrape_delay?: string,
|
||||
scrape_retry?: boolean,
|
||||
failed_queue?: string[]
|
||||
version?: string,
|
||||
screenshot_key?: string,
|
||||
search_console: boolean,
|
||||
search_console_client_email: string,
|
||||
search_console_private_key: string,
|
||||
search_console_integrated?: boolean,
|
||||
adwords_client_id?: string,
|
||||
adwords_client_secret?: string,
|
||||
adwords_refresh_token?: string,
|
||||
adwords_developer_token?: string,
|
||||
adwords_account_id?: string,
|
||||
keywordsColumns: string[]
|
||||
}
|
||||
|
||||
type KeywordSCDataChild = {
|
||||
@@ -108,7 +130,8 @@ type KeywordAddPayload = {
|
||||
device: string,
|
||||
country: string,
|
||||
domain: string,
|
||||
tags: string,
|
||||
tags?: string,
|
||||
city?:string
|
||||
}
|
||||
|
||||
type SearchAnalyticsRawItem = {
|
||||
@@ -171,17 +194,60 @@ type SCDomainDataType = {
|
||||
|
||||
type SCKeywordType = SearchAnalyticsItem;
|
||||
|
||||
type DomainIdeasSettings = {
|
||||
seedSCKeywords: boolean,
|
||||
seedCurrentKeywords: boolean,
|
||||
seedDomain: boolean,
|
||||
language: string,
|
||||
countries: string[],
|
||||
keywords: string
|
||||
}
|
||||
|
||||
type AdwordsCredentials = {
|
||||
client_id: string,
|
||||
client_secret: string,
|
||||
developer_token: string,
|
||||
account_id: string,
|
||||
refresh_token: string,
|
||||
}
|
||||
|
||||
type IdeaKeyword = {
|
||||
uid: string,
|
||||
keyword: string,
|
||||
competition: 'UNSPECIFIED' | 'UNKNOWN' | 'HIGH' | 'LOW' | 'MEDIUM',
|
||||
country: string,
|
||||
domain: string,
|
||||
competitionIndex : number,
|
||||
monthlySearchVolumes: Record<string, string>,
|
||||
avgMonthlySearches: number,
|
||||
added: number,
|
||||
updated: number,
|
||||
position:number
|
||||
}
|
||||
|
||||
type scraperExtractedItem = {
|
||||
title: string,
|
||||
url: string,
|
||||
position: number,
|
||||
}
|
||||
interface ScraperSettings {
|
||||
/** A Unique ID for the Scraper. eg: myScraper */
|
||||
id:string,
|
||||
/** The Name of the Scraper */
|
||||
name:string,
|
||||
/** The Website address of the Scraper */
|
||||
website:string,
|
||||
/** The result object's key that contains the results of the scraped data. For example,
|
||||
* if your scraper API the data like this `{scraped:[item1,item2..]}` the resultObjectKey should be "scraped" */
|
||||
resultObjectKey: string,
|
||||
/** If the Scraper allows setting a perices location or allows city level scraping set this to true. */
|
||||
allowsCity?: boolean,
|
||||
/** Set your own custom HTTP header properties when making the scraper API request.
|
||||
* The function should return an object that contains all the header properties you want to pass to API request's header.
|
||||
* Example: `{'Cache-Control': 'max-age=0', 'Content-Type': 'application/json'}` */
|
||||
headers?(keyword:KeywordType, settings: SettingsType): Object,
|
||||
/** Construct the API URL for scraping the data through your Scraper's API */
|
||||
scrapeURL?(keyword:KeywordType, settings:SettingsType, countries:countryData): string,
|
||||
/** Custom function to extract the serp result from the scraped data. The extracted data should be @return {scraperExtractedItem[]} */
|
||||
serpExtractor?(content:string): scraperExtractedItem[],
|
||||
}
|
||||
|
||||
432
utils/adwords.ts
Normal file
432
utils/adwords.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import Cryptr from 'cryptr';
|
||||
import TTLCache from '@isaacs/ttlcache';
|
||||
import { setTimeout as sleep } from 'timers/promises';
|
||||
import Keyword from '../database/models/keyword';
|
||||
import parseKeywords from './parseKeywords';
|
||||
import countries from './countries';
|
||||
import { readLocalSCData } from './searchConsole';
|
||||
|
||||
const memoryCache = new TTLCache({ max: 10000 });
|
||||
|
||||
type keywordIdeasMetrics = {
|
||||
competition: IdeaKeyword['competition'],
|
||||
monthlySearchVolumes: {month : string, year : string, monthlySearches : string}[],
|
||||
avgMonthlySearches: string,
|
||||
competitionIndex: string,
|
||||
lowTopOfPageBidMicros: string,
|
||||
highTopOfPageBidMicros: string
|
||||
}
|
||||
|
||||
type keywordIdeasResponseItem = {
|
||||
keywordIdeaMetrics: keywordIdeasMetrics,
|
||||
text: string,
|
||||
keywordAnnotations: Object
|
||||
};
|
||||
|
||||
type IdeaSettings = {
|
||||
country?: string;
|
||||
city?: string;
|
||||
language?: string;
|
||||
keywords?: string[];
|
||||
url?: string;
|
||||
domain?:string;
|
||||
seedType: 'auto' | 'custom' | 'tracking' | 'searchconsole'
|
||||
}
|
||||
|
||||
type IdeaDatabaseUpdateData = {
|
||||
keywords?: IdeaKeyword[],
|
||||
settings?: IdeaSettings,
|
||||
favorites?: IdeaKeyword[]
|
||||
}
|
||||
|
||||
export type KeywordIdeasDatabase = {
|
||||
keywords: IdeaKeyword[],
|
||||
favorites: IdeaKeyword[],
|
||||
settings: IdeaSettings,
|
||||
updated: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The function `getAdwordsCredentials` reads and decrypts Google Ads credentials from the App settings file.
|
||||
* @returns {Promise<false | AdwordsCredentials>} returns either a decrypted `AdwordsCredentials` object if the settings are successfully decrypted,
|
||||
* or `false` if the decryption process fails.
|
||||
*/
|
||||
export const getAdwordsCredentials = async (): Promise<false | AdwordsCredentials> => {
|
||||
try {
|
||||
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
|
||||
const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
let decryptedSettings: false | AdwordsCredentials = false;
|
||||
|
||||
try {
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
const client_id = settings.adwords_client_id ? cryptr.decrypt(settings.adwords_client_id) : '';
|
||||
const client_secret = settings.adwords_client_secret ? cryptr.decrypt(settings.adwords_client_secret) : '';
|
||||
const developer_token = settings.adwords_developer_token ? cryptr.decrypt(settings.adwords_developer_token) : '';
|
||||
const account_id = settings.adwords_account_id ? cryptr.decrypt(settings.adwords_account_id) : '';
|
||||
const refresh_token = settings.adwords_refresh_token ? cryptr.decrypt(settings.adwords_refresh_token) : '';
|
||||
|
||||
decryptedSettings = {
|
||||
client_id,
|
||||
client_secret,
|
||||
developer_token,
|
||||
account_id,
|
||||
refresh_token,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('Error Decrypting Settings API Keys!');
|
||||
}
|
||||
|
||||
return decryptedSettings;
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Getting App Settings. ', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* retrieves an access token using Google Ads credentials for Google API authentication.
|
||||
* @param {AdwordsCredentials} credentials - The `credentials` to use to generate the access token,
|
||||
* @returns {Promise<string>} the fetched access token or an empty string if failed.
|
||||
*/
|
||||
export const getAdwordsAccessToken = async (credentials:AdwordsCredentials) => {
|
||||
const { client_id, client_secret, refresh_token } = credentials;
|
||||
try {
|
||||
const resp = await fetch('https://www.googleapis.com/oauth2/v3/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ grant_type: 'refresh_token', client_id, client_secret, refresh_token }),
|
||||
});
|
||||
const tokens = await resp.json();
|
||||
// console.log('token :', tokens);
|
||||
return tokens?.access_token || '';
|
||||
} catch (error) {
|
||||
console.log('[Error] Getting Google Account Access Token:', error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The function `getAdwordsKeywordIdeas` retrieves keyword ideas from Google Ads API based on
|
||||
* provided credentials and settings.
|
||||
* @param {AdwordsCredentials} credentials - an object containing Google Ads credentials needed to authenticate
|
||||
* the API request.
|
||||
* @param {IdeaSettings} adwordsDomainOptions - an object that contains settings and options for fetching
|
||||
* keyword ideas from Google Ads.
|
||||
* @param {boolean} [test=false] - a boolean flag that indicates whether the function is being run in a test mode or not.
|
||||
* When `test` is set to `true`, only 1 keyword is requested from adwords.
|
||||
* @returns returns an array of fetched keywords (`fetchedKeywords`) after processing the Google Ads API response.
|
||||
*/
|
||||
export const getAdwordsKeywordIdeas = async (credentials:AdwordsCredentials, adwordsDomainOptions:IdeaSettings, test:boolean = false) => {
|
||||
if (!credentials) { return false; }
|
||||
const { account_id, developer_token } = credentials;
|
||||
const { country = '2840', language = '1000', keywords = [], domain = '', seedType } = adwordsDomainOptions || {};
|
||||
|
||||
let accessToken = '';
|
||||
|
||||
const cachedAccessToken:string|false|undefined = memoryCache.get('adwords_token');
|
||||
if (cachedAccessToken && !test) {
|
||||
accessToken = cachedAccessToken;
|
||||
} else {
|
||||
accessToken = await getAdwordsAccessToken(credentials);
|
||||
memoryCache.delete('adwords_token');
|
||||
memoryCache.set('adwords_token', accessToken, { ttl: 3300000 });
|
||||
}
|
||||
|
||||
let fetchedKeywords:IdeaKeyword[] = [];
|
||||
if (accessToken) {
|
||||
const seedKeywords = [...keywords];
|
||||
|
||||
// Load Keywords from Google Search Console File.
|
||||
if (seedType === 'searchconsole' && domain) {
|
||||
const domainSCData = await readLocalSCData(domain);
|
||||
if (domainSCData && domainSCData.thirtyDays) {
|
||||
const scKeywords = domainSCData.thirtyDays;
|
||||
const sortedSCKeywords = scKeywords.sort((a, b) => (b.impressions > a.impressions ? 1 : -1));
|
||||
sortedSCKeywords.slice(0, 100).forEach((sckeywrd) => {
|
||||
if (sckeywrd.keyword && !seedKeywords.includes(sckeywrd.keyword)) {
|
||||
seedKeywords.push(sckeywrd.keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load all Keywords from Database
|
||||
if (seedType === 'tracking' && domain) {
|
||||
const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain } });
|
||||
const currentKeywords: KeywordType[] = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
||||
currentKeywords.forEach((keyword) => {
|
||||
if (keyword.keyword && !seedKeywords.includes(keyword.keyword)) {
|
||||
seedKeywords.push(keyword.keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// API: https://developers.google.com/google-ads/api/rest/reference/rest/v18/customers/generateKeywordIdeas
|
||||
const customerID = account_id.replaceAll('-', '');
|
||||
const geoTargetConstants = countries[country][3]; // '2840';
|
||||
const reqPayload: Record<string, any> = {
|
||||
geoTargetConstants: `geoTargetConstants/${geoTargetConstants}`,
|
||||
language: `languageConstants/${language}`,
|
||||
pageSize: test ? '1' : '1000',
|
||||
};
|
||||
if (['custom', 'searchconsole', 'tracking'].includes(seedType) && seedKeywords.length > 0) {
|
||||
reqPayload.keywordSeed = { keywords: seedKeywords.slice(0, 20) };
|
||||
}
|
||||
if (seedType === 'auto' && domain) {
|
||||
reqPayload.siteSeed = { site: domain };
|
||||
}
|
||||
|
||||
const resp = await fetch(`https://googleads.googleapis.com/v18/customers/${customerID}:generateKeywordIdeas`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'developer-token': developer_token,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'login-customer-id': customerID,
|
||||
},
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const ideaData = await resp.json();
|
||||
|
||||
if (resp.status !== 200) {
|
||||
console.log('[ERROR] Google Ads Response :', ideaData?.error?.details[0]?.errors[0]?.message);
|
||||
// console.log('Response from Ads :', JSON.stringify(ideaData, null, 2));
|
||||
}
|
||||
|
||||
if (ideaData?.results) {
|
||||
fetchedKeywords = extractAdwordskeywordIdeas(ideaData.results as keywordIdeasResponseItem[], { country, domain });
|
||||
}
|
||||
|
||||
if (!test && fetchedKeywords.length > 0) {
|
||||
await updateLocalKeywordIdeas(domain, { keywords: fetchedKeywords, settings: adwordsDomainOptions });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Fetching Keyword Ideas from Google Ads :', error);
|
||||
}
|
||||
}
|
||||
|
||||
return fetchedKeywords;
|
||||
};
|
||||
|
||||
/**
|
||||
* The function `extractAdwordskeywordIdeas` processes keyword ideas data and returns an array of
|
||||
* IdeaKeyword objects sorted by average monthly searches.
|
||||
* @param {keywordIdeasResponseItem[]} keywordIdeas - The `keywordIdeas` parameter is an array of
|
||||
* objects that contain keyword ideas and their metrics.
|
||||
* @param options - The `options` parameter in the `extractAdwordskeywordIdeas` function is an object
|
||||
* that can contain two properties: `country` and `domain`.
|
||||
* @returns returns an array of `IdeaKeyword` array sorted based on the average monthly searches in descending order.
|
||||
*/
|
||||
const extractAdwordskeywordIdeas = (keywordIdeas:keywordIdeasResponseItem[], options:Record<string, string>) => {
|
||||
const keywords: IdeaKeyword[] = [];
|
||||
if (keywordIdeas.length > 0) {
|
||||
const { country = '', domain = '' } = options;
|
||||
keywordIdeas.forEach((kwRaw) => {
|
||||
const { text, keywordIdeaMetrics } = kwRaw;
|
||||
const { competition, competitionIndex = '0', avgMonthlySearches = '0', monthlySearchVolumes = [] } = keywordIdeaMetrics || {};
|
||||
if (keywordIdeaMetrics?.avgMonthlySearches) {
|
||||
const searchVolumeTrend: Record<string, string> = {};
|
||||
const searchVolume = parseInt(avgMonthlySearches, 10);
|
||||
monthlySearchVolumes.forEach((item) => {
|
||||
searchVolumeTrend[`${item.month}-${item.year}`] = item.monthlySearches;
|
||||
});
|
||||
if (searchVolume > 100) {
|
||||
keywords.push({
|
||||
uid: `${country.toLowerCase()}:${text.replaceAll(' ', '-')}`,
|
||||
keyword: text,
|
||||
competition,
|
||||
competitionIndex: competitionIndex !== null ? parseInt(competitionIndex, 10) : 0,
|
||||
monthlySearchVolumes: searchVolumeTrend,
|
||||
avgMonthlySearches: searchVolume,
|
||||
added: new Date().getTime(),
|
||||
updated: new Date().getTime(),
|
||||
country,
|
||||
domain,
|
||||
position: 999,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return keywords.sort((a: IdeaKeyword, b: IdeaKeyword) => (b.avgMonthlySearches > a.avgMonthlySearches ? 1 : -1));
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves keyword search volumes from Google Ads API based on provided keywords and their countries.
|
||||
* @param {KeywordType[]} keywords - The keywords that you want to get the search volume data for.
|
||||
* @returns returns a Promise that resolves to an object with a `volumes` and error `proprties`.
|
||||
* The `volumes` propery which outputs `false` if the request fails and outputs the volume data in `{[keywordID]: volume}` object if succeeds.
|
||||
* The `error` porperty that outputs the error message if any.
|
||||
*/
|
||||
export const getKeywordsVolume = async (keywords: KeywordType[]): Promise<{error?: string, volumes: false | Record<number, number>}> => {
|
||||
const credentials = await getAdwordsCredentials();
|
||||
if (!credentials) { return { error: 'Cannot Load Google Ads Credentials', volumes: false }; }
|
||||
const { client_id, client_secret, developer_token, account_id } = credentials;
|
||||
if (!client_id || !client_secret || !developer_token || !account_id) {
|
||||
return { error: 'Google Ads Not Integrated Properly', volumes: false };
|
||||
}
|
||||
|
||||
// Generate Access Token
|
||||
let accessToken = '';
|
||||
const cachedAccessToken:string|false|undefined = memoryCache.get('adwords_token');
|
||||
if (cachedAccessToken) {
|
||||
accessToken = cachedAccessToken;
|
||||
} else {
|
||||
accessToken = await getAdwordsAccessToken(credentials);
|
||||
memoryCache.delete('adwords_token');
|
||||
memoryCache.set('adwords_token', accessToken, { ttl: 3300000 });
|
||||
}
|
||||
const fetchedKeywords:Record<number, number> = {};
|
||||
|
||||
if (accessToken) {
|
||||
// Group keywords based on their country.
|
||||
const keywordRequests: Record<string, KeywordType[]> = {};
|
||||
keywords.forEach((kw) => {
|
||||
const kwCountry = kw.country;
|
||||
if (keywordRequests[kwCountry]) {
|
||||
keywordRequests[kwCountry].push(kw);
|
||||
} else {
|
||||
keywordRequests[kwCountry] = [kw];
|
||||
}
|
||||
});
|
||||
|
||||
// Send Requests to adwords based on grouped countries.
|
||||
// Since adwords does not allow sending country data for each keyword we are making requests for.
|
||||
for (const country in keywordRequests) {
|
||||
if (Object.hasOwn(keywordRequests, country) && keywordRequests[country].length > 0) {
|
||||
try {
|
||||
// API: https://developers.google.com/google-ads/api/rest/reference/rest/v18/customers/generateKeywordHistoricalMetrics
|
||||
const customerID = account_id.replaceAll('-', '');
|
||||
const geoTargetConstants = countries[country][3]; // '2840';
|
||||
const reqKeywords = keywordRequests[country].map((kw) => kw.keyword);
|
||||
const reqPayload: Record<string, any> = {
|
||||
keywords: [...new Set(reqKeywords)],
|
||||
geoTargetConstants: `geoTargetConstants/${geoTargetConstants}`,
|
||||
// language: `languageConstants/${language}`,
|
||||
};
|
||||
const resp = await fetch(`https://googleads.googleapis.com/v18/customers/${customerID}:generateKeywordHistoricalMetrics`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'developer-token': developer_token,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'login-customer-id': customerID,
|
||||
},
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const ideaData = await resp.json();
|
||||
|
||||
if (resp.status !== 200) {
|
||||
console.log('[ERROR] Google Ads Volume Request Response :', ideaData?.error?.details[0]?.errors[0]?.message);
|
||||
// console.log('Response from Google Ads :', JSON.stringify(ideaData, null, 2));
|
||||
}
|
||||
|
||||
if (ideaData?.results) {
|
||||
if (Array.isArray(ideaData.results) && ideaData.results.length > 0) {
|
||||
const volumeDataObj:Map<string, number> = new Map();
|
||||
ideaData.results.forEach((item:{ keywordMetrics: keywordIdeasMetrics, text: string }) => {
|
||||
const kwVol = item?.keywordMetrics?.avgMonthlySearches;
|
||||
volumeDataObj.set(`${country}:${item.text}`, kwVol ? parseInt(kwVol, 10) : 0);
|
||||
});
|
||||
|
||||
keywordRequests[country].forEach((keyword) => {
|
||||
const keywordKey = `${keyword.country}:${keyword.keyword}`;
|
||||
if (volumeDataObj.has(keywordKey)) {
|
||||
const volume = volumeDataObj.get(keywordKey);
|
||||
if (volume !== undefined) {
|
||||
fetchedKeywords[keyword.ID] = volume;
|
||||
}
|
||||
}
|
||||
});
|
||||
// console.log('fetchedKeywords :', fetchedKeywords);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Fetching Keyword Volume from Google Ads :', error);
|
||||
}
|
||||
if (Object.keys(keywordRequests).length > 1) {
|
||||
await sleep(7000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { volumes: fetchedKeywords };
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates volume data for keywords in the Keywords database using async/await and error handling.
|
||||
* @param {false | Record<number, number>} volumesData - The `volumesData` parameter can either be `false` or an object containing
|
||||
* keyword IDs as keys and corresponding volume data as values.
|
||||
* @returns returns a Promise that resolves to `true` if `volumesData` is not `false` else it returns `false`.
|
||||
*/
|
||||
export const updateKeywordsVolumeData = async (volumesData: false | Record<number, number>) => {
|
||||
if (volumesData === false) { return false; }
|
||||
|
||||
Object.keys(volumesData).forEach(async (keywordID) => {
|
||||
const keyID = parseInt(keywordID, 10);
|
||||
const volumeData = volumesData && volumesData[keyID] ? volumesData[keyID] : 0;
|
||||
try {
|
||||
await Keyword.update({ volume: volumeData }, { where: { ID: keyID } });
|
||||
} catch (error) {
|
||||
console.log('');
|
||||
}
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* The function `getLocalKeywordIdeas` reads keyword ideas data from a local JSON file based on a domain slug and returns it as a Promise.
|
||||
* @param {string} domain - The `domain` parameter is the domain slug for which the keyword Ideas are fetched.
|
||||
* @returns returns either a `KeywordIdeasDatabase` object if the data is successfully retrieved , or it returns `false` if
|
||||
* there are no keywords found in the retrieved data or if an error occurs during the process.
|
||||
*/
|
||||
export const getLocalKeywordIdeas = async (domain:string): Promise<false | KeywordIdeasDatabase> => {
|
||||
try {
|
||||
const domainName = domain.replaceAll('-', '.').replaceAll('_', '-');
|
||||
const filename = `IDEAS_${domainName}.json`;
|
||||
const keywordIdeasRaw = await readFile(`${process.cwd()}/data/${filename}`, { encoding: 'utf-8' });
|
||||
const keywordIdeasData = JSON.parse(keywordIdeasRaw) as KeywordIdeasDatabase;
|
||||
if (keywordIdeasData.keywords) {
|
||||
return keywordIdeasData;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
// console.log('[ERROR] Getting Local Ideas. ', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The function `updateLocalKeywordIdeas` updates a local JSON file containing keyword ideas for a specific domain with new data provided.
|
||||
* @param {string} domain - The `domain` parameter is the domain slug for which the keyword Ideas are being updated.
|
||||
* @param {IdeaDatabaseUpdateData} data - The `data` parameter is an object of type `IdeaDatabaseUpdateData`.
|
||||
* It contains the following properties: `keywords`, `favorites` & `settings`
|
||||
* @returns The function `updateLocalKeywordIdeas` returns a Promise<boolean>.
|
||||
*/
|
||||
export const updateLocalKeywordIdeas = async (domain:string, data:IdeaDatabaseUpdateData): Promise<boolean> => {
|
||||
try {
|
||||
const domainName = domain.replaceAll('-', '.').replaceAll('_', '-');
|
||||
const existingIdeas = await getLocalKeywordIdeas(domain);
|
||||
const filename = `IDEAS_${domainName}.json`;
|
||||
const fileContent = { ...existingIdeas, updated: new Date().getTime() };
|
||||
if (data.keywords && Array.isArray(data.keywords) && data.keywords.length > 0) {
|
||||
fileContent.keywords = data.keywords;
|
||||
}
|
||||
if (data.favorites && Array.isArray(data.favorites) && data.favorites.length > 0) {
|
||||
fileContent.favorites = data.favorites;
|
||||
}
|
||||
if (data.settings) {
|
||||
fileContent.settings = data.settings;
|
||||
}
|
||||
|
||||
await writeFile(`${process.cwd()}/data/${filename}`, JSON.stringify(fileContent, null, 2), 'utf-8');
|
||||
console.log(`Data saved to ${filename} successfully!`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Error] Saving data to IDEAS_${domain}.json: ${error}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user