Compare commits
2 الالتزامات
954fc4c34a
...
ui
| المؤلف | SHA1 | التاريخ | |
|---|---|---|---|
|
|
b211e5efd8 | ||
|
|
73e2f11fe5 |
27
package-lock.json
مولّد
27
package-lock.json
مولّد
@@ -18,7 +18,8 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.9.1",
|
"react-router-dom": "^7.9.1",
|
||||||
"recharts": "^3.2.1"
|
"recharts": "^3.2.1",
|
||||||
|
"recoil": "^0.7.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
@@ -3329,6 +3330,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hamt_plus": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-t2JXKaehnMb9paaYA7J0BX8QQAY8lwfQ9Gjf4pg/mk4krt+cmwmU652HOoWonf+7+EQV97ARPMhhVgU1ra2GhA=="
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -4395,6 +4401,25 @@
|
|||||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recoil": {
|
||||||
|
"version": "0.7.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/recoil/-/recoil-0.7.7.tgz",
|
||||||
|
"integrity": "sha512-8Og5KPQW9LwC577Vc7Ug2P0vQshkv1y3zG3tSSkWMqkWSwHmE+by06L8JtnGocjW6gcCvfwB3YtrJG6/tWivNQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"hamt_plus": "1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.13.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.9.1",
|
"react-router-dom": "^7.9.1",
|
||||||
"recharts": "^3.2.1"
|
"recharts": "^3.2.1",
|
||||||
|
"recoil": "^0.7.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|||||||
6
src/atoms/siteAtom.ts
Normal file
6
src/atoms/siteAtom.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { atom } from 'recoil';
|
||||||
|
|
||||||
|
export const siteAtom = atom<string>({
|
||||||
|
key: 'siteAtom',
|
||||||
|
default: ''
|
||||||
|
});
|
||||||
@@ -6,11 +6,19 @@ import { ActionBadge } from "./shared/ActionBadge";
|
|||||||
|
|
||||||
interface KeywordRowProps {
|
interface KeywordRowProps {
|
||||||
keyword: KeywordRowType;
|
keyword: KeywordRowType;
|
||||||
|
hasGmb: boolean;
|
||||||
|
llmStatus: {
|
||||||
|
google: { status: string };
|
||||||
|
gemini: { status: string };
|
||||||
|
perplexity: { status: string };
|
||||||
|
chatGPT: { status: string };
|
||||||
|
};
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeywordRow({ keyword }: KeywordRowProps) {
|
export function KeywordRow({ keyword, hasGmb, llmStatus, isLoading }: KeywordRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-12 gap-4 justify-items-cente px-6 pr-[52px] py-3 border-t border-grey-300 min-h-[54px] hover:bg-gray-100 transition-colors">
|
<div className={`grid ${hasGmb ? 'grid-cols-12' : 'grid-cols-11'} gap-4 justify-items-cente px-6 pr-[52px] py-3 border-t border-grey-300 min-h-[54px] hover:bg-gray-100 transition-colors`}>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 mr-20 col-span-2" >
|
<div className="flex items-center gap-1 mr-20 col-span-2" >
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className="shrink-0">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className="shrink-0">
|
||||||
@@ -24,27 +32,69 @@ export function KeywordRow({ keyword }: KeywordRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MetricBadge type="google" value={keyword.googlePos} />
|
{isLoading ? (
|
||||||
|
<div className="animate-pulse h-4 w-4 rounded-full bg-gray-200"></div>
|
||||||
|
) : (
|
||||||
|
<div className={`text-xs font-medium ${
|
||||||
|
llmStatus.google.status === "Mentioned" ? "text-green-500" :
|
||||||
|
llmStatus.google.status === "Not Mentioned" ? "text-red-500" :
|
||||||
|
"text-gray-500"
|
||||||
|
}`}>
|
||||||
|
{llmStatus.google.status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{hasGmb && (
|
||||||
{keyword.mapsPos && <MetricBadge type="maps" value={keyword.mapsPos} />}
|
<div>
|
||||||
</div>
|
{keyword.mapsPos && <MetricBadge type="maps" value={keyword.mapsPos} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<VisibilityIndicator platform="aiOverview" visible={keyword.aiOverview === "visible"} />
|
<VisibilityIndicator platform="aiOverview" visible={keyword.aiOverview === "visible"} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<VisibilityIndicator platform="chatGPT" visible={keyword.chatGPT === "visible"} />
|
{isLoading ? (
|
||||||
|
<div className="animate-pulse h-4 w-4 rounded-full bg-gray-200"></div>
|
||||||
|
) : (
|
||||||
|
<div className={`text-xs font-medium ${
|
||||||
|
llmStatus.chatGPT.status === "Mentioned" ? "text-green-500" :
|
||||||
|
llmStatus.chatGPT.status === "Not Mentioned" ? "text-red-500" :
|
||||||
|
"text-gray-500"
|
||||||
|
}`}>
|
||||||
|
{llmStatus.chatGPT.status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<VisibilityIndicator platform="gemini" visible={keyword.gemini === "visible"} />
|
{isLoading ? (
|
||||||
|
<div className="animate-pulse h-4 w-4 rounded-full bg-gray-200"></div>
|
||||||
|
) : (
|
||||||
|
<div className={`text-xs font-medium ${
|
||||||
|
llmStatus.gemini.status === "Mentioned" ? "text-green-500" :
|
||||||
|
llmStatus.gemini.status === "Not Mentioned" ? "text-red-500" :
|
||||||
|
"text-gray-500"
|
||||||
|
}`}>
|
||||||
|
{llmStatus.gemini.status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<VisibilityIndicator platform="perplexity" visible={keyword.perplexity === "visible"} />
|
{isLoading ? (
|
||||||
|
<div className="animate-pulse h-4 w-4 rounded-full bg-gray-200"></div>
|
||||||
|
) : (
|
||||||
|
<div className={`text-xs font-medium ${
|
||||||
|
llmStatus.perplexity.status === "Mentioned" ? "text-green-500" :
|
||||||
|
llmStatus.perplexity.status === "Not Mentioned" ? "text-red-500" :
|
||||||
|
"text-gray-500"
|
||||||
|
}`}>
|
||||||
|
{llmStatus.perplexity.status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ import MetricTooltip from "./shared/MetricTooltip";
|
|||||||
|
|
||||||
interface KeywordSectionProps {
|
interface KeywordSectionProps {
|
||||||
section: KeywordSectionType;
|
section: KeywordSectionType;
|
||||||
|
hasGmb: boolean;
|
||||||
|
llmStatuses: Record<string, {
|
||||||
|
google: { status: string };
|
||||||
|
gemini: { status: string };
|
||||||
|
perplexity: { status: string };
|
||||||
|
chatGPT: { status: string };
|
||||||
|
}>;
|
||||||
|
loadingKeywords: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeywordSection({ section }: KeywordSectionProps) {
|
export function KeywordSection({ section, hasGmb, llmStatuses, loadingKeywords }: KeywordSectionProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(section.expanded);
|
const [isExpanded, setIsExpanded] = useState(section.expanded);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +54,11 @@ export function KeywordSection({ section }: KeywordSectionProps) {
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-semibold text-grey-800 leading-[18px]">G Pos #</span>
|
<span className="text-xs font-semibold text-grey-800 leading-[18px]">G Pos #</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{hasGmb && (
|
||||||
<span className="text-xs font-semibold text-grey-800 leading-[18px]">Maps</span>
|
<div>
|
||||||
</div>
|
<span className="text-xs font-semibold text-grey-800 leading-[18px]">Maps</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-semibold text-grey-800 leading-[18px]">AI Overview</span>
|
<span className="text-xs font-semibold text-grey-800 leading-[18px]">AI Overview</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,15 +85,21 @@ export function KeywordSection({ section }: KeywordSectionProps) {
|
|||||||
|
|
||||||
<div className="bg-grey-50">
|
<div className="bg-grey-50">
|
||||||
{section.keywords.map((keyword) => (
|
{section.keywords.map((keyword) => (
|
||||||
<KeywordRow key={keyword.id} keyword={keyword} />
|
<KeywordRow
|
||||||
|
key={keyword.id}
|
||||||
|
keyword={keyword}
|
||||||
|
hasGmb={hasGmb}
|
||||||
|
llmStatus={llmStatuses[keyword.id]}
|
||||||
|
isLoading={loadingKeywords[keyword.id]}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2 py-3.5 px-2 bg-[#FAFAFC]">
|
{/* <div className="flex items-center justify-center gap-2 py-3.5 px-2 bg-[#FAFAFC]">
|
||||||
<button className="text-sm font-medium text-[#4C60E5] underline leading-[17px] hover:text-primary/80 transition-colors">
|
<button className="text-sm font-medium text-[#4C60E5] underline leading-[17px] hover:text-primary/80 transition-colors">
|
||||||
Load 10 More Keywords
|
Load 10 More Keywords
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div> */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const Header = () => {
|
|||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
||||||
const isHeaderVisible = location.pathname !== '/';
|
const isHeaderVisible = location.pathname !== '/';
|
||||||
|
const isLoggedIn = localStorage.getItem('sp_user') !== null;
|
||||||
|
|
||||||
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
|
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
|
||||||
const toggleMobileNav = () => setIsMobileNavOpen(!isMobileNavOpen);
|
const toggleMobileNav = () => setIsMobileNavOpen(!isMobileNavOpen);
|
||||||
@@ -121,20 +122,29 @@ const Header = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Side - Desktop Only */}
|
{/* Right Side - Desktop Only */}
|
||||||
<div className="hidden md:flex items-center space-x-4 relative">
|
<div className="hidden md:flex items-center space-x-4 relative">
|
||||||
{/* Add Keyword Button */}
|
{isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
{/* Add Keyword Button */}
|
||||||
|
<button className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-xl font-medium shadow-md hover:bg-blue-700 transition-colors duration-200 "
|
||||||
|
onClick={_ => setShowOverlay(true)}>
|
||||||
|
<Plus size={18} />
|
||||||
|
<span>Add Keywords</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-xl font-medium shadow-md hover:bg-blue-700 transition-colors duration-200 "
|
<div className="w-0 h-6 outline outline-1 outline-offset-[-0.50px] outline-indigo-200"></div>
|
||||||
onClick={_ => setShowOverlay(true)}>
|
</>
|
||||||
<Plus size={18} />
|
) : (
|
||||||
<span>Add Keywords</span>
|
<Link
|
||||||
</button>
|
to="/login"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-xl font-medium shadow-md hover:bg-blue-700 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Profile Dropdown */}
|
||||||
<div className="w-0 h-6 outline outline-1 outline-offset-[-0.50px] outline-indigo-200"></div>
|
|
||||||
|
|
||||||
{/* Profile Dropdown */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={toggleMenu}
|
onClick={toggleMenu}
|
||||||
@@ -190,10 +200,18 @@ const Header = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile nav - visible when toggled */}
|
{/* Mobile nav - visible when toggled */}
|
||||||
{isMobileNavOpen && (
|
{isMobileNavOpen && (
|
||||||
<div className="md:hidden px-4 pb-4">
|
<div className="md:hidden px-4 pb-4">
|
||||||
{/* Nav links */}
|
{!isLoggedIn && (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="w-full mb-4 flex justify-center px-4 py-2 bg-blue-600 text-white rounded-xl font-medium shadow-md hover:bg-blue-700 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{/* Nav links */}
|
||||||
<div className="flex flex-col gap-2 rounded-xl bg-blue-100/30 p-3 mt-2">
|
<div className="flex flex-col gap-2 rounded-xl bg-blue-100/30 p-3 mt-2">
|
||||||
{navLinks.map((link, index) => (
|
{navLinks.map((link, index) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
10
src/main.tsx
10
src/main.tsx
@@ -12,6 +12,13 @@ import { setContext } from '@apollo/client/link/context'
|
|||||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
|
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
|
||||||
import { createClient } from 'graphql-ws'
|
import { createClient } from 'graphql-ws'
|
||||||
import { getMainDefinition } from '@apollo/client/utilities'
|
import { getMainDefinition } from '@apollo/client/utilities'
|
||||||
|
import {
|
||||||
|
RecoilRoot,
|
||||||
|
atom,
|
||||||
|
selector,
|
||||||
|
useRecoilState,
|
||||||
|
useRecoilValue,
|
||||||
|
} from 'recoil';
|
||||||
|
|
||||||
// Initialize Nhost client
|
// Initialize Nhost client
|
||||||
const nhost = new NhostClient({
|
const nhost = new NhostClient({
|
||||||
@@ -99,7 +106,10 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<NhostProvider nhost={nhost}>
|
<NhostProvider nhost={nhost}>
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ApolloProvider client={client}>
|
<ApolloProvider client={client}>
|
||||||
|
<RecoilRoot>
|
||||||
|
|
||||||
<App />
|
<App />
|
||||||
|
</RecoilRoot>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
</NhostProvider>,
|
</NhostProvider>,
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import Herosection from "../assets/herosection.svg"
|
import Herosection from "../assets/herosection.svg"
|
||||||
import DashboardPreview from "../assets/MacBookPro.svg"
|
import DashboardPreview from "../assets/MacBookPro.svg"
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useSetRecoilState } from 'recoil'
|
||||||
|
import { siteAtom } from '../atoms/siteAtom'
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
|
const [domain, setDomain] = useState('')
|
||||||
|
const setSite = useSetRecoilState(siteAtom)
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleDomainSubmit = () => {
|
||||||
|
// Basic validation - should start with http:// or https://
|
||||||
|
if (!domain.match(/^https?:\/\//)) {
|
||||||
|
alert('Please enter a valid URL starting with http:// or https://')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSite(domain)
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white overflow-x-hidden">
|
<div className="min-h-screen bg-white overflow-x-hidden">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -130,6 +148,8 @@ const HomePage = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter Your Website URL"
|
placeholder="Enter Your Website URL"
|
||||||
className="flex-1 px-4 py-2 sm:py-0 text-sm md:text-base text-[#65677D] bg-transparent outline-none placeholder:text-[#65677D]"
|
className="flex-1 px-4 py-2 sm:py-0 text-sm md:text-base text-[#65677D] bg-transparent outline-none placeholder:text-[#65677D]"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="px-3 md:px-4 py-2.5 rounded-[67px] text-white text-xs md:text-sm font-medium whitespace-nowrap"
|
className="px-3 md:px-4 py-2.5 rounded-[67px] text-white text-xs md:text-sm font-medium whitespace-nowrap"
|
||||||
@@ -137,6 +157,7 @@ const HomePage = () => {
|
|||||||
background: 'linear-gradient(180deg, #4A81FC 0%, #334EFD 100%)',
|
background: 'linear-gradient(180deg, #4A81FC 0%, #334EFD 100%)',
|
||||||
boxShadow: '0 1px 3px 0 rgba(2, 10, 66, 0.38), 0 0 0 1px #4D64FB'
|
boxShadow: '0 1px 3px 0 rgba(2, 10, 66, 0.38), 0 0 0 1px #4D64FB'
|
||||||
}}
|
}}
|
||||||
|
onClick={handleDomainSubmit}
|
||||||
>
|
>
|
||||||
Let Us Show You How
|
Let Us Show You How
|
||||||
</button>
|
</button>
|
||||||
@@ -180,4 +201,4 @@ const HomePage = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HomePage
|
export default HomePage
|
||||||
|
|||||||
@@ -1,22 +1,233 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { keywordSections } from "../assets/keyword-data";
|
|
||||||
import { KeywordSection } from "../components/KeywordSection";
|
import { KeywordSection } from "../components/KeywordSection";
|
||||||
import ContainerPage from '../components/shared/ContainerPage';
|
import ContainerPage from '../components/shared/ContainerPage';
|
||||||
import HeaderPage from '../components/shared/HeaderPage';
|
import HeaderPage from '../components/shared/HeaderPage';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { siteAtom } from '../atoms/siteAtom';
|
||||||
|
|
||||||
|
interface ApiKeywordResponse {
|
||||||
|
domain: string;
|
||||||
|
results: {
|
||||||
|
bofu: string[];
|
||||||
|
mofu: string[];
|
||||||
|
tofu: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GMBResponse {
|
||||||
|
has_gmb: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LLMMentionStatus {
|
||||||
|
status: "Mentioned" | "Not Mentioned" | "N/A" | "Loading" | "Error";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeywordLLMStatus {
|
||||||
|
google: LLMMentionStatus;
|
||||||
|
gemini: LLMMentionStatus;
|
||||||
|
perplexity: LLMMentionStatus;
|
||||||
|
chatGPT: LLMMentionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const Keywords = () => {
|
const Keywords = () => {
|
||||||
|
const [keywordSections, setKeywordSections] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasGmb, setHasGmb] = useState<boolean>(false);
|
||||||
|
const [llmStatuses, setLlmStatuses] = useState<Record<string, KeywordLLMStatus>>({});
|
||||||
|
const [loadingKeywords, setLoadingKeywords] = useState<Record<string, boolean>>({});
|
||||||
|
const site = useRecoilValue(siteAtom);
|
||||||
|
|
||||||
|
console.log("site", site)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
throw new Error('No site domain set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch GMB status
|
||||||
|
const gmbResponse = await fetch(`https://has-gmb-e7d107688dea.hosted.ghaymah.systems/has_gmb?domain=${encodeURIComponent(site)}`);
|
||||||
|
const gmbData: GMBResponse = await gmbResponse.json();
|
||||||
|
setHasGmb(gmbData.has_gmb);
|
||||||
|
|
||||||
|
// Fetch keywords
|
||||||
|
const keywordsResponse = await fetch(`https://keyword-funnel-api-abbc7b57dc18.hosted.ghaymah.systems/get_categorized_keywords?domain=${encodeURIComponent(site)}`);
|
||||||
|
const keywordsData: ApiKeywordResponse = await keywordsResponse.json();
|
||||||
|
|
||||||
|
// Transform API data to match expected format
|
||||||
|
const transformedSections = [
|
||||||
|
{
|
||||||
|
id: "bofu",
|
||||||
|
title: "Transactional, Immediate Business Impact, Highest Competition Opportunities (Bottom of Funnel - BoFu)",
|
||||||
|
subtitle: "High Conversion Intent. Immediate Revenue Drivers.",
|
||||||
|
expanded: true,
|
||||||
|
keywords: keywordsData.results.bofu.map((keyword: string, index: number) => ({
|
||||||
|
id: `bofu-${index}`,
|
||||||
|
keyword,
|
||||||
|
searchVolume: 0,
|
||||||
|
googlePos: 0,
|
||||||
|
mapsPos: null,
|
||||||
|
aiOverview: "invisible",
|
||||||
|
chatGPT: "invisible",
|
||||||
|
gemini: "invisible",
|
||||||
|
perplexity: "invisible",
|
||||||
|
competition: "medium",
|
||||||
|
promptExplorer: "coming-soon",
|
||||||
|
status: "Add-Keyword"
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mofu",
|
||||||
|
title: "Consideration Opportunities (Middle of Funnel - MoFu)",
|
||||||
|
subtitle: "Research and Comparison Intent. Nurturing Leads.",
|
||||||
|
expanded: false,
|
||||||
|
keywords: keywordsData.results.mofu.map((keyword: string, index: number) => ({
|
||||||
|
id: `mofu-${index}`,
|
||||||
|
keyword,
|
||||||
|
searchVolume: 0,
|
||||||
|
googlePos: 0,
|
||||||
|
mapsPos: null,
|
||||||
|
aiOverview: "invisible",
|
||||||
|
chatGPT: "invisible",
|
||||||
|
gemini: "invisible",
|
||||||
|
perplexity: "invisible",
|
||||||
|
competition: "medium",
|
||||||
|
promptExplorer: "coming-soon",
|
||||||
|
status: "Add-Keyword"
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tofu",
|
||||||
|
title: "Educational Opportunities (Top of Funnel - ToFu)",
|
||||||
|
subtitle: "Informational Intent. Essential for building E-E-A-T and AI Trust.",
|
||||||
|
expanded: false,
|
||||||
|
keywords: keywordsData.results.tofu.map((keyword: string, index: number) => ({
|
||||||
|
id: `tofu-${index}`,
|
||||||
|
keyword,
|
||||||
|
searchVolume: 0,
|
||||||
|
googlePos: 0,
|
||||||
|
mapsPos: null,
|
||||||
|
aiOverview: "invisible",
|
||||||
|
chatGPT: "invisible",
|
||||||
|
gemini: "invisible",
|
||||||
|
perplexity: "invisible",
|
||||||
|
competition: "medium",
|
||||||
|
promptExplorer: "coming-soon",
|
||||||
|
status: "Add-Keyword"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
setKeywordSections(transformedSections);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// Initialize LLM statuses and loading states
|
||||||
|
const initialStatuses: Record<string, KeywordLLMStatus> = {};
|
||||||
|
const initialLoading: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
transformedSections.forEach(section => {
|
||||||
|
section.keywords.forEach(keyword => {
|
||||||
|
initialStatuses[keyword.id] = {
|
||||||
|
google: { status: "Loading" },
|
||||||
|
gemini: { status: "Loading" },
|
||||||
|
perplexity: { status: "Loading" },
|
||||||
|
chatGPT: { status: "Loading" }
|
||||||
|
};
|
||||||
|
initialLoading[keyword.id] = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setLlmStatuses(initialStatuses);
|
||||||
|
setLoadingKeywords(initialLoading);
|
||||||
|
|
||||||
|
// Process keywords sequentially
|
||||||
|
for (const section of transformedSections) {
|
||||||
|
for (const keyword of section.keywords) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://llm-mention-api-2407b03f0ba9.hosted.ghaymah.systems/api/check-mention?keyword=${encodeURIComponent(keyword.keyword)}&url=${encodeURIComponent(site)}&llms=google,gemini,perplexity,chatgpt`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
setLlmStatuses(prev => ({
|
||||||
|
...prev,
|
||||||
|
[keyword.id]: {
|
||||||
|
google: { status: data.results.google?.status || "N/A" },
|
||||||
|
gemini: { status: data.results.gemini?.status || "N/A" },
|
||||||
|
perplexity: { status: data.results.perplexity?.status || "N/A" },
|
||||||
|
chatGPT: { status: data.results.chatGPT?.status || "N/A" }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
setLlmStatuses(prev => ({
|
||||||
|
...prev,
|
||||||
|
[keyword.id]: {
|
||||||
|
google: { status: "Error" },
|
||||||
|
gemini: { status: "Error" },
|
||||||
|
perplexity: { status: "Error" },
|
||||||
|
chatGPT: { status: "Error" }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setLoadingKeywords(prev => ({
|
||||||
|
...prev,
|
||||||
|
[keyword.id]: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch data');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ContainerPage>
|
||||||
|
<HeaderPage title="Keyword Visibility Matrix" buttonShow={true} />
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
</ContainerPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ContainerPage>
|
||||||
|
<HeaderPage title="Keyword Visibility Matrix" buttonShow={true} />
|
||||||
|
<div className="flex justify-center items-center h-64 text-red-500">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</ContainerPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContainerPage>
|
<ContainerPage>
|
||||||
<HeaderPage title="Keyword Visibility Matrix" buttonShow={true} />
|
<HeaderPage title="Keyword Visibility Matrix" buttonShow={true} />
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{keywordSections.map((section) => (
|
{keywordSections.map((section) => (
|
||||||
<KeywordSection key={section.id} section={section} />
|
<KeywordSection
|
||||||
))}
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
hasGmb={hasGmb}
|
||||||
|
llmStatuses={llmStatuses}
|
||||||
|
loadingKeywords={loadingKeywords}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ContainerPage>
|
</ContainerPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Keywords
|
export default Keywords
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const Login = () => {
|
|||||||
// Redirect or handle successful authentication
|
// Redirect or handle successful authentication
|
||||||
console.log('Authentication successful:', result.data);
|
console.log('Authentication successful:', result.data);
|
||||||
// You can redirect here or handle the successful login
|
// You can redirect here or handle the successful login
|
||||||
window.location.href = '/'; // Example redirect
|
window.location.href = '/dashboard/keywords'; // Example redirect
|
||||||
} else {
|
} else {
|
||||||
// Error is already set in the hook, no need to set it again
|
// Error is already set in the hook, no need to set it again
|
||||||
console.error('Authentication failed:', result.error);
|
console.error('Authentication failed:', result.error);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['react', 'react-dom', 'recoil']
|
||||||
|
},
|
||||||
css: {
|
css: {
|
||||||
postcss: './postcss.config.js',
|
postcss: './postcss.config.js',
|
||||||
},
|
},
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم