Skip to content

Commit

Permalink
feat: Adds a Keyword Research Section.
Browse files Browse the repository at this point in the history
- Adds a /research page to the app that lets users generate keyword ideas based on given keywords.
- Allows the ability to export keywords.
  • Loading branch information
towfiqi committed Feb 29, 2024
1 parent 5650645 commit 4d15989
Show file tree
Hide file tree
Showing 9 changed files with 260 additions and 38 deletions.
14 changes: 14 additions & 0 deletions components/common/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,20 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
</g>
</svg>
}
{type === 'research'
&& <svg width={size} viewBox="0 0 48 48" {...xmlnsProps}>
<g fill="none" stroke={color} strokeWidth={4}>
<path strokeLinecap="round" d="M4 7h40M4 23h11M4 39h11"></path>
<path d="M31.5 34a8.5 8.5 0 1 0 0-17a8.5 8.5 0 0 0 0 17Z"></path>
<path strokeLinecap="round" d="m37 32l7 7.05"></path>
</g>
</svg>
}
{type === 'domains'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 56 56">
<path fill={color} d="M7.328 43.504c.445 0 .914-.14 1.383-.469V17.957c0-.844.164-1.172.914-1.57L31.352 3.87c.07-1.547-.915-2.508-2.25-2.508c-.61 0-1.266.164-1.97.586L7.493 13.246c-2.297 1.336-2.578 1.758-2.578 4.43v22.43c0 2.015.96 3.398 2.414 3.398m9.375 5.414c.422 0 .89-.14 1.383-.469V23.371c0-.914.117-1.148.89-1.57L40.703 9.26c.07-1.523-.89-2.507-2.25-2.507c-.586 0-1.266.187-1.945.562L16.82 18.636c-2.297 1.313-2.555 1.805-2.555 4.43V45.52c0 2.015 1.008 3.398 2.438 3.398m10.031 5.719c.82 0 1.805-.328 2.977-.985l18.375-10.547c2.156-1.242 3-2.53 3-5.156l-.047-21.234c0-2.813-1.008-4.242-2.766-4.242c-.773 0-1.758.304-2.859.937L26.992 24.027c-2.203 1.29-2.977 2.602-2.977 5.157v21.234c0 2.719.961 4.219 2.72 4.219M28 50.067c-.117-.024-.164-.094-.164-.258L28 29.254c0-.89.258-1.36 1.055-1.805l17.742-10.43c.07-.046.14-.046.234-.023c.094.024.164.094.164.258l-.07 20.625c0 .773-.281 1.36-1.055 1.828L28.234 50.043a.284.284 0 0 1-.234.023"></path>
</svg>
}
</span>
);
};
Expand Down
6 changes: 4 additions & 2 deletions components/common/SelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type SelectFieldProps = {
maxHeight?: number|string,
rounded?: string,
flags?: boolean,
inline?: boolean,
emptyMsg?: string
}
const SelectField = (props: SelectFieldProps) => {
Expand All @@ -30,6 +31,7 @@ const SelectField = (props: SelectFieldProps) => {
maxHeight = 96,
fullWidth = false,
rounded = 'rounded-3xl',
inline = false,
flags = false,
label = '',
emptyMsg = '' } = props;
Expand Down Expand Up @@ -70,7 +72,7 @@ const SelectField = (props: SelectFieldProps) => {
};

return (
<div className="select font-semibold text-gray-500 relative flex justify-between items-center">
<div className={`select font-semibold text-gray-500 relative ${inline ? 'inline-block' : 'flex'} justify-between items-center"`}>
{label && <label className='mb-2 font-semibold inline-block text-sm text-gray-700 capitalize'>{label}</label>}
<div
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none ${fullWidth ? 'w-full' : 'w-[210px]'}
Expand Down Expand Up @@ -104,7 +106,7 @@ const SelectField = (props: SelectFieldProps) => {
return (
<li
key={opt.value}
className={`select-none cursor-pointer px-3 py-2 hover:bg-[#FCFCFF] capitalize
className={`select-none cursor-pointer px-3 py-2 hover:bg-[#FCFCFF] capitalize text-ellipsis overflow-hidden
${itemActive ? ' bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''} `}
onClick={() => selectItem(opt)}
>
Expand Down
24 changes: 19 additions & 5 deletions components/common/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
<span className=' relative top-[3px] mr-1'><Icon type="logo" size={24} color="#364AFF" /></span> SerpBear
<button className='px-3 py-1 font-bold text-blue-700 lg:hidden ml-3 text-lg' onClick={() => showAddModal()}>+</button>
</h3>
{!isDomainsPage && (
{!isDomainsPage && router.asPath !== '/research' && (
<Link href={'/domains'} passHref={true}>
<a className=' right-14 top-2 px-2 py-1 cursor-pointer bg-[#ecf2ff] hover:bg-indigo-100 transition-all
absolute lg:top-3 lg:right-auto lg:left-8 lg:px-3 lg:py-2 rounded-full'>
Expand All @@ -52,16 +52,30 @@ const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
<ul
className={`text-sm font-semibold text-gray-500 absolute mt-[-10px] right-3 bg-white
border border-gray-200 lg:mt-2 lg:relative lg:block lg:border-0 lg:bg-transparent ${showMobileMenu ? 'block' : 'hidden'}`}>
<li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' href='https://docs.serpbear.com/' target="_blank" rel='noreferrer'>
<Icon type="question" color={'#888'} size={14} /> Help
</a>
<li className={`block lg:inline-block lg:ml-5 ${router.asPath === '/domains' ? ' text-blue-700' : ''}`}>
<Link href={'/domains'} passHref={true}>
<a className='block px-3 py-2 cursor-pointer'>
<Icon type="domains" color={router.asPath === '/domains' ? '#1d4ed8' : '#888'} size={14} /> Domains
</a>
</Link>
</li>
<li className={`block lg:inline-block lg:ml-5 ${router.asPath === '/research' ? ' text-blue-700' : ''}`}>
<Link href={'/research'} passHref={true}>
<a className='block px-3 py-2 cursor-pointer'>
<Icon type="research" color={router.asPath === '/research' ? '#1d4ed8' : '#888'} size={14} /> Research
</a>
</Link>
</li>
<li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' onClick={() => showSettings()}>
<Icon type="settings-alt" color={'#888'} size={14} /> Settings
</a>
</li>
<li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' href='https://docs.serpbear.com/' target="_blank" rel='noreferrer'>
<Icon type="question" color={'#888'} size={14} /> Help
</a>
</li>
<li className='block lg:inline-block lg:ml-5'>
<a className='block px-3 py-2 cursor-pointer' onClick={() => logoutUser()}>
<Icon type="logout" color={'#888'} size={14} /> Logout
Expand Down
33 changes: 30 additions & 3 deletions components/ideas/KeywordIdeasTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRouter } from 'next/router';
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import { useQuery } from 'react-query';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { useAddKeywords } from '../../services/keywords';
import Icon from '../common/Icon';
Expand All @@ -11,6 +12,8 @@ import { IdeasSortKeywords, IdeasfilterKeywords } from '../../utils/client/Ideas
import IdeasFilters from './IdeasFilter';
import { useMutateFavKeywordIdeas } from '../../services/adwords';
import IdeaDetails from './IdeaDetails';
import { fetchDomains } from '../../services/domains';
import SelectField from '../common/SelectField';

type IdeasKeywordsTableProps = {
domain: DomainType | null,
Expand All @@ -33,9 +36,15 @@ const IdeasKeywordsTable = ({
const [sortBy, setSortBy] = useState<string>('imp_desc');
const [listHeight, setListHeight] = useState(500);
const [addKeywordDevice, setAddKeywordDevice] = useState<'desktop'|'mobile'>('desktop');
const [addKeywordDomain, setAddKeywordDomain] = useState('');
const { mutate: addKeywords } = useAddKeywords(() => { if (domain && domain.slug) router.push(`/domain/${domain.slug}`); });
const { mutate: faveKeyword, isLoading: isFaving } = useMutateFavKeywordIdeas(router);
const [isMobile] = useIsMobile();
const isResearchPage = router.pathname === '/research';

const { data: domainsData } = useQuery('domains', () => fetchDomains(router, false), { enabled: selectedKeywords.length > 0, retry: false });
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];

useWindowResize(() => setListHeight(window.innerHeight - (isMobile ? 200 : 400)));

const finalKeywords: IdeaKeyword[] = useMemo(() => {
Expand Down Expand Up @@ -78,7 +87,7 @@ const IdeasKeywordsTable = ({

const favoriteKeyword = (keywordID: string) => {
if (!isFaving) {
faveKeyword({ keywordID, domain: domain?.slug });
faveKeyword({ keywordID, domain: isResearchPage ? 'research' : domain?.slug });
}
};

Expand All @@ -87,7 +96,13 @@ const IdeasKeywordsTable = ({
keywords.forEach((kitem:IdeaKeyword) => {
if (selectedKeywords.includes(kitem.uid)) {
const { keyword, country } = kitem;
selectedkeywords.push({ keyword, device: addKeywordDevice, country, domain: domain?.domain || '', tags: '' });
selectedkeywords.push({
keyword,
device: addKeywordDevice,
country,
domain: isResearchPage ? addKeywordDomain : (domain?.domain || ''),
tags: '',
});
}
});
addKeywords(selectedkeywords);
Expand Down Expand Up @@ -118,7 +133,19 @@ const IdeasKeywordsTable = ({
<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 ${isResearchPage ? ' mr-2' : ''}`}>Add Keywords to Tracker</div>
{isResearchPage && (
<SelectField
selected={[]}
options={theDomains.map((d) => ({ label: d.domain, value: d.domain }))}
defaultLabel={'Select a Domain'}
updateField={(updated:string[]) => updated[0] && setAddKeywordDomain(updated[0])}
emptyMsg="No Domains Found"
multiple={false}
inline={true}
rounded='rounded'
/>
)}
<div className='inline-block ml-2'>
<button
className={`inline-block px-2 py-1 rounded-s
Expand Down
7 changes: 4 additions & 3 deletions pages/api/ideas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ const favoriteKeywords = async (req: NextApiRequest, res: NextApiResponse<keywor

try {
const keywordsDatabase = await getLocalKeywordIdeas(domain);
if (keywordsDatabase && keywordsDatabase.keywords && keywordsDatabase.favorites) {
if (keywordsDatabase && keywordsDatabase.keywords) {
const theKeyword = keywordsDatabase.keywords.find((kw) => kw.uid === keywordID);
const newFavorites = [...keywordsDatabase.favorites];
const existingKeywords = keywordsDatabase.favorites || [];
const newFavorites = [...existingKeywords];
const existingKeywordIndex = newFavorites.findIndex((kw) => kw.uid === keywordID);
if (existingKeywordIndex > -1) {
newFavorites.splice(existingKeywordIndex, 1);
Expand All @@ -98,7 +99,7 @@ const favoriteKeywords = async (req: NextApiRequest, res: NextApiResponse<keywor
const updated = await updateLocalKeywordIdeas(domain, { favorites: newFavorites });

if (updated) {
return res.status(200).json({ keywords: keywordsDatabase.favorites, error: '' });
return res.status(200).json({ keywords: newFavorites, error: '' });
}
}

Expand Down
148 changes: 148 additions & 0 deletions pages/research/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { NextPage } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import Icon from '../../components/common/Icon';
import TopBar from '../../components/common/TopBar';
import KeywordIdeasTable from '../../components/ideas/KeywordIdeasTable';
import { exportKeywordIdeas } from '../../utils/client/exportcsv';
import { useFetchKeywordIdeas, useMutateKeywordIdeas } from '../../services/adwords';
import { useFetchSettings } from '../../services/settings';
import Settings from '../../components/settings/Settings';
import SelectField from '../../components/common/SelectField';
import allCountries, { adwordsLanguages } from '../../utils/countries';

const Research: NextPage = () => {
const router = useRouter();
const [showSettings, setShowSettings] = useState(false);
const [showFavorites, setShowFavorites] = useState(false);
const [language, setLanguage] = useState('1000');
const [country, setCountry] = useState('US');
const [seedKeywords, setSeedKeywords] = useState('');

const { data: appSettings } = useFetchSettings();
const adwordsConnected = !!(appSettings && appSettings?.settings?.adwords_refresh_token
&& appSettings?.settings?.adwords_developer_token, appSettings?.settings?.adwords_account_id);
const { data: keywordIdeasData, isLoading: isLoadingIdeas, isError: errorLoadingIdeas } = useFetchKeywordIdeas(router, adwordsConnected);
const { mutate: updateKeywordIdeas, isLoading: isUpdatingIdeas } = useMutateKeywordIdeas(router);

const keywordIdeas:IdeaKeyword[] = keywordIdeasData?.data?.keywords || [];
const favorites:IdeaKeyword[] = keywordIdeasData?.data?.favorites || [];
const keywordIdeasSettings = keywordIdeasData?.data?.settings || undefined;
const { country: previousCountry, language: previousLang, keywords: previousSeedKeywords } = keywordIdeasSettings || {};

useEffect(() => {
if (previousCountry) { setCountry(previousCountry); }
if (previousLang) { setLanguage(previousLang.toString()); }
if (previousSeedKeywords) { setSeedKeywords(previousSeedKeywords.join(',')); }
}, [previousCountry, previousLang, previousSeedKeywords]);

const reloadKeywordIdeas = () => {
const keywordPaylod = seedKeywords ? seedKeywords.split(',').map((key) => key.trim()) : undefined;
updateKeywordIdeas({ seedType: 'custom', language, domain: 'research', keywords: keywordPaylod, country });
};

const countryOptions = useMemo(() => {
return Object.keys(allCountries)
.filter((countryISO) => allCountries[countryISO][3] !== 0)
.map((countryISO) => ({ label: allCountries[countryISO][0], value: countryISO }));
}, []);

const languageOPtions = useMemo(() => Object.entries(adwordsLanguages).map(([value, label]) => ({ label, value })), []);

const buttonStyle = 'leading-6 inline-block px-2 py-2 text-gray-500 hover:text-gray-700';
const buttonLabelStyle = 'ml-2 text-sm not-italic lg:invisible lg:opacity-0';
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize w-full';

return (
<div className={'Login'}>
<Head>
<title>Research Keywords - SerpBear</title>
</Head>
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => null } />
<div className=" w-full max-w-7xl mx-auto lg:flex lg:flex-row">
<div className="sidebar w-full p-6 lg:pt-44 lg:w-1/5 lg:block lg:pr-0" data-testid="sidebar">
<h3 className="hidden py-7 text-base font-bold text-blue-700 lg:block">
<span className=' relative top-[3px] mr-1'><Icon type="logo" size={24} color="#364AFF" /></span> SerpBear
</h3>
<div className={`sidebar_menu domKeywords max-h-96 overflow-auto styled-scrollbar p-4
bg-white border border-gray-200 rounded lg:rounded-none lg:rounded-s lg:border-r-0`}>
<div className={'mb-3'}>
<label className={labelStyle}>Generate Ideas from given Keywords (Max 20)</label>
<textarea
className='w-full border border-solid border-gray-300 focus:border-blue-100 p-3 rounded outline-none text-sm'
value={seedKeywords}
onChange={(event) => setSeedKeywords(event.target.value)}
placeholder="keyword1, keyword2.."
/>
</div>
<div className={'mb-3'}>
<label className={labelStyle}>Country</label>
<SelectField
selected={[country]}
options={countryOptions}
defaultLabel='All Countries'
updateField={(updated:string[]) => setCountry(updated[0])}
flags={true}
multiple={false}
fullWidth={true}
maxHeight={48}
rounded='rounded'
/>
</div>
<div className={'mb-3'}>
<label className={labelStyle}>Language</label>
<SelectField
selected={[language]}
options={languageOPtions}
defaultLabel='All Languages'
updateField={(updated:string[]) => setLanguage(updated[0])}
rounded='rounded'
multiple={false}
fullWidth={true}
maxHeight={48}
/>
</div>
<button
className={`w-full py-2 px-5 mt-2 rounded bg-blue-700 text-white
font-semibold ${!adwordsConnected ? ' cursor-not-allowed opacity-40' : 'cursor-pointer'}`}
title={!adwordsConnected ? 'Please Connect Adwords account to generate Keyword Ideas..' : ''}
onClick={() => !isUpdatingIdeas && adwordsConnected && reloadKeywordIdeas()}>
<Icon type={isUpdatingIdeas ? 'loading' : 'download'} size={14} /> {isUpdatingIdeas ? 'Loading....' : 'Load Ideas'}
</button>
</div>
</div>
<div className="domain_kewywords px-5 lg:px-0 lg:pt-8 w-full">
<div className='domain_kewywords_head w-full '>
<div className=' flex mt-12 mb-0 justify-between'>
<h1 className=" font-bold mb-0 mt-0 pt-2 lg:text-xl lg:mb-6" data-testid="domain-header">Research Keywords</h1>
<button
className={`domheader_action_button relative mb-3
${buttonStyle} ${keywordIdeas.length === 0 ? 'cursor-not-allowed opacity-60' : ''}`}
aria-pressed="false"
onClick={() => exportKeywordIdeas(showFavorites ? favorites : keywordIdeas, 'research')}>
<Icon type='download' size={20} /><i className={`${buttonLabelStyle}`}>Export as csv</i>
</button>
</div>
</div>
<KeywordIdeasTable
isLoading={isLoadingIdeas}
noIdeasDatabase={errorLoadingIdeas}
domain={null}
keywords={keywordIdeas}
favorites={favorites}
isAdwordsIntegrated={adwordsConnected}
showFavorites={showFavorites}
setShowFavorites={setShowFavorites}
/>
</div>
</div>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition>
</div>
);
};

export default Research;
Loading

0 comments on commit 4d15989

Please sign in to comment.