Adding atom
هذا الالتزام موجود في:
27
package-lock.json
مولّد
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
6
src/atoms/siteAtom.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const siteAtom = atom<string>({
|
||||
key: 'siteAtom',
|
||||
default: ''
|
||||
});
|
||||
@@ -7,9 +7,16 @@ 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, hasGmb }: KeywordRowProps) {
|
||||
export function KeywordRow({ keyword, hasGmb, llmStatus, isLoading }: KeywordRowProps) {
|
||||
return (
|
||||
<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`}>
|
||||
|
||||
@@ -25,7 +32,17 @@ export function KeywordRow({ keyword, hasGmb }: 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>
|
||||
|
||||
{hasGmb && (
|
||||
@@ -39,15 +56,45 @@ export function KeywordRow({ keyword, hasGmb }: KeywordRowProps) {
|
||||
</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>
|
||||
|
||||
@@ -7,9 +7,16 @@ 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, hasGmb }: KeywordSectionProps) {
|
||||
export function KeywordSection({ section, hasGmb, llmStatuses, loadingKeywords }: KeywordSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(section.expanded);
|
||||
|
||||
return (
|
||||
@@ -78,15 +85,21 @@ export function KeywordSection({ section, hasGmb }: KeywordSectionProps) {
|
||||
|
||||
<div className="bg-grey-50">
|
||||
{section.keywords.map((keyword) => (
|
||||
<KeywordRow key={keyword.id} keyword={keyword} hasGmb={hasGmb} />
|
||||
<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
|
||||
|
||||
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 { 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>
|
||||
|
||||
@@ -2,6 +2,8 @@ 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;
|
||||
@@ -16,24 +18,44 @@ 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=https://www.rjmedspa.com');
|
||||
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=https://www.rjmedspa.com/');
|
||||
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
|
||||
@@ -102,6 +124,62 @@ const Keywords = () => {
|
||||
|
||||
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);
|
||||
@@ -139,7 +217,13 @@ const Keywords = () => {
|
||||
|
||||
<div className="flex flex-col">
|
||||
{keywordSections.map((section) => (
|
||||
<KeywordSection key={section.id} section={section} hasGmb={hasGmb} />
|
||||
<KeywordSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
hasGmb={hasGmb}
|
||||
llmStatuses={llmStatuses}
|
||||
loadingKeywords={loadingKeywords}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ContainerPage>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم