diff --git a/components/common/Chart.tsx b/components/common/Chart.tsx index d5ff0e4..3803ede 100644 --- a/components/common/Chart.tsx +++ b/components/common/Chart.tsx @@ -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: { diff --git a/components/common/ChartSlim.tsx b/components/common/ChartSlim.tsx index ba91bb4..3c6dad2 100644 --- a/components/common/ChartSlim.tsx +++ b/components/common/ChartSlim.tsx @@ -6,10 +6,11 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, type ChartProps ={ labels: string[], - sreies: number[] + sreies: number[], + noMaxLimit?: boolean } -const ChartSlim = ({ labels, sreies }:ChartProps) => { +const ChartSlim = ({ labels, sreies, noMaxLimit = false }:ChartProps) => { const options = { responsive: true, maintainAspectRatio: false, @@ -19,7 +20,7 @@ const ChartSlim = ({ labels, sreies }:ChartProps) => { display: false, reverse: true, min: 1, - max: 100, + max: noMaxLimit ? undefined : 100, }, x: { display: false, diff --git a/components/common/Icon.tsx b/components/common/Icon.tsx index 5e2cc2c..068bec9 100644 --- a/components/common/Icon.tsx +++ b/components/common/Icon.tsx @@ -131,13 +131,13 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = ' } {type === 'star' && - - + + } {type === 'star-filled' && - - + + } {type === 'link' && @@ -208,6 +208,26 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = ' } + {type === 'adwords' + && + + + + + + + } + {type === 'keywords' + && + + + } + {type === 'integration' + && + + + } + {type === 'cursor' && diff --git a/components/common/SecretField.tsx b/components/common/SecretField.tsx index c842f0f..5ce9de4 100644 --- a/components/common/SecretField.tsx +++ b/components/common/SecretField.tsx @@ -14,7 +14,7 @@ const SecretField = ({ label = '', value = '', placeholder = '', onChange, hasEr const [showValue, setShowValue] = useState(false); const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize'; return ( -
+
{ updateField, minWidth = 180, maxHeight = 96, + fullWidth = false, rounded = 'rounded-3xl', flags = false, label = '', @@ -71,8 +73,8 @@ const SelectField = (props: SelectFieldProps) => {
{label && }
setShowOptions(!showOptions)}> {selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel} @@ -83,7 +85,7 @@ const SelectField = (props: SelectFieldProps) => {
{showOptions && (
{options.length > 20 && (
diff --git a/components/common/Sidebar.tsx b/components/common/Sidebar.tsx index e75b187..e7a9cb0 100644 --- a/components/common/Sidebar.tsx +++ b/components/common/Sidebar.tsx @@ -25,7 +25,7 @@ const Sidebar = ({ domains, showAddModal } : SidebarProps) => { void ; classNames?: string; } -const ToggleField = ({ label = '', value = '', onChange, classNames = '' }: ToggleFieldProps) => { +const ToggleField = ({ label = '', value = false, onChange, classNames = '' }: ToggleFieldProps) => { return (
-
-
    +
    + -
    +
    {!isInsight && @@ -89,7 +107,7 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma Export as csv )} - {!isConsole && !isInsight && ( + {!isConsole && !isInsight && !isIdeas && (
    - {!isConsole && !isInsight && ( + {!isConsole && !isInsight && !isIdeas && (
    )} + {isIdeas && ( + + )}
diff --git a/components/ideas/IdeaDetails.tsx b/components/ideas/IdeaDetails.tsx new file mode 100644 index 0000000..43caf2f --- /dev/null +++ b/components/ideas/IdeaDetails.tsx @@ -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(null); + const searchResultFound = useRef(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 ( +
+
+
+

+ {keyword.keyword} + + {formattedNum(keyword.avgMonthlySearches)}/month + +

+ +
+
+ +
+
+

Search Volume Trend

+
+
+ +
+
+
+
+

Google Search Result + + + +

+ {dayjs(updatedDate).format('MMMM D, YYYY')} +
+
+ {!searchResultsFetched && ( +
+

View Google Search Results for "{keyword.keyword}"

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

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

+ {/*

{item.description}

*/} + {item.url} +
+ ); + }) + )} +
+
+ +
+
+
+
+ ); +}; + +export default IdeaDetails; diff --git a/components/ideas/IdeasFilter.tsx b/components/ideas/IdeasFilter.tsx new file mode 100644 index 0000000..65b2f9c --- /dev/null +++ b/components/ideas/IdeasFilter.tsx @@ -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) => { + 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 ( +
+
+
    +
  • { setKeywordType('all'); showFavorites(false); }}> + + All Keywords + {keywords.length} +
  • +
  • { setKeywordType('favorites'); showFavorites(true); }}> + + Favorites + {favorites.length} +
  • +
+
+ +
+ +
+ +
+
+ {keywordType === 'all' && ( +
+ ({ label: tag, value: tag }))} + defaultLabel={`All Groups (${allTags.length})`} + updateField={(updated:string[]) => filterTags(updated)} + emptyMsg="No Groups Found for this Domain" + minWidth={270} + /> +
+ )} +
+ +
+
+
+ + {sortOptions && ( +
    + {sortOptionChoices.map((sortOption) => { + return
  • { updateSort(sortOption.value); showSortOptions(false); }}> + {sortOption.label} +
  • ; + })} +
+ )} +
+
+ +
+ ); +}; + +export default IdeasFilters; diff --git a/components/ideas/KeywordIdea.tsx b/components/ideas/KeywordIdea.tsx new file mode 100644 index 0000000..d2cb7f6 --- /dev/null +++ b/components/ideas/KeywordIdea.tsx @@ -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 ( +
+ +
+ + showKeywordDetails()}> + {keyword} + + +
+ +
+ {formattedNum(avgMonthlySearches)}/month +
+ +
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 && } +
+ +
+
+ {competitionIndex}/100 + {competition} +
+
+
+ ); + }; + + export default KeywordIdea; diff --git a/components/ideas/KeywordIdeasTable.tsx b/components/ideas/KeywordIdeasTable.tsx new file mode 100644 index 0000000..0d49f8b --- /dev/null +++ b/components/ideas/KeywordIdeasTable.tsx @@ -0,0 +1,220 @@ +import { useRouter } from 'next/router'; +import React, { useState, useMemo } from 'react'; +import { Toaster } from 'react-hot-toast'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import { useAddKeywords } 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'; + +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([]); + const [showKeyDetails, setShowKeyDetails] = useState(null); + const [filterParams, setFilterParams] = useState({ countries: [], tags: [], search: '' }); + const [sortBy, setSortBy] = useState('imp_desc'); + const [listHeight, setListHeight] = useState(500); + const [addKeywordDevice, setAddKeywordDevice] = useState<'desktop'|'mobile'>('desktop'); + const { mutate: addKeywords } = useAddKeywords(() => { if (domain && domain.slug) router.push(`/domain/${domain.slug}`); }); + const { mutate: faveKeyword, isLoading: isFaving } = useMutateFavKeywordIdeas(router); + const [isMobile] = useIsMobile(); + 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 = 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: 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: domain?.domain || '', tags: '' }); + } + }); + addKeywords(selectedkeywords); + setSelectedKeywords([]); + }; + + const selectedAllItems = selectedKeywords.length === finalKeywords.length; + + const Row = ({ data, index, style }:ListChildComponentProps) => { + const keyword: IdeaKeyword = data[index]; + return ( + favoriteKeyword(keyword.uid)} + showKeywordDetails={() => setShowKeyDetails(keyword)} + isFavorite={favoriteIDs.includes(keyword.uid)} + keywordData={keyword} + lastItem={index === (finalKeywords.length - 1)} + /> + ); + }; + + return ( +
+
+ {selectedKeywords.length > 0 && ( +
+
Add Keywords to Tracker
+
+ + +
+ addKeywordIdeasToTracker()} + > + + Add Keywords + +
+ )} + {selectedKeywords.length === 0 && ( + setFilterParams(params)} + updateSort={(sorted:string) => setSortBy(sorted)} + sortBy={sortBy} + keywords={keywords} + favorites={favorites} + showFavorites={(show:boolean) => { setShowFavorites(show); }} + /> + )} +
+
+ +
+ {!isLoading && finalKeywords && finalKeywords.length > 0 && ( + + {Row} + + )} + + {isAdwordsIntegrated && isLoading && ( +

Loading Keywords Ideas...

+ )} + {isAdwordsIntegrated && noIdeasDatabase && !isLoading && ( +

+ {'No keyword Ideas has been generated for this domain yet. Click the "Load Ideas" button to generate keyword ideas.'} +

+ )} + {isAdwordsIntegrated && !isLoading && finalKeywords.length === 0 && !noIdeasDatabase && ( +

+ {'No Keyword Ideas found. Please try generating Keyword Ideas again by clicking the "Load Ideas" button.'} +

+ )} + {!isAdwordsIntegrated && ( +

+ Google Adwords has not been Integrated yet. Please follow These Steps to integrate Google Adwords. +

+ )} +
+
+
+
+ {showKeyDetails && showKeyDetails.uid && ( + setShowKeyDetails(null)} /> + )} + +
+ ); + }; + + export default IdeasKeywordsTable; diff --git a/components/ideas/KeywordIdeasUpdater.tsx b/components/ideas/KeywordIdeasUpdater.tsx new file mode 100644 index 0000000..2f0c5e9 --- /dev/null +++ b/components/ideas/KeywordIdeasUpdater.tsx @@ -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(() => 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 ( +
+ +
+
+ + setSeedType(updated[0])} + fullWidth={true} + multiple={false} + rounded='rounded' + /> +
+ {seedType === 'custom' && ( + <> +
+ +