feat: Adds Google Adwords Integration to allow generating Keyword Ideas.

- Integrates Google Adwords API to generate keywords for a domain.
- Adds a New Adwords Integration ui inside the App settings > Integrations screen to integrate Google Adwords.
- Adds a New Ideas tab under each domain.
- Adds ability to automatically generate keyword ideas based on website content, currently tracked keywords, Currently Ranking keywords or custom keywords
- The Keyword Ideas are not saved in database, they are saved in a local file inside the data folder. File naming convention: IDEAS_domain.com.json
- The keywords can be marked as favorites, and each time a keyword is favorited, they are added in the IDEAS_domain.com.json file.
This commit is contained in:
towfiqi
2024-02-28 19:19:23 +06:00
parent 83c47452fc
commit 5650645b58
33 changed files with 3509 additions and 305 deletions

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="settings__section__secret mb-5 relative flex justify-between items-center">
<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"

View File

@@ -12,6 +12,7 @@ type SelectFieldProps = {
label?: string,
multiple?: boolean,
updateField: Function,
fullWidth?: boolean,
minWidth?: number,
maxHeight?: number|string,
rounded?: string,
@@ -27,6 +28,7 @@ const SelectField = (props: SelectFieldProps) => {
updateField,
minWidth = 180,
maxHeight = 96,
fullWidth = false,
rounded = 'rounded-3xl',
flags = false,
label = '',
@@ -71,8 +73,8 @@ const SelectField = (props: SelectFieldProps) => {
<div className="select font-semibold text-gray-500 relative flex justify-between items-center">
{label && <label className='mb-2 font-semibold inline-block text-sm text-gray-700 capitalize'>{label}</label>}
<div
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[210px] 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-full inline-block truncate mr-2 capitalize'}>
{selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel}
@@ -83,7 +85,7 @@ const SelectField = (props: SelectFieldProps) => {
</div>
{showOptions && (
<div
className={`select_list mt-1 border absolute min-w-[${minWidth}px] top-[30px] right-0 w-[210px]
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=''>

View File

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

View File

@@ -1,18 +1,18 @@
type ToggleFieldProps = {
label: string;
value: string;
value: boolean;
onChange: (bool:boolean) => void ;
classNames?: string;
}
const ToggleField = ({ label = '', value = '', onChange, classNames = '' }: ToggleFieldProps) => {
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-56">{label}</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-300 w-auto">{label}</span>
<input
type="checkbox"
value={value}
value={value.toString()}
checked={!!value}
className="sr-only peer"
onChange={() => onChange(!value)}

View File

@@ -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 Adwords'
/>
</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>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<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 { 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<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: 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 (
<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-8'>
{selectedKeywords.length > 0 && (
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
<div className='inline-block'>Add Keywords to Tracker</div>
<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 Adwords has not been Integrated yet. Please follow <a className='text-indigo-600 underline' href='https://docs.serpbear.com/miscellaneous/integrate-google-adwords' target="_blank" rel='noreferrer'>These Steps</a> to integrate Google Adwords.
</p>
)}
</div>
</div>
</div>
</div>
{showKeyDetails && showKeyDetails.uid && (
<IdeaDetails keyword={showKeyDetails} closeDetails={() => setShowKeyDetails(null)} />
)}
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};
export default IdeasKeywordsTable;

View File

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

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { 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 cloudProjectIntegrated = adwords_client_id && adwords_client_secret && adwords_refresh_token;
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 (adwords_client_id && adwords_client_secret && adwords_refresh_token && adwords_developer_token && adwords_account_id) {
testAdWordsIntegration({ developer_token: adwords_developer_token, account_id: adwords_account_id });
}
};
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 AdWords</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='AdWords 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
${adwords_client_id && adwords_client_secret && adwords_refresh_token ? '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={testIntegration}>
{isTesting && <Icon type='loading' />}
<Icon type='adwords' size={14} /> Test AdWords Integration
</button>
</div>
</div>
</div>
<p className='mb-4 text-xs'>
<a target='_blank' rel='noreferrer' href='https://docs.serpbear.com/keyword-research' className=' underline text-blue-600'>Integrate Google Adwords</a> to get Keyword Ideas & Search Volume.{' '}
</p>
</div>
</div>
);
};
export default AdWordsSettings;

View File

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

View File

@@ -57,13 +57,15 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
/>
</div>
{settings.scraper_type !== 'none' && settings.scraper_type !== 'proxy' && (
<SecretField
label='Scraper API Key or Token'
placeholder={'API Key/Token'}
value={settings?.scaping_api || ''}
hasError={settingsError?.type === 'no_api_key'}
onChange={(value:string) => updateSettings('scaping_api', value)}
/>
<div className="settings__section__secret mb-5">
<SecretField
label='Scraper API Key or Token'
placeholder={'API Key/Token'}
value={settings?.scaping_api || ''}
hasError={settingsError?.type === 'no_api_key'}
onChange={(value:string) => updateSettings('scaping_api', value)}
/>
</div>
)}
{settings.scraper_type === 'proxy' && (
<div className="settings__section__input mb-5">
@@ -111,7 +113,7 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
<div className="settings__section__input mb-5">
<ToggleField
label='Auto Retry Failed Keyword Scrape'
value={settings?.scrape_retry ? 'true' : '' }
value={!!settings?.scrape_retry }
onChange={(val) => updateSettings('scrape_retry', val)}
/>
</div>

View File

@@ -1,5 +1,4 @@
import React from 'react';
import ToggleField from '../common/ToggleField';
import InputField from '../common/InputField';
type SearchConsoleSettingsProps = {
@@ -14,7 +13,7 @@ type SearchConsoleSettingsProps = {
const SearchConsoleSettings = ({ settings, settingsError, updateSettings }:SearchConsoleSettingsProps) => {
return (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div>
{/* <div className="settings__section__input mb-5">
<ToggleField

View File

@@ -5,7 +5,7 @@ import Icon from '../common/Icon';
import NotificationSettings from './NotificationSettings';
import ScraperSettings from './ScraperSettings';
import useOnKey from '../../hooks/useOnKey';
import SearchConsoleSettings from './SearchConsoleSettings';
import IntegrationSettings from './IntegrationSettings';
type SettingsProps = {
closeSettings: Function,
@@ -118,9 +118,9 @@ const Settings = ({ closeSettings }:SettingsProps) => {
<Icon type='email' /> Notification
</li>
<li
className={`${tabStyle} ${currentTab === 'searchconsole' ? tabStyleActive : 'border-transparent'}`}
onClick={() => setCurrentTab('searchconsole')}>
<Icon type='google' size={14} /> Search Console
className={`${tabStyle} ${currentTab === 'integrations' ? tabStyleActive : 'border-transparent'}`}
onClick={() => setCurrentTab('integrations')}>
<Icon type='integration' size={14} /> Integrations
</li>
</ul>
</div>
@@ -131,8 +131,14 @@ const Settings = ({ closeSettings }:SettingsProps) => {
{currentTab === 'notification' && settings && (
<NotificationSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
{currentTab === 'searchconsole' && settings && (
<SearchConsoleSettings 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

26
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.3",
"dependencies": {
"@googleapis/searchconsole": "^1.0.0",
"@isaacs/ttlcache": "^1.4.1",
"@types/react-transition-group": "^4.4.5",
"axios": "^1.1.3",
"axios-retry": "^3.3.1",
@@ -20,6 +21,7 @@
"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",
@@ -944,6 +946,14 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@isaacs/ttlcache": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz",
"integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==",
"engines": {
"node": ">=12"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -6318,9 +6328,9 @@
}
},
"node_modules/gcp-metadata": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.0.0.tgz",
"integrity": "sha512-Ozxyi23/1Ar51wjUT2RDklK+3HxqDr8TLBNK8rBBFQ7T85iIGnXnVusauj06QyqCXRFZig8LZC+TUddWbndlpQ==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz",
"integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==",
"dependencies": {
"gaxios": "^6.0.0",
"json-bigint": "^1.0.0"
@@ -6702,14 +6712,14 @@
}
},
"node_modules/google-auth-library": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.2.0.tgz",
"integrity": "sha512-1oV3p0JhNEhVbj26eF3FAJcv9MXXQt4S0wcvKZaDbl4oHq5V3UJoSbsGZGQNcjoCdhW4kDSwOs11wLlHog3fgQ==",
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.6.3.tgz",
"integrity": "sha512-4CacM29MLC2eT9Cey5GDVK4Q8t+MMp8+OEdOaqD9MG6b0dOyLORaaeJMPQ7EESVgm/+z5EKYyFLxgzBJlJgyHQ==",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^6.0.0",
"gcp-metadata": "^6.0.0",
"gaxios": "^6.1.1",
"gcp-metadata": "^6.1.0",
"gtoken": "^7.0.0",
"jws": "^4.0.0"
},

View File

@@ -19,6 +19,7 @@
},
"dependencies": {
"@googleapis/searchconsole": "^1.0.0",
"@isaacs/ttlcache": "^1.4.1",
"@types/react-transition-group": "^4.4.5",
"axios": "^1.1.3",
"axios-retry": "^3.3.1",
@@ -30,6 +31,7 @@
"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",

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

@@ -0,0 +1,97 @@
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);
// console.log('code :', code);
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 redirectURL = `${process.env.NEXT_PUBLIC_APP_URL}/api/adwords`;
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('Adwords Intergrated Successfully! You can close this window.');
}
return res.status(200).send('Error Getting the Adwords Refresh Token. Please Try Again!');
} catch (error) {
console.log('[Error] Getting Adwords Refresh Token!');
console.log('error :', error);
return res.status(200).send('Error Saving the Adwords Refresh Token. Please Try Again!');
}
} else {
return res.status(200).send('No Code Provided By Google. Please Try Again!');
}
} catch (error) {
console.log('[ERROR] CRON Refreshing Keywords: ', error);
return res.status(400).send('Error Getting Adwords Refresh Token. Please Try Again!');
}
};
const validateAdwordsIntegration = async (req: NextApiRequest, res: NextApiResponse<adwordsValidateResp>) => {
const errMsg = 'Error Validating Adwords 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 Adwords Developer Token and Test Account ID' });
}
try {
// Save the Adwords Developer Token & Adwords 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 Adwords
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 AdWords Integration: ', error);
return res.status(400).json({ valid: false, error: errMsg });
}
};

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

@@ -0,0 +1,110 @@
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 && keywordsDatabase.favorites) {
const theKeyword = keywordsDatabase.keywords.find((kw) => kw.uid === keywordID);
const newFavorites = [...keywordsDatabase.favorites];
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: keywordsDatabase.favorites, error: '' });
}
}
return res.status(400).json({ keywords: [], error: errMsg });
} catch (error) {
console.log('[ERROR] Favorating Keyword Idea: ', error);
return res.status(400).json({ keywords: [], error: errMsg });
}
};

View File

@@ -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,46 @@ 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: '',
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!' });
}
};

View File

@@ -46,7 +46,22 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password.trim()) : '';
const search_console_client_email = settings.search_console_client_email ? cryptr.encrypt(settings.search_console_client_email.trim()) : '';
const search_console_private_key = settings.search_console_private_key ? cryptr.encrypt(settings.search_console_private_key.trim()) : '';
const securedSettings = { ...settings, scaping_api, smtp_password, search_console_client_email, search_console_private_key };
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 });
@@ -71,6 +86,11 @@ export const getAppSettings = async () : Promise<SettingsType> => {
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,
@@ -82,6 +102,10 @@ export const getAppSettings = async () : Promise<SettingsType> => {
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!');

View File

@@ -0,0 +1,112 @@
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';
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 Adwords'} verticalCenter={true}>
<KeywordIdeasUpdater
domain={activDomain}
onUpdate={() => setShowUpdateModal(false)}
settings={keywordIdeasSettings}
searchConsoleConnected={searchConsoleConnected}
adwordsConnected={adwordsConnected}
/>
</Modal>
)}
</div>
);
};
export default DiscoverPage;

100
services/adwords.tsx Normal file
View File

@@ -0,0 +1,100 @@
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 Adwords has been integrated successfully!', { icon: '✔️' });
if (onSuccess) {
onSuccess(false);
}
},
onError: (error) => {
console.log('Error Loading Keyword Ideas!!!', error);
toast('Failed to connect to Google Adwords. Please make sure you have provided the correct API info.', { icon: '⚠️' });
},
});
}
export async function fetchAdwordsKeywordIdeas(router: NextRouter) {
// if (!router.query.slug) { throw new Error('Invalid Domain Name'); }
const res = await fetch(`${window.location.origin}/api/ideas?domain=${router.query.slug}`, { 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) {
return useQuery(
`keywordIdeas-${router.query.slug}`,
() => router.query.slug && fetchAdwordsKeywordIdeas(router),
{ enabled: adwordsConnected, retry: false },
);
}
export function useMutateKeywordIdeas(router:NextRouter, onSuccess?: Function) {
const queryClient = useQueryClient();
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-${router.query.slug}`]);
},
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();
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-${router.query.slug}`]);
},
onError: (error) => {
console.log('Error Favorating Keywords', error);
toast('Error Favorating Keywords', { icon: '⚠️' });
},
});
}

View File

@@ -178,3 +178,16 @@ export function useFetchSingleKeyword(keywordID:number) {
},
});
}
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();
}

View File

@@ -283,6 +283,24 @@ body {
}
}
.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;
}
/* Disable LastPass Icon for Secret Field */
[autocomplete="off"] + div[data-lastpass-icon-root="true"], [autocomplete="off"] + div[data-lastpass-infield="true"] {
display: none;

39
types.d.ts vendored
View File

@@ -16,6 +16,7 @@ type DomainType = {
scImpressions?: number,
scPosition?: number,
search_console?: string,
ideas_settings?: string,
}
type KeywordHistory = {
@@ -56,7 +57,7 @@ type KeywordFilters = {
}
type countryData = {
[ISO:string] : [countryName:string, cityName:string, language:string]
[ISO:string] : [countryName:string, cityName:string, language:string, AdWordsID: number]
}
type countryCodeData = {
@@ -98,6 +99,11 @@ type SettingsType = {
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,
}
type KeywordSCDataChild = {
@@ -185,6 +191,37 @@ 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,

304
utils/adwords.ts Normal file
View File

@@ -0,0 +1,304 @@
import { readFile, writeFile } from 'fs/promises';
import Cryptr from 'cryptr';
import TTLCache from '@isaacs/ttlcache';
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 keywordIdeasResponseItem = {
keywordIdeaMetrics: {
competition: IdeaKeyword['competition'],
monthlySearchVolumes: {month : string, year : string, monthlySearches : string}[],
avgMonthlySearches: string,
competitionIndex: string,
lowTopOfPageBidMicros: string,
highTopOfPageBidMicros: string
},
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 Adwords 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 Adwords 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 AdWords API based on
* provided credentials and settings.
* @param {AdwordsCredentials} credentials - an object containing Adwords credentials needed to authenticate
* the API request.
* @param {IdeaSettings} adwordsDomainOptions - an object that contains settings and options for fetching
* keyword ideas from Google AdWords.
* @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 Adwords 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/v16/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 (seedType === 'custom' && 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/v16/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] Adwords Response :', ideaData?.error?.details[0]?.errors[0]?.message);
// console.log('Response from AdWords :', 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 Adwords :', 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> = {};
monthlySearchVolumes.forEach((item) => {
searchVolumeTrend[`${item.month}-${item.year}`] = item.monthlySearches;
});
keywords.push({
uid: `${country.toLowerCase()}:${text.replaceAll(' ', '-')}`,
keyword: text,
competition,
competitionIndex: competitionIndex !== null ? parseInt(competitionIndex, 10) : 0,
monthlySearchVolumes: searchVolumeTrend,
avgMonthlySearches: parseInt(avgMonthlySearches, 10),
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));
};
/**
* 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;
}
};

View File

@@ -0,0 +1,55 @@
/**
* Sorrt Keyword Ideas by user's given input.
* @param {IdeaKeyword[]} theKeywords - The Keywords to sort.
* @param {string} sortBy - The sort method.
* @returns {IdeaKeyword[]}
*/
export const IdeasSortKeywords = (theKeywords:IdeaKeyword[], sortBy:string) : IdeaKeyword[] => {
let sortedItems = [];
switch (sortBy) {
case 'vol_asc':
sortedItems = theKeywords.sort((a: IdeaKeyword, b: IdeaKeyword) => (a.avgMonthlySearches > b.avgMonthlySearches ? 1 : -1));
break;
case 'vol_desc':
sortedItems = theKeywords.sort((a: IdeaKeyword, b: IdeaKeyword) => (b.avgMonthlySearches > a.avgMonthlySearches ? 1 : -1));
break;
case 'competition_asc':
sortedItems = theKeywords.sort((a: IdeaKeyword, b: IdeaKeyword) => (b.competitionIndex > a.competitionIndex ? 1 : -1));
break;
case 'competition_desc':
sortedItems = theKeywords.sort((a: IdeaKeyword, b: IdeaKeyword) => (a.competitionIndex > b.competitionIndex ? 1 : -1));
break;
default:
return theKeywords;
}
return sortedItems;
};
/**
* Filters the keyword Ideas by country, search string or tags.
* @param {IdeaKeyword[]} keywords - The keywords.
* @param {KeywordFilters} filterParams - The user Selected filter object.
* @returns {IdeaKeyword[]}
*/
export const IdeasfilterKeywords = (keywords: IdeaKeyword[], filterParams: KeywordFilters):IdeaKeyword[] => {
const filteredItems:IdeaKeyword[] = [];
keywords.forEach((keywrd) => {
const { keyword, country } = keywrd;
const countryMatch = filterParams.countries.length === 0 ? true : filterParams.countries && filterParams.countries.includes(country);
const searchMatch = !filterParams.search ? true : filterParams.search && keyword.includes(filterParams.search);
const tagsMatch = filterParams.tags.length === 0 ? true : filterParams.tags.find((tag) => {
const theTag = tag.replace(/\s*\(\d+\)/, '');
const reversedTag = theTag.split(' ').reverse().join(' ');
return keyword.includes(theTag) || keyword.includes(reversedTag);
});
if (countryMatch && searchMatch && tagsMatch) {
filteredItems.push(keywrd);
}
});
return filteredItems;
};

View File

@@ -31,6 +31,23 @@ const exportCSV = (keywords: KeywordType[] | SCKeywordType[], domain:string, scD
});
}
downloadCSV(csvHeader, csvBody, fileName);
};
export const exportKeywordIdeas = (keywords: IdeaKeyword[], domainName:string) => {
const csvHeader = 'Keyword,Volume,Competition,CompetitionScore,Country,Added\r\n';
let csvBody = '';
const fileName = `${domainName}-keyword_ideas.csv`;
keywords.forEach((keywordData) => {
const { keyword, competition, country, domain, competitionIndex, avgMonthlySearches, added, updated, position } = keywordData;
// eslint-disable-next-line max-len
const addedDate = new Intl.DateTimeFormat('en-US').format(new Date(added));
csvBody += `${keyword}, ${avgMonthlySearches}, ${competition}, ${competitionIndex}, ${countries[country][0]}, ${addedDate}\r\n`;
});
downloadCSV(csvHeader, csvBody, fileName);
};
const downloadCSV = (csvHeader:string, csvBody:string, fileName:string) => {
const blob = new Blob([csvHeader + csvBody], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');

File diff suppressed because it is too large Load Diff