2 الالتزامات

المؤلف SHA1 الرسالة التاريخ
Your Name
b211e5efd8 Adding atom 2025-10-26 09:41:44 +00:00
Your Name
73e2f11fe5 working table 2025-10-26 08:07:11 +00:00
11 ملفات معدلة مع 405 إضافات و44 حذوفات

27
package-lock.json مولّد
عرض الملف

@@ -18,7 +18,8 @@
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.1",
"recharts": "^3.2.1"
"recharts": "^3.2.1",
"recoil": "^0.7.7"
},
"devDependencies": {
"@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": {
"version": "4.0.0",
"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"
}
},
"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": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",

عرض الملف

@@ -20,7 +20,8 @@
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.1",
"recharts": "^3.2.1"
"recharts": "^3.2.1",
"recoil": "^0.7.7"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

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 {
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 (
<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" >
<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>
<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>
{keyword.mapsPos && <MetricBadge type="maps" value={keyword.mapsPos} />}
</div>
{hasGmb && (
<div>
{keyword.mapsPos && <MetricBadge type="maps" value={keyword.mapsPos} />}
</div>
)}
<div>
<VisibilityIndicator platform="aiOverview" visible={keyword.aiOverview === "visible"} />
</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>
<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>
<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>

عرض الملف

@@ -6,9 +6,17 @@ import MetricTooltip from "./shared/MetricTooltip";
interface KeywordSectionProps {
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);
return (
@@ -46,9 +54,11 @@ export function KeywordSection({ section }: KeywordSectionProps) {
<div>
<span className="text-xs font-semibold text-grey-800 leading-[18px]">G Pos #</span>
</div>
<div>
<span className="text-xs font-semibold text-grey-800 leading-[18px]">Maps</span>
</div>
{hasGmb && (
<div>
<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]">AI Overview</span>
</div>
@@ -75,15 +85,21 @@ export function KeywordSection({ section }: KeywordSectionProps) {
<div className="bg-grey-50">
{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 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">
Load 10 More Keywords
</button>
</div>
</div> */}
</>
)}
</div>

عرض الملف

@@ -34,6 +34,7 @@ const Header = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
const isHeaderVisible = location.pathname !== '/';
const isLoggedIn = localStorage.getItem('sp_user') !== null;
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
const toggleMobileNav = () => setIsMobileNavOpen(!isMobileNavOpen);
@@ -121,20 +122,29 @@ const Header = () => {
</div>
</div>
{/* Right Side - Desktop Only */}
<div className="hidden md:flex items-center space-x-4 relative">
{/* Add Keyword Button */}
{/* Right Side - Desktop Only */}
<div className="hidden md:flex items-center space-x-4 relative">
{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 "
onClick={_ => setShowOverlay(true)}>
<Plus size={18} />
<span>Add Keywords</span>
</button>
<div className="w-0 h-6 outline outline-1 outline-offset-[-0.50px] outline-indigo-200"></div>
</>
) : (
<Link
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>
)}
<div className="w-0 h-6 outline outline-1 outline-offset-[-0.50px] outline-indigo-200"></div>
{/* Profile Dropdown */}
{/* Profile Dropdown */}
<div className="relative">
<button
onClick={toggleMenu}
@@ -190,10 +200,18 @@ const Header = () => {
</div>
</div>
{/* Mobile nav - visible when toggled */}
{isMobileNavOpen && (
<div className="md:hidden px-4 pb-4">
{/* Nav links */}
{/* Mobile nav - visible when toggled */}
{isMobileNavOpen && (
<div className="md:hidden px-4 pb-4">
{!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">
{navLinks.map((link, index) => (
<Link

عرض الملف

@@ -12,6 +12,13 @@ import { setContext } from '@apollo/client/link/context'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'
import { getMainDefinition } from '@apollo/client/utilities'
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
} from 'recoil';
// Initialize Nhost client
const nhost = new NhostClient({
@@ -99,7 +106,10 @@ createRoot(document.getElementById('root')!).render(
<NhostProvider nhost={nhost}>
<StrictMode>
<ApolloProvider client={client}>
<RecoilRoot>
<App />
</RecoilRoot>
</ApolloProvider>
</StrictMode>
</NhostProvider>,

عرض الملف

@@ -1,8 +1,26 @@
import React from 'react'
import React, { useState } from 'react'
import Herosection from "../assets/herosection.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 [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 (
<div className="min-h-screen bg-white overflow-x-hidden">
<div className="relative">
@@ -130,6 +148,8 @@ const HomePage = () => {
type="text"
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]"
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
<button
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%)',
boxShadow: '0 1px 3px 0 rgba(2, 10, 66, 0.38), 0 0 0 1px #4D64FB'
}}
onClick={handleDomainSubmit}
>
Let Us Show You How
</button>
@@ -180,4 +201,4 @@ const HomePage = () => {
)
}
export default HomePage
export default HomePage

عرض الملف

@@ -1,22 +1,233 @@
import React from 'react'
import { keywordSections } from "../assets/keyword-data";
import React, { useState, useEffect } from 'react'
import { KeywordSection } from "../components/KeywordSection";
import ContainerPage from '../components/shared/ContainerPage';
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 [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 (
<ContainerPage>
<HeaderPage title="Keyword Visibility Matrix" buttonShow={true} />
<div className="flex flex-col">
{keywordSections.map((section) => (
<KeywordSection key={section.id} section={section} />
))}
{keywordSections.map((section) => (
<KeywordSection
key={section.id}
section={section}
hasGmb={hasGmb}
llmStatuses={llmStatuses}
loadingKeywords={loadingKeywords}
/>
))}
</div>
</ContainerPage>
)
}
export default Keywords
export default Keywords

عرض الملف

@@ -44,7 +44,7 @@ const Login = () => {
// Redirect or handle successful authentication
console.log('Authentication successful:', result.data);
// You can redirect here or handle the successful login
window.location.href = '/'; // Example redirect
window.location.href = '/dashboard/keywords'; // Example redirect
} else {
// Error is already set in the hook, no need to set it again
console.error('Authentication failed:', result.error);

عرض الملف

@@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
include: ['react', 'react-dom', 'recoil']
},
css: {
postcss: './postcss.config.js',
},