mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
feat: Adds the ability to setup Search Console through the UI.
- Adds the ability to add domain specific Search Console API Info through the Domain Settings panel. - Adds the ability to add global Search Console API Info through the App Settings Panel. - Adds better Search Console Error logging. - Changes the App Settings Sidebar UI. - Changers the Domain Settings Modal UI. - Replaces html Input field with custom InputField component. - Adds a new /domain api route to get the full domain info which includes the domain level Search console API. closes #59, #146
This commit is contained in:
@@ -253,6 +253,16 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
|
||||
<path d="M15.75 9h3v2.25h-3z" fill={color} />
|
||||
</svg>
|
||||
}
|
||||
{type === 'email'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<path fill={color} d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z" />
|
||||
</svg>
|
||||
}
|
||||
{type === 'scraper'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 16 16">
|
||||
<path fill={color} d="M1 3.5A2.5 2.5 0 0 1 3.5 1h7A2.5 2.5 0 0 1 13 3.5v1.53a4.538 4.538 0 0 0-1-.004V5H2v5.5A1.5 1.5 0 0 0 3.5 12h2.954l-.72.72a2.52 2.52 0 0 0-.242.28H3.5A2.5 2.5 0 0 1 1 10.5zm7.931 3.224l-.577-.578a.5.5 0 1 0-.708.708l.745.744c.144-.306.324-.6.54-.874M2 4h10v-.5A1.5 1.5 0 0 0 10.5 2h-7A1.5 1.5 0 0 0 2 3.5zm4.354 2.854a.5.5 0 1 0-.708-.708l-2 2a.5.5 0 0 0 0 .708l2 2a.5.5 0 0 0 .708-.708L4.707 8.5zm6.538-.83c.366.042.471.48.21.742l-.975.975a1.507 1.507 0 1 0 2.132 2.132l.975-.975c.261-.261.7-.156.742.21a3.518 3.518 0 0 1-4.676 3.723l-2.726 2.727a1.507 1.507 0 1 1-2.132-2.132L9.168 10.7a3.518 3.518 0 0 1 3.724-4.676" />
|
||||
</svg>
|
||||
}
|
||||
{type === 'city'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 48 48">
|
||||
<g fill="none">
|
||||
|
||||
@@ -10,10 +10,10 @@ type InputFieldProps = {
|
||||
const InputField = ({ label = '', value = '', placeholder = '', onChange, hasError = false }: InputFieldProps) => {
|
||||
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
|
||||
return (
|
||||
<div className="field--input w-full relative">
|
||||
<div className="field--input w-full relative flex justify-between items-center">
|
||||
<label className={labelStyle}>{label}</label>
|
||||
<input
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none
|
||||
className={`p-2 border border-gray-200 rounded focus:outline-none w-[210px]
|
||||
focus:border-blue-200 ${hasError ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type={'text'}
|
||||
value={value}
|
||||
|
||||
@@ -6,10 +6,11 @@ type ModalProps = {
|
||||
children: React.ReactNode,
|
||||
width?: string,
|
||||
title?: string,
|
||||
verticalCenter?: boolean,
|
||||
closeModal: Function,
|
||||
}
|
||||
|
||||
const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
|
||||
const Modal = ({ children, width = '1/2', closeModal, title, verticalCenter = false }:ModalProps) => {
|
||||
useOnKey('Escape', closeModal);
|
||||
|
||||
const closeOnBGClick = (e:React.SyntheticEvent) => {
|
||||
@@ -21,8 +22,9 @@ const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
|
||||
return (
|
||||
<div className='modal fixed top-0 left-0 bg-white/[.7] w-full h-screen z-50' onClick={closeOnBGClick}>
|
||||
<div
|
||||
className={`modal__content max-w-[340px] absolute top-1/4 left-0 right-0 ml-auto mr-auto w-${width}
|
||||
lg:max-w-md bg-white shadow-md rounded-md p-5 border-t-[1px] border-gray-100 text-base`}>
|
||||
className={`modal__content max-w-[340px] absolute left-0 right-0 ml-auto mr-auto w-${width}
|
||||
lg:max-w-md bg-white shadow-md rounded-md p-5 border-t-[1px] border-gray-100 text-base
|
||||
${verticalCenter ? ' top-1/2 translate-y-[-50%]' : 'top-1/4'}`}>
|
||||
{title && <h3 className=' font-semibold mb-3'>{title}</h3>}
|
||||
<button
|
||||
className='modal-close absolute right-2 top-2 p-2 cursor-pointer text-gray-400 transition-all
|
||||
|
||||
@@ -14,15 +14,15 @@ 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">
|
||||
<div className="settings__section__secret mb-5 relative flex justify-between items-center">
|
||||
<label className={labelStyle}>{label}</label>
|
||||
<span
|
||||
className="absolute top-8 right-0 px-2 py-1 cursor-pointer text-gray-400 select-none"
|
||||
className="absolute top-1 right-0 px-2 py-1 cursor-pointer text-gray-400 select-none"
|
||||
onClick={() => setShowValue(!showValue)}>
|
||||
<Icon type={showValue ? 'eye-closed' : 'eye'} size={18} />
|
||||
</span>
|
||||
<input
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none
|
||||
className={`w-[210px] p-2 border border-gray-200 rounded focus:outline-none
|
||||
focus:border-blue-200 ${hasError ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type={showValue ? 'text' : 'password'}
|
||||
value={value}
|
||||
|
||||
@@ -9,6 +9,7 @@ type SelectFieldProps = {
|
||||
defaultLabel: string,
|
||||
options: SelectionOption[],
|
||||
selected: string[],
|
||||
label?: string,
|
||||
multiple?: boolean,
|
||||
updateField: Function,
|
||||
minWidth?: number,
|
||||
@@ -28,6 +29,7 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
maxHeight = 96,
|
||||
rounded = 'rounded-3xl',
|
||||
flags = false,
|
||||
label = '',
|
||||
emptyMsg = '' } = props;
|
||||
|
||||
const [showOptions, setShowOptions] = useState<boolean>(false);
|
||||
@@ -66,12 +68,13 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="select font-semibold text-gray-500">
|
||||
<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-[180px] min-w-[${minWidth}px]
|
||||
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[210px] min-w-[${minWidth}px]
|
||||
${showOptions ? 'border-indigo-200' : ''}`}
|
||||
onClick={() => setShowOptions(!showOptions)}>
|
||||
<span className={`w-[${minWidth - 30}px] inline-block truncate mr-2 capitalize`}>
|
||||
<span className={'w-full inline-block truncate mr-2 capitalize'}>
|
||||
{selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel}
|
||||
</span>
|
||||
{multiple && selected.length > 2
|
||||
@@ -80,7 +83,7 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
</div>
|
||||
{showOptions && (
|
||||
<div
|
||||
className={`select_list mt-1 border absolute min-w-[${minWidth}px]
|
||||
className={`select_list mt-1 border absolute min-w-[${minWidth}px] top-[30px] right-0 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=''>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import Modal from '../common/Modal';
|
||||
import { useDeleteDomain, useUpdateDomain } from '../../services/domains';
|
||||
import { useDeleteDomain, useFetchDomain, useUpdateDomain } from '../../services/domains';
|
||||
import InputField from '../common/InputField';
|
||||
import SelectField from '../common/SelectField';
|
||||
|
||||
@@ -24,17 +24,21 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
const [domainSettings, setDomainSettings] = useState<DomainSettings>(() => ({
|
||||
notification_interval: domain && domain.notification_interval ? domain.notification_interval : 'never',
|
||||
notification_emails: domain && domain.notification_emails ? domain.notification_emails : '',
|
||||
search_console: domain && domain.search_console ? JSON.parse(domain.search_console) : { property_type: 'domain', url: '' },
|
||||
search_console: domain && domain.search_console ? JSON.parse(domain.search_console) : {
|
||||
property_type: 'domain', url: '', client_email: '', private_key: '',
|
||||
},
|
||||
}));
|
||||
|
||||
const { mutate: updateMutate } = useUpdateDomain(() => closeModal(false));
|
||||
const { mutate: deleteMutate } = useDeleteDomain(() => {
|
||||
closeModal(false);
|
||||
router.push('/domains');
|
||||
const { mutate: updateMutate, error: domainUpdateError, isLoading: isUpdating } = useUpdateDomain(() => closeModal(false));
|
||||
const { mutate: deleteMutate } = useDeleteDomain(() => { closeModal(false); router.push('/domains'); });
|
||||
|
||||
// Get the Full Domain Data along with the Search Console API Data.
|
||||
useFetchDomain(router, domain && domain.domain ? domain.domain : '', (domainObj:DomainType) => {
|
||||
const currentSearchConsoleSettings = domainObj.search_console && JSON.parse(domainObj.search_console);
|
||||
setDomainSettings({ ...domainSettings, search_console: currentSearchConsoleSettings || domainSettings.search_console });
|
||||
});
|
||||
|
||||
const updateDomain = () => {
|
||||
console.log('Domain: ');
|
||||
let error: DomainSettingsError | null = null;
|
||||
if (domainSettings.notification_emails) {
|
||||
const notification_emails = domainSettings.notification_emails.split(',');
|
||||
@@ -55,23 +59,24 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm select-none';
|
||||
|
||||
const tabStyle = `inline-block px-4 py-2 rounded-md mr-3 cursor-pointer text-sm select-none z-10
|
||||
text-gray-600 border border-b-0 relative top-[1px] rounded-b-none`;
|
||||
return (
|
||||
<div>
|
||||
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]">
|
||||
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]" verticalCenter={currentTab === 'searchconsole'} >
|
||||
<div data-testid="domain_settings" className=" text-sm">
|
||||
<div className='mt-5 mb-5 '>
|
||||
<div className=' mt-3 mb-5 border border-slate-200 px-2 py-4 pb-0
|
||||
relative left-[-20px] w-[calc(100%+40px)] border-l-0 border-r-0 bg-[#f8f9ff]'>
|
||||
<ul>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-blue-50 text-blue-600' : ''}`}
|
||||
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'} `}
|
||||
onClick={() => setCurrentTab('notification')}>
|
||||
Notification
|
||||
<Icon type='email' /> Notification
|
||||
</li>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-blue-50 text-blue-600' : ''}`}
|
||||
className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'}`}
|
||||
onClick={() => setCurrentTab('searchconsole')}>
|
||||
Search Console
|
||||
<Icon type='google' /> Search Console
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -97,7 +102,7 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
defaultLabel="Select Search Console Property Type"
|
||||
updateField={(updated:['domain'|'url']) => setDomainSettings({
|
||||
...domainSettings,
|
||||
search_console: { ...domainSettings.search_console, property_type: updated[0] || 'domain' },
|
||||
search_console: { ...(domainSettings.search_console as DomainSearchConsole), property_type: updated[0] || 'domain' },
|
||||
})}
|
||||
multiple={false}
|
||||
rounded={'rounded'}
|
||||
@@ -109,17 +114,46 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
label='Property URL (Required)'
|
||||
onChange={(url:string) => setDomainSettings({
|
||||
...domainSettings,
|
||||
search_console: { ...domainSettings.search_console, url },
|
||||
search_console: { ...(domainSettings.search_console as DomainSearchConsole), url },
|
||||
})}
|
||||
value={domainSettings?.search_console?.url || ''}
|
||||
placeholder='Search Console Property URL. eg: https://mywebsite.com/'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex justify-between items-center w-full">
|
||||
<InputField
|
||||
label='Search Console Client Email'
|
||||
onChange={(client_email:string) => setDomainSettings({
|
||||
...domainSettings,
|
||||
search_console: { ...(domainSettings.search_console as DomainSearchConsole), client_email },
|
||||
})}
|
||||
value={domainSettings?.search_console?.client_email || ''}
|
||||
placeholder='myapp@appspot.gserviceaccount.com'
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-col justify-between items-center w-full">
|
||||
<label className='mb-2 font-semibold block text-sm text-gray-700 capitalize w-full'>Search Console Private Key</label>
|
||||
<textarea
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
|
||||
focus:outline-none h-[100px] focus:border-blue-200`}
|
||||
value={domainSettings?.search_console?.private_key || ''}
|
||||
placeholder={'-----BEGIN PRIVATE KEY-----/ssssaswdkihad....'}
|
||||
onChange={(event) => setDomainSettings({
|
||||
...domainSettings,
|
||||
search_console: { ...(domainSettings.search_console as DomainSearchConsole), private_key: event.target.value },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isUpdating && (domainUpdateError as Error)?.message && (
|
||||
<div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{(domainUpdateError as Error).message}</div>
|
||||
)}
|
||||
{!isUpdating && settingsError?.msg && (
|
||||
<div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{settingsError.msg}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t-[1px] border-gray-100 mt-8 pt-4 pb-0">
|
||||
@@ -129,9 +163,9 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
<Icon type="trash" /> Remove Domain
|
||||
</button>
|
||||
<button
|
||||
className='text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white'
|
||||
onClick={() => updateDomain()}>
|
||||
Update Settings
|
||||
className={`text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white ${isUpdating ? 'cursor-not-allowed' : ''}`}
|
||||
onClick={() => !isUpdating && updateDomain()}>
|
||||
{isUpdating && <Icon type='loading' />} Update Settings
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import SelectField from '../common/SelectField';
|
||||
import SecretField from '../common/SecretField';
|
||||
import InputField from '../common/InputField';
|
||||
|
||||
type NotificationSettingsProps = {
|
||||
settings: SettingsType,
|
||||
@@ -18,8 +19,8 @@ const NotificationSettings = ({ settings, settingsError, updateSettings }:Notifi
|
||||
<div>
|
||||
<div className='settings__content styled-scrollbar p-6 text-sm'>
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>Notification Frequency</label>
|
||||
<SelectField
|
||||
label='Notification Frequency'
|
||||
multiple={false}
|
||||
selected={[settings.notification_interval]}
|
||||
options={[
|
||||
@@ -32,68 +33,61 @@ const NotificationSettings = ({ settings, settingsError, updateSettings }:Notifi
|
||||
updateField={(updated:string[]) => updated[0] && updateSettings('notification_interval', updated[0])}
|
||||
rounded='rounded'
|
||||
maxHeight={48}
|
||||
minWidth={270}
|
||||
minWidth={220}
|
||||
/>
|
||||
</div>
|
||||
{settings.notification_interval !== 'never' && (
|
||||
<>
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>Notification Emails</label>
|
||||
<input
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
|
||||
${settingsError?.type === 'no_email' ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type="text"
|
||||
value={settings?.notification_email}
|
||||
placeholder={'test@gmail.com'}
|
||||
onChange={(event) => updateSettings('notification_email', event.target.value)}
|
||||
<InputField
|
||||
label='Notification Emails'
|
||||
hasError={settingsError?.type === 'no_email'}
|
||||
value={settings?.notification_email}
|
||||
placeholder={'test@gmail.com, test2@test.com'}
|
||||
onChange={(value:string) => updateSettings('notification_email', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>SMTP Server</label>
|
||||
<input
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
|
||||
${settingsError?.type === 'no_smtp_server' ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type="text"
|
||||
value={settings?.smtp_server || ''}
|
||||
onChange={(event) => updateSettings('smtp_server', event.target.value)}
|
||||
<InputField
|
||||
label='SMTP Server'
|
||||
hasError={settingsError?.type === 'no_smtp_server'}
|
||||
value={settings?.smtp_server || ''}
|
||||
placeholder={'test@gmail.com, test2@test.com'}
|
||||
onChange={(value:string) => updateSettings('smtp_server', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>SMTP Port</label>
|
||||
<input
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
|
||||
${settingsError && settingsError.type === 'no_smtp_port' ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type="text"
|
||||
value={settings?.smtp_port || ''}
|
||||
onChange={(event) => updateSettings('smtp_port', event.target.value)}
|
||||
<InputField
|
||||
label='SMTP Port'
|
||||
hasError={settingsError?.type === 'no_smtp_port'}
|
||||
value={settings?.smtp_port || ''}
|
||||
placeholder={'2234'}
|
||||
onChange={(value:string) => updateSettings('smtp_port', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>SMTP Username</label>
|
||||
<input
|
||||
className={'w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200'}
|
||||
type="text"
|
||||
<InputField
|
||||
label='SMTP Username'
|
||||
hasError={settingsError?.type === 'no_smtp_port'}
|
||||
value={settings?.smtp_username || ''}
|
||||
onChange={(event) => updateSettings('smtp_username', event.target.value)}
|
||||
onChange={(value:string) => updateSettings('smtp_username', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings__section__input mb-5">
|
||||
<SecretField
|
||||
label='SMTP Password'
|
||||
value={settings?.smtp_password || ''}
|
||||
onChange={(value:string) => updateSettings('smtp_password', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SecretField
|
||||
label='SMTP Password'
|
||||
value={settings?.smtp_password || ''}
|
||||
onChange={(value:string) => updateSettings('smtp_password', value)}
|
||||
/>
|
||||
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>From Email Address</label>
|
||||
<input
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
|
||||
${settingsError?.type === 'no_smtp_from' ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
type="text"
|
||||
<InputField
|
||||
label='From Email Address'
|
||||
hasError={settingsError?.type === 'no_smtp_from'}
|
||||
value={settings?.notification_email_from || ''}
|
||||
placeholder="no-reply@mydomain.com"
|
||||
onChange={(event) => updateSettings('notification_email_from', event.target.value)}
|
||||
/>
|
||||
onChange={(value:string) => updateSettings('notification_email_from', value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -45,15 +45,15 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
<div className='settings__content styled-scrollbar p-6 text-sm'>
|
||||
|
||||
<div className="settings__section__select mb-5">
|
||||
<label className={labelStyle}>Scraping Method</label>
|
||||
<SelectField
|
||||
label='Scraping Method'
|
||||
options={scraperOptions}
|
||||
selected={[settings.scraper_type || 'none']}
|
||||
defaultLabel="Select Scraper"
|
||||
updateField={(updatedTime:[string]) => updateSettings('scraper_type', updatedTime[0])}
|
||||
multiple={false}
|
||||
rounded={'rounded'}
|
||||
minWidth={270}
|
||||
minWidth={220}
|
||||
/>
|
||||
</div>
|
||||
{settings.scraper_type !== 'none' && settings.scraper_type !== 'proxy' && (
|
||||
@@ -80,8 +80,8 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
)}
|
||||
{settings.scraper_type !== 'none' && (
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>Scraping Frequency</label>
|
||||
<SelectField
|
||||
label='Scraping Frequency'
|
||||
multiple={false}
|
||||
selected={[settings?.scrape_interval || 'daily']}
|
||||
options={scrapingOptions}
|
||||
@@ -89,14 +89,14 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_interval', updated[0])}
|
||||
rounded='rounded'
|
||||
maxHeight={48}
|
||||
minWidth={270}
|
||||
minWidth={220}
|
||||
/>
|
||||
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings__section__input mb-5">
|
||||
<label className={labelStyle}>Delay Between Each keyword Scrape</label>
|
||||
<SelectField
|
||||
label='keyword Scrape Delay'
|
||||
multiple={false}
|
||||
selected={[settings?.scrape_delay || '0']}
|
||||
options={delayOptions}
|
||||
@@ -104,7 +104,7 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
|
||||
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_delay', updated[0])}
|
||||
rounded='rounded'
|
||||
maxHeight={48}
|
||||
minWidth={270}
|
||||
minWidth={220}
|
||||
/>
|
||||
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
|
||||
</div>
|
||||
|
||||
49
components/settings/SearchConsoleSettings.tsx
Normal file
49
components/settings/SearchConsoleSettings.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import ToggleField from '../common/ToggleField';
|
||||
import InputField from '../common/InputField';
|
||||
|
||||
type SearchConsoleSettingsProps = {
|
||||
settings: SettingsType,
|
||||
settingsError: null | {
|
||||
type: string,
|
||||
msg: string
|
||||
},
|
||||
updateSettings: Function,
|
||||
}
|
||||
|
||||
const SearchConsoleSettings = ({ settings, settingsError, updateSettings }:SearchConsoleSettingsProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div className='settings__content styled-scrollbar p-6 text-sm'>
|
||||
|
||||
{/* <div className="settings__section__input mb-5">
|
||||
<ToggleField
|
||||
label='Enable Goolge Search Console'
|
||||
value={settings?.scrape_retry ? 'true' : '' }
|
||||
onChange={(val) => updateSettings('scrape_retry', val)}
|
||||
/>
|
||||
</div> */}
|
||||
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
|
||||
<InputField
|
||||
label='Search Console Client Email'
|
||||
onChange={(client_email:string) => updateSettings('search_console_client_email', client_email)}
|
||||
value={settings.search_console_client_email}
|
||||
placeholder='myapp@appspot.gserviceaccount.com'
|
||||
/>
|
||||
</div>
|
||||
<div className="settings__section__input mb-4 flex flex-col justify-between items-center w-full">
|
||||
<label className='mb-2 font-semibold block text-sm text-gray-700 capitalize w-full'>Search Console Private Key</label>
|
||||
<textarea
|
||||
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
|
||||
focus:outline-none h-[100px] focus:border-blue-200`}
|
||||
value={settings.search_console_private_key}
|
||||
placeholder={'-----BEGIN PRIVATE KEY-----/ssssaswdkihad....'}
|
||||
onChange={(event) => updateSettings('search_console_private_key', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchConsoleSettings;
|
||||
@@ -5,6 +5,7 @@ import Icon from '../common/Icon';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import ScraperSettings from './ScraperSettings';
|
||||
import useOnKey from '../../hooks/useOnKey';
|
||||
import SearchConsoleSettings from './SearchConsoleSettings';
|
||||
|
||||
type SettingsProps = {
|
||||
closeSettings: Function,
|
||||
@@ -16,7 +17,7 @@ type SettingsError = {
|
||||
msg: string
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
const defaultSettings: SettingsType = {
|
||||
scraper_type: 'none',
|
||||
scrape_delay: 'none',
|
||||
scrape_retry: false,
|
||||
@@ -27,6 +28,9 @@ const defaultSettings = {
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
notification_email_from: '',
|
||||
search_console: true,
|
||||
search_console_client_email: '',
|
||||
search_console_private_key: '',
|
||||
};
|
||||
|
||||
const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
@@ -81,30 +85,38 @@ const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm';
|
||||
const tabStyle = `inline-block px-3 py-2 rounded-md cursor-pointer text-xs lg:text-sm lg:mr-3 lg:px-4 select-none z-10
|
||||
text-gray-600 border border-b-0 relative top-[1px] rounded-b-none`;
|
||||
const tabStyleActive = 'bg-white text-blue-600 border-slate-200';
|
||||
|
||||
return (
|
||||
<div className="settings fixed w-full h-screen top-0 left-0 z-50" onClick={closeOnBGClick}>
|
||||
<div className="absolute w-full max-w-xs bg-white customShadow top-0 right-0 h-screen" data-loading={isLoading} >
|
||||
<div className="absolute w-full max-w-md bg-white customShadow top-0 right-0 h-screen" data-loading={isLoading} >
|
||||
{isLoading && <div className='absolute flex content-center items-center h-full'><Icon type="loading" size={24} /></div>}
|
||||
<div className='settings__header p-6 border-b border-b-slate-200 text-slate-500'>
|
||||
<div className='settings__header px-5 py-4 text-slate-500'>
|
||||
<h3 className=' text-black text-lg font-bold'>Settings</h3>
|
||||
<button
|
||||
className=' absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
|
||||
className=' absolute top-2 right-2 p-2 px- text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
|
||||
onClick={() => closeSettings()}>
|
||||
<Icon type='close' size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className=' px-4 mt-4 '>
|
||||
<div className='border border-slate-200 px-3 py-4 pb-0 border-l-0 border-r-0 bg-[#f8f9ff]'>
|
||||
<ul>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'scraper' ? ' bg-blue-50 text-blue-600' : ''}`}
|
||||
className={`${tabStyle} ${currentTab === 'scraper' ? tabStyleActive : 'border-transparent '}`}
|
||||
onClick={() => setCurrentTab('scraper')}>
|
||||
Scraper
|
||||
<Icon type='scraper' /> Scraper
|
||||
</li>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-blue-50 text-blue-600' : ''}`}
|
||||
className={`${tabStyle} ${currentTab === 'notification' ? tabStyleActive : 'border-transparent'}`}
|
||||
onClick={() => setCurrentTab('notification')}>
|
||||
Notification
|
||||
<Icon type='email' /> Notification
|
||||
</li>
|
||||
<li
|
||||
className={`${tabStyle} ${currentTab === 'searchconsole' ? tabStyleActive : 'border-transparent'}`}
|
||||
onClick={() => setCurrentTab('searchconsole')}>
|
||||
<Icon type='google' size={14} /> Search Console
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -115,6 +127,9 @@ const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
{currentTab === 'notification' && settings && (
|
||||
<NotificationSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
|
||||
)}
|
||||
{currentTab === 'searchconsole' && settings && (
|
||||
<SearchConsoleSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
|
||||
)}
|
||||
<div className=' border-t-[1px] border-gray-200 p-2 px-3'>
|
||||
<button
|
||||
onClick={() => performUpdate()}
|
||||
|
||||
48
pages/api/domain.ts
Normal file
48
pages/api/domain.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import Cryptr from 'cryptr';
|
||||
import db from '../../database/database';
|
||||
import Domain from '../../database/models/domain';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
|
||||
type DomainGetResponse = {
|
||||
domain?: DomainType | null
|
||||
error?: string|null,
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const authorized = verifyUser(req, res);
|
||||
if (authorized === 'authorized' && req.method === 'GET') {
|
||||
await db.sync();
|
||||
return getDomain(req, res);
|
||||
}
|
||||
return res.status(401).json({ error: authorized });
|
||||
}
|
||||
|
||||
const getDomain = async (req: NextApiRequest, res: NextApiResponse<DomainGetResponse>) => {
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') {
|
||||
return res.status(400).json({ error: 'Domain Name is Required!' });
|
||||
}
|
||||
|
||||
try {
|
||||
const query = { domain: req.query.domain as string };
|
||||
const foundDomain:Domain| null = await Domain.findOne({ where: query });
|
||||
const parsedDomain = foundDomain?.get({ plain: true }) || false;
|
||||
|
||||
if (parsedDomain && parsedDomain.search_console) {
|
||||
try {
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
const scData = JSON.parse(parsedDomain.search_console);
|
||||
scData.client_email = scData.client_email ? cryptr.decrypt(scData.client_email) : '';
|
||||
scData.private_key = scData.private_key ? cryptr.decrypt(scData.private_key) : '';
|
||||
parsedDomain.search_console = JSON.stringify(scData);
|
||||
} catch (error) {
|
||||
console.log('[Error] Parsing Search Console Keys.');
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ domain: parsedDomain });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Getting Domain: ', error);
|
||||
return res.status(400).json({ error: 'Error Loading Domain' });
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import Cryptr from 'cryptr';
|
||||
import db from '../../database/database';
|
||||
import Domain from '../../database/models/domain';
|
||||
import Keyword from '../../database/models/keyword';
|
||||
import getdomainStats from '../../utils/domains';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
import { removeLocalSCData } from '../../utils/searchConsole';
|
||||
import { checkSerchConsoleIntegration, removeLocalSCData } from '../../utils/searchConsole';
|
||||
|
||||
type DomainsGetRes = {
|
||||
domains: DomainType[]
|
||||
@@ -53,7 +54,13 @@ export const getDomains = async (req: NextApiRequest, res: NextApiResponse<Domai
|
||||
const withStats = !!req?.query?.withstats;
|
||||
try {
|
||||
const allDomains: Domain[] = await Domain.findAll();
|
||||
const formattedDomains: DomainType[] = allDomains.map((el) => el.get({ plain: true }));
|
||||
const formattedDomains: DomainType[] = allDomains.map((el) => {
|
||||
const domainItem:DomainType = el.get({ plain: true });
|
||||
const scData = domainItem?.search_console ? JSON.parse(domainItem.search_console) : {};
|
||||
const { client_email, private_key } = scData;
|
||||
const searchConsoleData = scData ? { ...scData, client_email: client_email ? 'true' : '', private_key: private_key ? 'true' : '' } : {};
|
||||
return { ...domainItem, search_console: JSON.stringify(searchConsoleData) };
|
||||
});
|
||||
const theDomains: DomainType[] = withStats ? await getdomainStats(formattedDomains) : allDomains;
|
||||
return res.status(200).json({ domains: theDomains });
|
||||
} catch (error) {
|
||||
@@ -112,6 +119,17 @@ export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
|
||||
|
||||
try {
|
||||
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } });
|
||||
// Validate Search Console API Data
|
||||
if (domainToUpdate && search_console?.client_email && search_console?.private_key) {
|
||||
const theDomainObj = domainToUpdate.get({ plain: true });
|
||||
const isSearchConsoleAPIValid = await checkSerchConsoleIntegration({ ...theDomainObj, search_console: JSON.stringify(search_console) });
|
||||
if (!isSearchConsoleAPIValid.isValid) {
|
||||
return res.status(400).json({ domain: null, error: isSearchConsoleAPIValid.error });
|
||||
}
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
search_console.client_email = search_console.client_email ? cryptr.encrypt(search_console.client_email.trim()) : '';
|
||||
search_console.private_key = search_console.private_key ? cryptr.encrypt(search_console.private_key.trim()) : '';
|
||||
}
|
||||
if (domainToUpdate) {
|
||||
domainToUpdate.set({ notification_interval, notification_emails, search_console: JSON.stringify(search_console) });
|
||||
await domainToUpdate.save();
|
||||
@@ -119,6 +137,6 @@ export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
|
||||
return res.status(200).json({ domain: domainToUpdate });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Updating Domain: ', req.query.domain, error);
|
||||
return res.status(400).json({ domain: null, error: 'Error Updating Domain' });
|
||||
return res.status(400).json({ domain: null, error: 'Error Updating Domain. An Unknown Error Occured.' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import db from '../../database/database';
|
||||
import { getCountryInsight, getKeywordsInsight, getPagesInsight } from '../../utils/insight';
|
||||
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole';
|
||||
import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
import Domain from '../../database/models/domain';
|
||||
|
||||
@@ -24,9 +24,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiResponse<SCInsightRes>) => {
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
|
||||
if (!!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) === false) {
|
||||
return res.status(200).json({ data: null, error: 'Google Search Console Not Integrated' });
|
||||
}
|
||||
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
|
||||
const getInsightFromSCData = (localSCData: SCDomainDataType): InsightDataType => {
|
||||
const { stats = [] } = localSCData;
|
||||
@@ -53,8 +50,11 @@ const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiRe
|
||||
const query = { domain: domainname };
|
||||
const foundDomain:Domain| null = await Domain.findOne({ where: query });
|
||||
const domainObj: DomainType = foundDomain && foundDomain.get({ plain: true });
|
||||
|
||||
const scData = await fetchDomainSCData(domainObj);
|
||||
const scDomainAPI = await getSearchConsoleApiInfo(domainObj);
|
||||
if (!(scDomainAPI.client_email && scDomainAPI.private_key)) {
|
||||
return res.status(200).json({ data: null, error: 'Google Search Console is not Integrated.' });
|
||||
}
|
||||
const scData = await fetchDomainSCData(domainObj, scDomainAPI);
|
||||
const response = getInsightFromSCData(scData);
|
||||
return res.status(200).json({ data: response });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import db from '../../database/database';
|
||||
import Domain from '../../database/models/domain';
|
||||
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole';
|
||||
import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
|
||||
type searchConsoleRes = {
|
||||
@@ -31,13 +31,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const getDomainSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleRes>) => {
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
|
||||
if (!!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) === false) {
|
||||
return res.status(200).json({ data: null, error: 'Google Search Console Not Integrated' });
|
||||
}
|
||||
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
|
||||
const localSCData = await readLocalSCData(domainname);
|
||||
console.log(localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length);
|
||||
|
||||
if (localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length) {
|
||||
return res.status(200).json({ data: localSCData });
|
||||
}
|
||||
@@ -45,7 +40,11 @@ const getDomainSearchConsoleData = async (req: NextApiRequest, res: NextApiRespo
|
||||
const query = { domain: domainname };
|
||||
const foundDomain:Domain| null = await Domain.findOne({ where: query });
|
||||
const domainObj: DomainType = foundDomain && foundDomain.get({ plain: true });
|
||||
const scData = await fetchDomainSCData(domainObj);
|
||||
const scDomainAPI = await getSearchConsoleApiInfo(domainObj);
|
||||
if (!(scDomainAPI.client_email && scDomainAPI.private_key)) {
|
||||
return res.status(200).json({ data: null, error: 'Google Search Console is not Integrated.' });
|
||||
}
|
||||
const scData = await fetchDomainSCData(domainObj, scDomainAPI);
|
||||
return res.status(200).json({ data: scData });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Getting Search Console Data for: ', domainname, error);
|
||||
|
||||
@@ -42,9 +42,11 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
|
||||
}
|
||||
try {
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api) : '';
|
||||
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password) : '';
|
||||
const securedSettings = { ...settings, scaping_api, smtp_password };
|
||||
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api.trim()) : '';
|
||||
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password.trim()) : '';
|
||||
const search_console_client_email = settings.search_console_client_email ? cryptr.encrypt(settings.search_console_client_email.trim()) : '';
|
||||
const search_console_private_key = settings.search_console_private_key ? cryptr.encrypt(settings.search_console_private_key.trim()) : '';
|
||||
const securedSettings = { ...settings, scaping_api, smtp_password, search_console_client_email, search_console_private_key };
|
||||
|
||||
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(securedSettings), { encoding: 'utf-8' });
|
||||
return res.status(200).json({ settings });
|
||||
@@ -67,11 +69,16 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : '';
|
||||
const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : '';
|
||||
const search_console_client_email = settings.search_console_client_email ? cryptr.decrypt(settings.search_console_client_email) : '';
|
||||
const search_console_private_key = settings.search_console_private_key ? cryptr.decrypt(settings.search_console_private_key) : '';
|
||||
decryptedSettings = {
|
||||
...settings,
|
||||
scaping_api,
|
||||
smtp_password,
|
||||
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL),
|
||||
search_console_client_email,
|
||||
search_console_private_key,
|
||||
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL)
|
||||
|| !!(search_console_client_email && search_console_private_key),
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id, allowsCity: !!scraper.allowsCity })),
|
||||
failed_queue: failedQueue,
|
||||
screenshot_key: screenshotAPIKey,
|
||||
@@ -94,6 +101,9 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
smtp_password: '',
|
||||
scrape_retry: false,
|
||||
screenshot_key: screenshotAPIKey,
|
||||
search_console: true,
|
||||
search_console_client_email: '',
|
||||
search_console_private_key: '',
|
||||
};
|
||||
const otherSettings = {
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
|
||||
|
||||
@@ -37,6 +37,11 @@ const SingleDomain: NextPage = () => {
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
const domainHasScAPI = useMemo(() => {
|
||||
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
|
||||
return !!(doaminSc?.client_email && doaminSc?.private_key);
|
||||
}, [activDomain]);
|
||||
|
||||
const { keywordsData, keywordsLoading } = useFetchKeywords(router, activDomain?.domain || '', setKeywordSPollInterval, keywordSPollInterval);
|
||||
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
|
||||
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
|
||||
@@ -72,7 +77,7 @@ const SingleDomain: NextPage = () => {
|
||||
keywords={theKeywords}
|
||||
showAddModal={showAddKeywords}
|
||||
setShowAddModal={setShowAddKeywords}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) }
|
||||
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || domainHasScAPI }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,11 @@ const DiscoverPage: NextPage = () => {
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
const domainHasScAPI = useMemo(() => {
|
||||
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
|
||||
return !!(doaminSc?.client_email && doaminSc?.private_key);
|
||||
}, [activDomain]);
|
||||
|
||||
return (
|
||||
<div className="Domain ">
|
||||
{activDomain && activDomain.domain
|
||||
@@ -65,7 +70,7 @@ const DiscoverPage: NextPage = () => {
|
||||
isLoading={keywordsLoading || isFetching}
|
||||
domain={activDomain}
|
||||
keywords={theKeywords}
|
||||
isConsoleIntegrated={scConnected}
|
||||
isConsoleIntegrated={scConnected || domainHasScAPI}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,11 @@ const InsightPage: NextPage = () => {
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
const domainHasScAPI = useMemo(() => {
|
||||
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
|
||||
return !!(doaminSc?.client_email && doaminSc?.private_key);
|
||||
}, [activDomain]);
|
||||
|
||||
return (
|
||||
<div className="Domain ">
|
||||
{activDomain && activDomain.domain
|
||||
@@ -65,7 +70,7 @@ const InsightPage: NextPage = () => {
|
||||
isLoading={false}
|
||||
domain={activDomain}
|
||||
insight={theInsight}
|
||||
isConsoleIntegrated={scConnected}
|
||||
isConsoleIntegrated={scConnected || domainHasScAPI}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,17 @@ const Domains: NextPage = () => {
|
||||
return keywords;
|
||||
}, [domainsData]);
|
||||
|
||||
const domainSCAPiObj = useMemo(() => {
|
||||
const domainsSCAPI:{ [ID:string] : boolean } = {};
|
||||
if (domainsData?.domains) {
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
const doaminSc = domain?.search_console ? JSON.parse(domain.search_console) : {};
|
||||
domainsSCAPI[domain.ID] = doaminSc.client_email && doaminSc.private_key;
|
||||
});
|
||||
}
|
||||
return domainsSCAPI;
|
||||
}, [domainsData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings.screenshot_key) {
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
@@ -94,7 +105,7 @@ const Domains: NextPage = () => {
|
||||
key={domain.ID}
|
||||
domain={domain}
|
||||
selected={false}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) }
|
||||
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || !!domainSCAPiObj[domain.ID] }
|
||||
thumb={domainThumbs[domain.domain]}
|
||||
updateThumb={manuallyUpdateThumb}
|
||||
// isConsoleIntegrated={false}
|
||||
|
||||
@@ -19,6 +19,19 @@ export async function fetchDomains(router: NextRouter, withStats:boolean): Promi
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchDomain(router: NextRouter, domainName: string): Promise<{domain: DomainType}> {
|
||||
if (!domainName) { throw new Error('No Domain Name Provided!'); }
|
||||
const res = await fetch(`${window.location.origin}/api/domain?domain=${domainName}`, { method: 'GET' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
if (res.status === 401) {
|
||||
console.log('Unauthorized!!');
|
||||
router.push('/login');
|
||||
}
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchDomainScreenshot(domain: string, screenshot_key:string, forceFetch = false): Promise<string | false> {
|
||||
const domainThumbsRaw = localStorage.getItem('domainThumbs');
|
||||
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
|
||||
@@ -53,6 +66,14 @@ export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
|
||||
return useQuery('domains', () => fetchDomains(router, withStats));
|
||||
}
|
||||
|
||||
export function useFetchDomain(router: NextRouter, domainName:string, onSuccess: Function) {
|
||||
return useQuery('domain', () => fetchDomain(router, domainName), {
|
||||
onSuccess: async (data) => {
|
||||
console.log('Domain Loaded!!!', data.domain);
|
||||
onSuccess(data.domain);
|
||||
} });
|
||||
}
|
||||
|
||||
export function useAddDomain(onSuccess:Function) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -89,10 +110,11 @@ export function useUpdateDomain(onSuccess:Function) {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify(domainSettings) };
|
||||
const res = await fetch(`${window.location.origin}/api/domains?domain=${domain.domain}`, fetchOpts);
|
||||
const responseObj = await res.json();
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
throw new Error(responseObj?.error || 'Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
return responseObj;
|
||||
}, {
|
||||
onSuccess: async () => {
|
||||
console.log('Settings Updated!!!');
|
||||
@@ -100,8 +122,8 @@ export function useUpdateDomain(onSuccess:Function) {
|
||||
onSuccess();
|
||||
queryClient.invalidateQueries(['domains']);
|
||||
},
|
||||
onError: () => {
|
||||
console.log('Error Updating Domain Settings!!!');
|
||||
onError: (error) => {
|
||||
console.log('Error Updating Domain Settings!!!', error);
|
||||
toast('Error Updating Domain Settings', { icon: '⚠️' });
|
||||
},
|
||||
});
|
||||
|
||||
11
types.d.ts
vendored
11
types.d.ts
vendored
@@ -64,8 +64,10 @@ type countryCodeData = {
|
||||
}
|
||||
|
||||
type DomainSearchConsole = {
|
||||
property_type?: 'domain' | 'url',
|
||||
url?: string
|
||||
property_type: 'domain' | 'url',
|
||||
url: string,
|
||||
client_email:string,
|
||||
private_key:string,
|
||||
}
|
||||
|
||||
type DomainSettings = {
|
||||
@@ -85,7 +87,6 @@ type SettingsType = {
|
||||
smtp_port: string,
|
||||
smtp_username?: string,
|
||||
smtp_password?: string,
|
||||
search_console_integrated?: boolean,
|
||||
available_scapers?: { label: string, value: string, allowsCity?: boolean }[],
|
||||
scrape_interval?: string,
|
||||
scrape_delay?: string,
|
||||
@@ -93,6 +94,10 @@ type SettingsType = {
|
||||
failed_queue?: string[]
|
||||
version?: string,
|
||||
screenshot_key?: string,
|
||||
search_console: boolean,
|
||||
search_console_client_email: string,
|
||||
search_console_private_key: string,
|
||||
search_console_integrated?: boolean,
|
||||
}
|
||||
|
||||
type KeywordSCDataChild = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { auth, searchconsole_v1 } from '@googleapis/searchconsole';
|
||||
import Cryptr from 'cryptr';
|
||||
import { readFile, writeFile, unlink } from 'fs/promises';
|
||||
import { getCountryCodeFromAlphaThree } from './countries';
|
||||
|
||||
@@ -7,24 +8,32 @@ export type SCDomainFetchError = {
|
||||
errorMsg: string,
|
||||
}
|
||||
|
||||
type SCAPISettings = { client_email: string, private_key: string }
|
||||
|
||||
type fetchConsoleDataResponse = SearchAnalyticsItem[] | SearchAnalyticsStat[] | SCDomainFetchError;
|
||||
|
||||
/**
|
||||
* function that retrieves data from the Google Search Console API based on the provided domain name, number of days, and optional type.
|
||||
* Retrieves data from the Google Search Console API based on the provided domain name, number of days, and optional type.
|
||||
* @param {DomainType} domain - The domain for which you want to fetch search console data.
|
||||
* @param {number} days - The `days` parameter is the number of days of data you want to fetch from the Search Console.
|
||||
* @param {string} [type] - The `type` parameter is an optional parameter that specifies the type of data to fetch from the Search Console API.
|
||||
* @param {number} days - number of days of data you want to fetch from the Search Console.
|
||||
* @param {string} [type] - (optional) specifies the type of data to fetch from the Search Console.
|
||||
* @param {SCAPISettings} [api] - (optional) specifies the Seach Console API Information.
|
||||
* @returns {Promise<fetchConsoleDataResponse>}
|
||||
*/
|
||||
const fetchSearchConsoleData = async (domain:DomainType, days:number, type?:string): Promise<fetchConsoleDataResponse> => {
|
||||
const fetchSearchConsoleData = async (domain:DomainType, days:number, type?:string, api?:SCAPISettings): Promise<fetchConsoleDataResponse> => {
|
||||
if (!domain) return { error: true, errorMsg: 'Domain Not Provided!' };
|
||||
if (!api?.private_key || !api?.client_email) return { error: true, errorMsg: 'Search Console API Data Not Avaialable.' };
|
||||
const domainName = domain.domain;
|
||||
const domainSettings = domain.search_console ? JSON.parse(domain.search_console) : { property_type: 'domain', url: '' };
|
||||
const defaultSCSettings = { property_type: 'domain', url: '', client_email: '', private_key: '' };
|
||||
const domainSettings = domain.search_console ? JSON.parse(domain.search_console) : defaultSCSettings;
|
||||
const sCPrivateKey = api?.private_key || process.env.SEARCH_CONSOLE_PRIVATE_KEY || '';
|
||||
const sCClientEmail = api?.client_email || process.env.SEARCH_CONSOLE_CLIENT_EMAIL || '';
|
||||
|
||||
try {
|
||||
const authClient = new auth.GoogleAuth({
|
||||
credentials: {
|
||||
private_key: process.env.SEARCH_CONSOLE_PRIVATE_KEY ? process.env.SEARCH_CONSOLE_PRIVATE_KEY.replaceAll('\\n', '\n') : '',
|
||||
client_email: process.env.SEARCH_CONSOLE_CLIENT_EMAIL ? process.env.SEARCH_CONSOLE_CLIENT_EMAIL : '',
|
||||
private_key: (sCPrivateKey).replaceAll('\\n', '\n'),
|
||||
client_email: (sCClientEmail || '').trim(),
|
||||
},
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||
@@ -73,10 +82,12 @@ const fetchSearchConsoleData = async (domain:DomainType, days:number, type?:stri
|
||||
}
|
||||
|
||||
return finalRows;
|
||||
} catch (error:any) {
|
||||
} catch (err:any) {
|
||||
const qType = type === 'stats' ? '(stats)' : `(${days}days)`;
|
||||
console.log(`[ERROR] Search Console API Error for ${domainName} ${qType} : `, error?.response?.status, error?.response?.statusText);
|
||||
return { error: true, errorMsg: `${error?.response?.status}: ${error?.response?.statusText}` };
|
||||
const errorMsg = err?.response?.status && `${err?.response?.statusText}. ${err?.response?.data?.error_description}`;
|
||||
console.log(`[ERROR] Search Console API Error for ${domainName} ${qType} : `, errorMsg || err?.code);
|
||||
// console.log('SC ERROR :', err);
|
||||
return { error: true, errorMsg: errorMsg || err?.code };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,12 +98,13 @@ const fetchSearchConsoleData = async (domain:DomainType, days:number, type?:stri
|
||||
* @returns The function `fetchDomainSCData` is returning a Promise that resolves to an object of type
|
||||
* `SCDomainDataType`.
|
||||
*/
|
||||
export const fetchDomainSCData = async (domain:DomainType): Promise<SCDomainDataType> => {
|
||||
export const fetchDomainSCData = async (domain:DomainType, scAPI?: SCAPISettings): Promise<SCDomainDataType> => {
|
||||
const days = [3, 7, 30];
|
||||
const scDomainData:SCDomainDataType = { threeDays: [], sevenDays: [], thirtyDays: [], lastFetched: '', lastFetchError: '', stats: [] };
|
||||
if (domain.domain) {
|
||||
if (domain.domain && scAPI) {
|
||||
const theDomain = domain;
|
||||
for (const day of days) {
|
||||
const items = await fetchSearchConsoleData(domain, day);
|
||||
const items = await fetchSearchConsoleData(theDomain, day, undefined, scAPI);
|
||||
scDomainData.lastFetched = new Date().toJSON();
|
||||
if (Array.isArray(items)) {
|
||||
if (day === 3) scDomainData.threeDays = items as SearchAnalyticsItem[];
|
||||
@@ -102,7 +114,7 @@ export const fetchDomainSCData = async (domain:DomainType): Promise<SCDomainData
|
||||
scDomainData.lastFetchError = items.errorMsg;
|
||||
}
|
||||
}
|
||||
const stats = await fetchSearchConsoleData(domain, 30, 'stat');
|
||||
const stats = await fetchSearchConsoleData(theDomain, 30, 'stat', scAPI);
|
||||
if (stats && Array.isArray(stats) && stats.length > 0) {
|
||||
scDomainData.stats = stats as SearchAnalyticsStat[];
|
||||
}
|
||||
@@ -167,6 +179,57 @@ export const integrateKeywordSCData = (keyword: KeywordType, SCData:SCDomainData
|
||||
return { ...keyword, scData: finalSCData };
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the Search Console API information for a given domain.
|
||||
* @param {DomainType} domain - The `domain` parameter is of type `DomainType`, which represents a
|
||||
* domain object. It likely contains information about a specific domain, such as its name, search
|
||||
* console settings, etc.
|
||||
* @returns an object of type `SCAPISettings`.
|
||||
*/
|
||||
export const getSearchConsoleApiInfo = async (domain: DomainType): Promise<SCAPISettings> => {
|
||||
const scAPIData = { client_email: '', private_key: '' };
|
||||
// Check if the Domain Has the API Data
|
||||
const domainSCSettings = domain.search_console && JSON.parse(domain.search_console);
|
||||
if (domainSCSettings && domainSCSettings.private_key) {
|
||||
if (!domainSCSettings.private_key.includes('BEGIN PRIVATE KEY')) {
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
scAPIData.client_email = domainSCSettings.client_email ? cryptr.decrypt(domainSCSettings.client_email) : '';
|
||||
scAPIData.private_key = domainSCSettings.private_key ? cryptr.decrypt(domainSCSettings.private_key) : '';
|
||||
} else {
|
||||
scAPIData.client_email = domainSCSettings.client_email;
|
||||
scAPIData.private_key = domainSCSettings.private_key;
|
||||
}
|
||||
}
|
||||
// Check if the App Settings Has the API Data
|
||||
if (!scAPIData?.private_key) {
|
||||
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);
|
||||
scAPIData.client_email = settings.search_console_client_email ? cryptr.decrypt(settings.search_console_client_email) : '';
|
||||
scAPIData.private_key = settings.search_console_private_key ? cryptr.decrypt(settings.search_console_private_key) : '';
|
||||
}
|
||||
if (!scAPIData?.private_key && process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) {
|
||||
scAPIData.client_email = process.env.SEARCH_CONSOLE_CLIENT_EMAIL;
|
||||
scAPIData.private_key = process.env.SEARCH_CONSOLE_PRIVATE_KEY;
|
||||
}
|
||||
|
||||
return scAPIData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the provided domain level Google Search Console API info is valid.
|
||||
* @param {DomainType} domain - The domain that represents the domain for which the SC API info is being checked.
|
||||
* @returns an object of type `{ isValid: boolean, error: string }`.
|
||||
*/
|
||||
export const checkSerchConsoleIntegration = async (domain: DomainType): Promise<{ isValid: boolean, error: string }> => {
|
||||
const res = { isValid: false, error: '' };
|
||||
const { client_email = '', private_key = '' } = domain?.search_console ? JSON.parse(domain.search_console) : {};
|
||||
const response = await fetchSearchConsoleData(domain, 3, undefined, { client_email, private_key });
|
||||
if (Array.isArray(response)) { res.isValid = true; }
|
||||
if ((response as SCDomainFetchError)?.errorMsg) { res.error = (response as SCDomainFetchError).errorMsg; }
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* The function reads and returns the domain-specific data stored in a local JSON file.
|
||||
* @param {string} domain - The `domain` parameter is a string that represents the domain for which the SC data is being read.
|
||||
|
||||
Reference in New Issue
Block a user