mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
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:
@@ -8,9 +8,10 @@ type ChartProps ={
|
||||
labels: string[],
|
||||
sreies: number[],
|
||||
reverse? : boolean,
|
||||
noMaxLimit?: boolean
|
||||
}
|
||||
|
||||
const Chart = ({ labels, sreies, reverse = true }:ChartProps) => {
|
||||
const Chart = ({ labels, sreies, reverse = true, noMaxLimit = false }:ChartProps) => {
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -19,7 +20,7 @@ const Chart = ({ labels, sreies, reverse = true }:ChartProps) => {
|
||||
y: {
|
||||
reverse,
|
||||
min: 1,
|
||||
max: reverse ? 100 : undefined,
|
||||
max: !noMaxLimit && reverse ? 100 : undefined,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -6,10 +6,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,
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=''>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
139
components/ideas/IdeaDetails.tsx
Normal file
139
components/ideas/IdeaDetails.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import Icon from '../common/Icon';
|
||||
import countries from '../../utils/countries';
|
||||
import Chart from '../common/Chart';
|
||||
import useOnKey from '../../hooks/useOnKey';
|
||||
import { formattedNum } from '../../utils/client/helpers';
|
||||
import { fetchSearchResults } from '../../services/keywords';
|
||||
|
||||
type IdeaDetailsProps = {
|
||||
keyword: IdeaKeyword,
|
||||
closeDetails: Function
|
||||
}
|
||||
|
||||
const dummySearchResults = [
|
||||
{ position: 1, url: 'https://google.com/?search=dummy+text', title: 'Google Search Result One' },
|
||||
{ position: 1, url: 'https://yahoo.com/?search=dummy+text', title: 'Yahoo Results | Sample Dummy' },
|
||||
{ position: 1, url: 'https://gamespot.com/?search=dummy+text', title: 'GameSpot | Dummy Search Results' },
|
||||
{ position: 1, url: 'https://compressimage.com/?search=dummy+text', title: 'Compress Images Online' },
|
||||
];
|
||||
|
||||
const IdeaDetails = ({ keyword, closeDetails }:IdeaDetailsProps) => {
|
||||
const router = useRouter();
|
||||
const updatedDate = new Date(keyword.updated);
|
||||
const searchResultContainer = useRef<HTMLDivElement>(null);
|
||||
const searchResultFound = useRef<HTMLDivElement>(null);
|
||||
const searchResultReqPayload = { keyword: keyword.keyword, country: keyword.country, device: 'desktop' };
|
||||
const { data: keywordSearchResultData, refetch: fetchKeywordSearchResults, isLoading: fetchingResult } = useQuery(
|
||||
`ideas:${keyword.uid}`,
|
||||
() => fetchSearchResults(router, searchResultReqPayload),
|
||||
{ refetchOnWindowFocus: false, enabled: false },
|
||||
);
|
||||
const { monthlySearchVolumes } = keyword;
|
||||
|
||||
useOnKey('Escape', closeDetails);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const chartDataObj: { labels: string[], sreies: number[] } = { labels: [], sreies: [] };
|
||||
Object.keys(monthlySearchVolumes).forEach((dateKey:string) => {
|
||||
const dateKeyArr = dateKey.split('-');
|
||||
const labelDate = `${dateKeyArr[0].slice(0, 1).toUpperCase()}${dateKeyArr[0].slice(1, 3).toLowerCase()}, ${dateKeyArr[1].slice(2)}`;
|
||||
chartDataObj.labels.push(labelDate);
|
||||
chartDataObj.sreies.push(parseInt(monthlySearchVolumes[dateKey], 10));
|
||||
});
|
||||
return chartDataObj;
|
||||
}, [monthlySearchVolumes]);
|
||||
|
||||
const closeOnBGClick = (e:React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
if (e.target === e.currentTarget) { closeDetails(); }
|
||||
};
|
||||
|
||||
const searchResultsFetched = !!keywordSearchResultData?.searchResult?.results;
|
||||
const keywordSearchResult = searchResultsFetched ? keywordSearchResultData?.searchResult?.results : dummySearchResults;
|
||||
|
||||
return (
|
||||
<div className="IdeaDetails fixed w-full h-screen top-0 left-0 z-[99999]" onClick={closeOnBGClick} data-testid="IdeaDetails">
|
||||
<div className="IdeaDetails absolute w-full lg:w-5/12 bg-white customShadow top-0 right-0 h-screen" >
|
||||
<div className='IdeaDetails__header p-6 border-b border-b-slate-200 text-slate-500'>
|
||||
<h3 className=' text-lg font-bold'>
|
||||
<span title={countries[keyword.country][0]}
|
||||
className={`fflag fflag-${keyword.country} w-[18px] h-[12px] mr-2`} /> {keyword.keyword}
|
||||
<span className='py-1 px-2 ml-2 rounded bg-blue-50 text-blue-700 text-xs font-bold'>
|
||||
{formattedNum(keyword.avgMonthlySearches)}/month
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
className='absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
|
||||
onClick={() => closeDetails()}>
|
||||
<Icon type='close' size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className='IdeaDetails__content p-6'>
|
||||
|
||||
<div className='IdeaDetails__section'>
|
||||
<div className="IdeaDetails__section__head flex justify-between mb-5">
|
||||
<h3 className=' font-bold text-gray-700 text-lg'>Search Volume Trend</h3>
|
||||
</div>
|
||||
<div className='IdeaDetails__section__chart h-64'>
|
||||
<Chart labels={chartData.labels} sreies={chartData.sreies} noMaxLimit={true} reverse={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='IdeaDetails__section mt-10'>
|
||||
<div className="IdeaDetails__section__head flex justify-between items-center pb-4 mb-4 border-b border-b-slate-200">
|
||||
<h3 className=' font-bold text-gray-700 lg:text-lg'>Google Search Result
|
||||
<a className='text-gray-400 hover:text-indigo-600 inline-block ml-1 px-2 py-1'
|
||||
href={`https://www.google.com/search?q=${encodeURI(keyword.keyword)}`}
|
||||
target="_blank"
|
||||
rel='noreferrer'>
|
||||
<Icon type='link' size={14} />
|
||||
</a>
|
||||
</h3>
|
||||
<span className=' text-xs text-gray-500'>{dayjs(updatedDate).format('MMMM D, YYYY')}</span>
|
||||
</div>
|
||||
<div className={'keywordDetails__section__results styled-scrollbar overflow-y-auto relative'} ref={searchResultContainer}>
|
||||
{!searchResultsFetched && (
|
||||
<div className=' absolute flex w-full h-full justify-center items-center flex-col z-50 font-semibold'>
|
||||
<p>View Google Search Results for "{keyword.keyword}"</p>
|
||||
<button
|
||||
onClick={() => fetchKeywordSearchResults()}
|
||||
className='mt-4 text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white '>
|
||||
<Icon type={fetchingResult ? 'loading' : 'google'} /> {fetchingResult ? 'Performing' : 'Perform'} Google Search
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={`${!searchResultsFetched ? ' blur-sm ' : ''}`}>
|
||||
{keywordSearchResult && Array.isArray(keywordSearchResult) && keywordSearchResult.length > 0 && (
|
||||
keywordSearchResult.map((item, index) => {
|
||||
const { position } = keyword;
|
||||
const domainExist = position < 100 && index === (position - 1);
|
||||
return (
|
||||
<div
|
||||
ref={domainExist ? searchResultFound : null}
|
||||
className={`leading-6 mb-4 mr-3 p-3 text-sm break-all pr-3 rounded
|
||||
${domainExist ? ' bg-amber-50 border border-amber-200' : ''}`}
|
||||
key={item.url + item.position}>
|
||||
<h4 className='font-semibold text-blue-700'>
|
||||
<a href={item.url} target="_blank" rel='noreferrer'>{`${index + 1}. ${item.title}`}</a>
|
||||
</h4>
|
||||
{/* <p>{item.description}</p> */}
|
||||
<a className=' text-green-900' href={item.url} target="_blank" rel='noreferrer'>{item.url}</a>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdeaDetails;
|
||||
135
components/ideas/IdeasFilter.tsx
Normal file
135
components/ideas/IdeasFilter.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState } from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import SelectField, { SelectionOption } from '../common/SelectField';
|
||||
|
||||
type IdeasFilterProps = {
|
||||
allTags: string[],
|
||||
filterParams: KeywordFilters,
|
||||
filterKeywords: Function,
|
||||
keywords: IdeaKeyword[],
|
||||
favorites: IdeaKeyword[],
|
||||
updateSort: Function,
|
||||
showFavorites: Function,
|
||||
sortBy: string,
|
||||
}
|
||||
|
||||
const IdeasFilters = (props: IdeasFilterProps) => {
|
||||
const { filterKeywords, allTags = [], updateSort, showFavorites, sortBy, filterParams, keywords = [], favorites = [] } = props;
|
||||
const [keywordType, setKeywordType] = useState<'all'|'favorites'>('all');
|
||||
const [sortOptions, showSortOptions] = useState(false);
|
||||
const [filterOptions, showFilterOptions] = useState(false);
|
||||
|
||||
const filterTags = (tags:string[]) => filterKeywords({ ...filterParams, tags });
|
||||
|
||||
const searchKeywords = (event:React.FormEvent<HTMLInputElement>) => {
|
||||
const filtered = filterKeywords({ ...filterParams, search: event.currentTarget.value });
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const sortOptionChoices: SelectionOption[] = [
|
||||
{ value: 'alpha_asc', label: 'Alphabetically(A-Z)' },
|
||||
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
|
||||
{ value: 'vol_asc', label: 'Lowest Search Volume' },
|
||||
{ value: 'vol_desc', label: 'Highest Search Volume' },
|
||||
{ value: 'competition_asc', label: 'High Competition' },
|
||||
{ value: 'competition_desc', label: 'Low Competition' },
|
||||
];
|
||||
|
||||
const sortItemStyle = (sortType:string) => {
|
||||
return `cursor-pointer py-2 px-3 hover:bg-[#FCFCFF] ${sortBy === sortType ? 'bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''}`;
|
||||
};
|
||||
|
||||
const deviceTabStyle = 'select-none cursor-pointer px-3 py-2 rounded-3xl mr-2';
|
||||
const deviceTabCountStyle = 'px-2 py-0 rounded-3xl bg-[#DEE1FC] text-[0.7rem] font-bold ml-1';
|
||||
const mobileFilterOptionsStyle = 'visible mt-8 border absolute min-w-[0] rounded-lg max-h-96 bg-white z-50 w-52 right-2 p-4';
|
||||
|
||||
return (
|
||||
<div className='domKeywords_filters py-4 px-6 flex justify-between text-sm text-gray-500 font-semibold border-b-[1px] lg:border-0 items-center'>
|
||||
<div>
|
||||
<ul className='flex text-xs'>
|
||||
<li
|
||||
data-testid="desktop_tab"
|
||||
className={`${deviceTabStyle} ${keywordType === 'all' ? ' bg-[#F8F9FF] text-gray-700' : ''}`}
|
||||
onClick={() => { setKeywordType('all'); showFavorites(false); }}>
|
||||
<Icon type='keywords' classes='top-[3px]' size={15} />
|
||||
<i className='hidden not-italic lg:inline-block ml-1'>All Keywords</i>
|
||||
<span className={`${deviceTabCountStyle}`}>{keywords.length}</span>
|
||||
</li>
|
||||
<li
|
||||
data-testid="mobile_tab"
|
||||
className={`${deviceTabStyle} ${keywordType === 'favorites' ? ' bg-[#F8F9FF] text-gray-700' : ''}`}
|
||||
onClick={() => { setKeywordType('favorites'); showFavorites(true); }}>
|
||||
<Icon type='star' classes='top-[4px]' />
|
||||
<i className='hidden not-italic lg:inline-block ml-1'>Favorites</i>
|
||||
<span className={`${deviceTabCountStyle}`}>{favorites.length}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-5'>
|
||||
|
||||
<div className=' lg:hidden'>
|
||||
<button
|
||||
data-testid="filter_button"
|
||||
className={`px-2 py-1 rounded ${filterOptions ? ' bg-indigo-100 text-blue-700' : ''}`}
|
||||
title='Filter'
|
||||
onClick={() => showFilterOptions(!filterOptions)}>
|
||||
<Icon type="filter" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`lg:flex gap-5 lg:visible ${filterOptions ? mobileFilterOptionsStyle : 'hidden'}`}>
|
||||
{keywordType === 'all' && (
|
||||
<div className={'tags_filter mb-2 lg:mb-0'}>
|
||||
<SelectField
|
||||
selected={filterParams.tags}
|
||||
options={allTags.map((tag:string) => ({ label: tag, value: tag }))}
|
||||
defaultLabel={`All Groups (${allTags.length})`}
|
||||
updateField={(updated:string[]) => filterTags(updated)}
|
||||
emptyMsg="No Groups Found for this Domain"
|
||||
minWidth={270}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={'mb-2 lg:mb-0'}>
|
||||
<input
|
||||
data-testid="filter_input"
|
||||
className={`border w-44 lg:w-36 focus:w-44 transition-all rounded-3xl
|
||||
p-1.5 px-4 outline-none ring-0 focus:border-indigo-200`}
|
||||
type="text"
|
||||
placeholder='Filter Keywords...'
|
||||
onChange={searchKeywords}
|
||||
value={filterParams.search}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<button
|
||||
data-testid="sort_button"
|
||||
className={`px-2 py-1 rounded ${sortOptions ? ' bg-indigo-100 text-blue-700' : ''}`}
|
||||
title='Sort'
|
||||
onClick={() => showSortOptions(!sortOptions)}>
|
||||
<Icon type="sort" size={18} />
|
||||
</button>
|
||||
{sortOptions && (
|
||||
<ul
|
||||
data-testid="sort_options"
|
||||
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg
|
||||
max-h-96 bg-white z-[9999] w-44 overflow-y-auto styled-scrollbar'>
|
||||
{sortOptionChoices.map((sortOption) => {
|
||||
return <li
|
||||
key={sortOption.value}
|
||||
className={sortItemStyle(sortOption.value)}
|
||||
onClick={() => { updateSort(sortOption.value); showSortOptions(false); }}>
|
||||
{sortOption.label}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdeasFilters;
|
||||
77
components/ideas/KeywordIdea.tsx
Normal file
77
components/ideas/KeywordIdea.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import countries from '../../utils/countries';
|
||||
import { formattedNum } from '../../utils/client/helpers';
|
||||
import ChartSlim from '../common/ChartSlim';
|
||||
|
||||
type KeywordIdeaProps = {
|
||||
keywordData: IdeaKeyword,
|
||||
selected: boolean,
|
||||
lastItem?:boolean,
|
||||
isFavorite: boolean,
|
||||
style: Object,
|
||||
selectKeyword: Function,
|
||||
favoriteKeyword:Function,
|
||||
showKeywordDetails: Function
|
||||
}
|
||||
|
||||
const KeywordIdea = (props: KeywordIdeaProps) => {
|
||||
const { keywordData, selected, lastItem, selectKeyword, style, isFavorite = false, favoriteKeyword, showKeywordDetails } = props;
|
||||
const { keyword, uid, position, country, monthlySearchVolumes, avgMonthlySearches, competition, competitionIndex } = keywordData;
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const chartDataObj: { labels: string[], sreies: number[] } = { labels: [], sreies: [] };
|
||||
Object.keys(monthlySearchVolumes).forEach((dateKey:string) => {
|
||||
chartDataObj.labels.push(dateKey);
|
||||
chartDataObj.sreies.push(parseInt(monthlySearchVolumes[dateKey], 10));
|
||||
});
|
||||
return chartDataObj;
|
||||
}, [monthlySearchVolumes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={keyword}
|
||||
style={style}
|
||||
className={`keyword relative py-5 px-4 text-gray-600 border-b-[1px] border-gray-200 lg:py-4 lg:px-6 lg:border-0
|
||||
lg:flex lg:justify-between lg:items-center ${selected ? ' bg-indigo-50 keyword--selected' : ''} ${lastItem ? 'border-b-0' : ''}`}>
|
||||
|
||||
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold cursor-pointer'>
|
||||
<button
|
||||
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border
|
||||
${selected ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}`}
|
||||
onClick={() => selectKeyword(uid)}
|
||||
>
|
||||
<Icon type="check" size={10} />
|
||||
</button>
|
||||
<a className='py-2 hover:text-blue-600' onClick={() => showKeywordDetails()}>
|
||||
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country] && countries[country][0]} />{keyword}
|
||||
</a>
|
||||
<button
|
||||
className={`ml-2 hover:text-yellow-600 hover:opacity-100 ${isFavorite ? 'text-yellow-600' : ' opacity-50'}`}
|
||||
onClick={() => favoriteKeyword()}>
|
||||
<Icon type={isFavorite ? 'star-filled' : 'star'} classes='top-[4px]' size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='keyword_imp text-center inline-block ml-6 lg:ml-0 lg:flex-1 '>
|
||||
{formattedNum(avgMonthlySearches)}<span className='lg:hidden'>/month</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => showKeywordDetails()}
|
||||
className={`keyword_visits text-center hidden mt-4 mr-5 ml-5 cursor-pointer
|
||||
lg:flex-1 lg:m-0 lg:ml-10 max-w-[70px] lg:max-w-none lg:pr-5 lg:flex justify-center`}>
|
||||
{chartData.labels.length > 0 && <ChartSlim labels={chartData.labels} sreies={chartData.sreies} noMaxLimit={true} />}
|
||||
</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;
|
||||
220
components/ideas/KeywordIdeasTable.tsx
Normal file
220
components/ideas/KeywordIdeasTable.tsx
Normal 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;
|
||||
139
components/ideas/KeywordIdeasUpdater.tsx
Normal file
139
components/ideas/KeywordIdeasUpdater.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMutateKeywordIdeas } from '../../services/adwords';
|
||||
import allCountries, { adwordsLanguages } from '../../utils/countries';
|
||||
import SelectField from '../common/SelectField';
|
||||
import Icon from '../common/Icon';
|
||||
|
||||
interface KeywordIdeasUpdaterProps {
|
||||
onUpdate?: Function,
|
||||
domain?: DomainType,
|
||||
searchConsoleConnected: boolean,
|
||||
adwordsConnected: boolean,
|
||||
settings?: {
|
||||
seedSCKeywords: boolean,
|
||||
seedCurrentKeywords: boolean,
|
||||
seedDomain: boolean,
|
||||
language: string,
|
||||
countries: string[],
|
||||
keywords: string,
|
||||
seedType: string
|
||||
}
|
||||
}
|
||||
|
||||
const KeywordIdeasUpdater = ({ onUpdate, settings, domain, searchConsoleConnected = false, adwordsConnected = false }: KeywordIdeasUpdaterProps) => {
|
||||
const router = useRouter();
|
||||
const [seedType, setSeedType] = useState(() => settings?.seedType || 'auto');
|
||||
const [language, setLanguage] = useState(() => settings?.language.toString() || '1000');
|
||||
const [countries, setCoutries] = useState<string[]>(() => settings?.countries || ['US']);
|
||||
const [keywords, setKeywords] = useState(() => (settings?.keywords && Array.isArray(settings?.keywords) ? settings?.keywords.join(',') : ''));
|
||||
const { mutate: updateKeywordIdeas, isLoading: isUpdatingIdeas } = useMutateKeywordIdeas(router, () => onUpdate && onUpdate());
|
||||
|
||||
const seedTypeOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: 'Automatically from Website Content', value: 'auto' },
|
||||
{ label: 'Based on currently tracked keywords', value: 'tracking' },
|
||||
{ label: 'From Custom Keywords', value: 'custom' },
|
||||
];
|
||||
|
||||
if (searchConsoleConnected) {
|
||||
options.splice(-2, 0, { label: 'Based on already ranking keywords (GSC)', value: 'searchconsole' });
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [searchConsoleConnected]);
|
||||
|
||||
const reloadKeywordIdeas = () => {
|
||||
const keywordPaylod = seedType !== 'auto' && keywords ? keywords.split(',').map((key) => key.trim()) : undefined;
|
||||
console.log('keywordPaylod :', keywords, keywordPaylod);
|
||||
updateKeywordIdeas({
|
||||
seedType,
|
||||
language,
|
||||
domain: domain?.domain,
|
||||
domainSlug: domain?.slug,
|
||||
keywords: keywordPaylod,
|
||||
country: countries[0],
|
||||
});
|
||||
};
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
return Object.keys(allCountries)
|
||||
.filter((countryISO) => allCountries[countryISO][3] !== 0)
|
||||
.map((countryISO) => ({ label: allCountries[countryISO][0], value: countryISO }));
|
||||
}, []);
|
||||
|
||||
const languageOPtions = useMemo(() => Object.entries(adwordsLanguages).map(([value, label]) => ({ label, value })), []);
|
||||
|
||||
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize w-full';
|
||||
return (
|
||||
<div>
|
||||
|
||||
<div>
|
||||
<div className={'mb-3'}>
|
||||
<label className={labelStyle}>Get Keyword Ideas</label>
|
||||
<SelectField
|
||||
selected={[seedType]}
|
||||
options={seedTypeOptions}
|
||||
defaultLabel='Get Ideas Based On'
|
||||
updateField={(updated:string[]) => setSeedType(updated[0])}
|
||||
fullWidth={true}
|
||||
multiple={false}
|
||||
rounded='rounded'
|
||||
/>
|
||||
</div>
|
||||
{seedType === 'custom' && (
|
||||
<>
|
||||
<div className={'mb-3'}>
|
||||
<label className={labelStyle}>Get Ideas from given Keywords (Max 20)</label>
|
||||
<textarea
|
||||
className='w-full border border-solid border-gray-300 focus:border-blue-100 p-3 rounded outline-none'
|
||||
value={keywords}
|
||||
onChange={(event) => setKeywords(event.target.value)}
|
||||
placeholder="keyword1, keyword2.."
|
||||
/>
|
||||
</div>
|
||||
<hr className=' my-4' />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={'mb-3'}>
|
||||
<label className={labelStyle}>Country</label>
|
||||
<SelectField
|
||||
selected={countries}
|
||||
options={countryOptions}
|
||||
defaultLabel='All Countries'
|
||||
updateField={(updated:string[]) => setCoutries(updated)}
|
||||
flags={true}
|
||||
multiple={false}
|
||||
fullWidth={true}
|
||||
maxHeight={48}
|
||||
rounded='rounded'
|
||||
/>
|
||||
</div>
|
||||
<div className={'mb-3'}>
|
||||
<label className={labelStyle}>Language</label>
|
||||
<SelectField
|
||||
selected={[language]}
|
||||
options={languageOPtions}
|
||||
defaultLabel='All Languages'
|
||||
updateField={(updated:string[]) => setLanguage(updated[0])}
|
||||
rounded='rounded'
|
||||
multiple={false}
|
||||
fullWidth={true}
|
||||
maxHeight={48}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`w-full py-2 px-5 mt-2 rounded bg-blue-700 text-white
|
||||
font-semibold ${!adwordsConnected ? ' cursor-not-allowed opacity-40' : 'cursor-pointer'}`}
|
||||
title={!adwordsConnected ? 'Please Connect 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;
|
||||
119
components/settings/AdWordsSettings.tsx
Normal file
119
components/settings/AdWordsSettings.tsx
Normal 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;
|
||||
53
components/settings/IntegrationSettings.tsx
Normal file
53
components/settings/IntegrationSettings.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
import SearchConsoleSettings from './SearchConsoleSettings';
|
||||
import AdWordsSettings from './AdWordsSettings';
|
||||
import Icon from '../common/Icon';
|
||||
|
||||
type IntegrationSettingsProps = {
|
||||
settings: SettingsType,
|
||||
settingsError: null | {
|
||||
type: string,
|
||||
msg: string
|
||||
},
|
||||
updateSettings: Function,
|
||||
performUpdate: Function,
|
||||
closeSettings: Function
|
||||
}
|
||||
const IntegrationSettings = ({ settings, settingsError, updateSettings, performUpdate, closeSettings }:IntegrationSettingsProps) => {
|
||||
const [currentTab, setCurrentTab] = useState<string>('searchconsole');
|
||||
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm';
|
||||
return (
|
||||
<div className='settings__content styled-scrollbar p-6 text-sm'>
|
||||
<div className='mb-4 '>
|
||||
<ul>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-blue-50 text-blue-600' : ''}`}
|
||||
onClick={() => setCurrentTab('searchconsole')}>
|
||||
<Icon type='google' size={14} /> Search Console
|
||||
</li>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'adwords' ? ' bg-blue-50 text-blue-600' : ''}`}
|
||||
onClick={() => setCurrentTab('adwords')}>
|
||||
<Icon type='adwords' size={14} /> 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
26
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
97
pages/api/adwords.ts
Normal 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
110
pages/api/ideas.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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!' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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!');
|
||||
|
||||
112
pages/domain/ideas/[slug]/index.tsx
Normal file
112
pages/domain/ideas/[slug]/index.tsx
Normal 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
100
services/adwords.tsx
Normal 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: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
39
types.d.ts
vendored
@@ -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
304
utils/adwords.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
55
utils/client/IdeasSortFilter.ts
Normal file
55
utils/client/IdeasSortFilter.ts
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
1813
utils/countries.ts
1813
utils/countries.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user