import React, { useState, useEffect, useMemo, useCallback, createContext, useContext } from 'react'; import { initializeApp } from 'firebase/app'; import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; import { getFirestore, collection, onSnapshot, deleteDoc, doc, writeBatch, query, setDoc, getDoc, getDocs, addDoc, serverTimestamp, orderBy, limit, where } from 'firebase/firestore'; import { Chart as ChartJS, registerables } from 'chart.js'; import { Line, Bar, Pie, Scatter } from 'react-chartjs-2'; import 'chart.js/auto'; // --- Firebase Configuration & Initialization --- const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : { apiKey: "YOUR_API_KEY", authDomain: "YOUR_AUTH_DOMAIN", projectId: "YOUR_PROJECT_ID", storageBucket: "YOUR_STORAGE_BUCKET", messagingSenderId: "YOUR_MESSAGING_SENDER_ID", appId: "YOUR_APP_ID" }; const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); ChartJS.register(...registerables); // --- Authentication Hook --- const useAuth = () => { const [user, setUser] = useState(null); const [isAuthReady, setIsAuthReady] = useState(false); useEffect(() => { const unsubscribe = onAuthStateChanged(auth, async (currentUser) => { if (currentUser) { setUser(currentUser); } else { try { await (__initial_auth_token ? signInWithCustomToken(auth, __initial_auth_token) : signInAnonymously(auth)); } catch (error) { console.error("Authentication Error:", error); } } setIsAuthReady(true); }); return () => unsubscribe(); }, []); return { user, isAuthReady }; }; // --- Firestore Query Hook --- const useFirestoreQuery = (firestoreQuery, deps = []) => { const [data, setData] = useState([]); const [isLoading, setIsLoading] = useState(true); useEffect(() => { if (!firestoreQuery) { setIsLoading(false); setData([]); return; } setIsLoading(true); const unsubscribe = onSnapshot(firestoreQuery, (snapshot) => { const fetchedData = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); setData(fetchedData); setIsLoading(false); }, (error) => { console.error("Firestore Query Error:", error); setIsLoading(false); }); return () => unsubscribe(); // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return { data, isLoading }; }; // --- Article Management Constants & Helpers --- const articleCategories = [ "Market Trends & Analysis", "Buying Property in Singapore", "Financing, Taxes & Regulations", "Transaction Costs & Process", "Sustainability & Lifestyle" ]; const getArticleExcerpt = (content, length = 100) => { if (!content) return ''; // Strip HTML tags for the excerpt const cleanedContent = content.replace(/<[^>]*>?/gm, ''); return cleanedContent.length > length ? `${cleanedContent.substring(0, length)}...` : cleanedContent; }; // --- Data & Utility Helpers --- function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [JSON.stringify(value), delay]); return debouncedValue; } const generateDocumentId = (row) => { const keyString = `${row.project_name || ''}-${row.street_name || ''}-${row.price || ''}-${row.area_sqft || ''}-${row.date_of_sale || ''}`.trim().toLowerCase(); let hash = 0; for (let i = 0; i < keyString.length; i++) { const char = keyString.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash |= 0; } return `tx_${Math.abs(hash)}`; }; const parseDate = (value) => { if (!value) return null; if (value instanceof Date) { return isNaN(value.getTime()) ? null : value; } // Handle Firestore Timestamps if (value && typeof value.toDate === 'function') { return value.toDate(); } let date; if (typeof value === 'number') { date = new Date(Math.round((value - 25569) * 86400 * 1000)); } else if (typeof value === 'string' && value.match(/^\w{3}-\d{2}$/)) { date = new Date(value.replace(/(\w{3})-(\d{2})/, '$1 1, 20$2')); } else { date = new Date(value); } return isNaN(date.getTime()) ? null : date; }; const normalizeData = (row) => { const newRow = {}; const keyMap = { 'Project Name': 'project_name', 'Street Name': 'street_name', 'Property Type': 'type', 'Postal District': 'postal_district', 'Market Segment': 'market_segment', 'Tenure': 'tenure', 'Type of Sale': 'type_of_sale', 'Transacted Price ($)': 'price', 'Nett Price($)': 'nett_price', 'Area (SQM)': 'area_sqm', 'Area (SQFT)': 'area_sqft', 'Type of Area': 'type_of_area', 'Floor Level': 'floor_level', 'Unit Price ($ PSF)': 'unit_price', 'Unit Price ($ PSM)': 'unit_price_psm', 'Sale Date': 'date_of_sale', 'Number of Units': 'number_of_units', }; for (const key in row) { const trimmedKey = key.trim(); const newKey = keyMap[trimmedKey] || trimmedKey.toLowerCase().replace(/ /g, '_').replace('($)', '').replace('($)', ''); newRow[newKey] = row[key]; } if (!newRow.area_sqft && newRow.area_sqm) { const areaSQM = parseFloat(newRow.area_sqm); if (!isNaN(areaSQM)) newRow.area_sqft = Math.round(areaSQM * 10.764); } if (newRow.price) newRow.price = String(newRow.price).replace(/[^0-9.-]+/g, ""); if (newRow.unit_price) newRow.unit_price = String(newRow.unit_price).replace(/[^0-9.-]+/g, ""); return newRow; }; const computeCagrFromTxs = (txs) => { if (txs.length < 2) return null; const monthlyData = {}; txs.forEach(tx => { const date = parseDate(tx.date_of_sale); const price = parseFloat(tx.price); if (date && !isNaN(price)) { const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; if (!monthlyData[monthKey]) monthlyData[monthKey] = { total: 0, count: 0, date }; monthlyData[monthKey].total += price; monthlyData[monthKey].count++; } }); const sortedMonths = Object.keys(monthlyData).sort(); if (sortedMonths.length < 2) return null; const startData = monthlyData[sortedMonths[0]], endData = monthlyData[sortedMonths[sortedMonths.length - 1]]; const startValue = startData.total / startData.count, endValue = endData.total / endData.count; const years = (endData.date.getTime() - startData.date.getTime()) / (1000 * 3600 * 24 * 365.25); if (years <= 0) return null; const cagr = (Math.pow(endValue / startValue, 1 / years)) - 1; return { cagr: parseFloat((cagr * 100).toFixed(2)), startValue, endValue, years }; }; // --- React Components --- const Header = React.memo(({ isAdminView, onToggleView, isAuthReady, userId, currentView, onNavigate }) => { const navLinks = ["Home", "About", "AI-Powered Market Insights", "Property Knowledge Hub", "Guides"]; return (
onNavigate('Home')}>RealtyIQ
{isAuthReady && userId && ( )}
); }); const VisualPlaceholder = React.memo(() => (
)); const HomePage = ({ onNavigate, appId, isAuthReady }) => { const featuredArticlesQuery = useMemo(() => { if (!appId || !isAuthReady) return null; return query(collection(db, 'artifacts', appId, 'public/data/articles'), where("isFeatured", "==", true), limit(2)); }, [appId, isAuthReady]); const { data: blogPosts, isLoading } = useFirestoreQuery(featuredArticlesQuery, [appId, isAuthReady]); const features = [ { title: 'Maximize Returns', description: 'Improve your transactions and potential when buying properties.' }, { title: 'Optimize Your Portfolio', description: 'Leverage strategy-led moments and capitalize on market opportunities.' }, { title: 'Trusted Expertise', description: 'Align with market experts to capitalize on sourced investment potentials.' } ]; return ( <>
{/* Left Column: Text Content */}

Financially-Engineered
Property Specialists

One-stop view of costs, returns & financing options

  • Curated End-to-end scenario financial planning
  • Automated interactive market analytics
  • Personalised property recommendations matched to your ROI targets and goals
{/* Right Column: Illustration */}
NO ABSD
{features.map((feature) => (

{feature.title}

{feature.description}

))}

Insights & Blog

Stay informed with our latest analysis and property guides.

{isLoading &&

Loading featured articles...

} {!isLoading && blogPosts.length > 0 ? blogPosts.map((post) => (
{post.imageUrl ? ( {post.title} { e.target.onerror = null; e.target.style.display='none'; }} /> ) : ( )}

{post.title}

{getArticleExcerpt(post.content)}

)) : !isLoading && (

No featured articles yet.

The administrator can select up to two articles to feature on this page from the Admin View.

)}
); }; const Hero = ({ children }) => { return (

Unlocking Smarter Property Decisions

Analyze market trends, compare properties, and discover insights with real-time data.

{children}
); }; const MarketAnalysis = ({ transactions, filters }) => { const [summary, setSummary] = useState(null); const [status, setStatus] = useState('idle'); const debouncedFilters = useDebounce(filters, 1500); const generateSummary = useCallback(async () => { setStatus('loading'); setSummary(null); try { const projectOptions = [...new Set(transactions.map(tx => tx.project_name).filter(Boolean))].sort(); const allCagrs = projectOptions.map(p => { const r = computeCagrFromTxs(transactions.filter(tx => tx.project_name === p)); return r ? { projectName: p, cagr: r.cagr } : null; }).filter(Boolean); const top5 = allCagrs.sort((a, b) => b.cagr - a.cagr).slice(0, 5); const top5WithStats = top5.map(p => { const projectTxs = transactions.filter(tx => tx.project_name === p.projectName); const totalSales = projectTxs.length; const avgPrice = projectTxs.reduce((sum, tx) => sum + parseFloat(tx.price), 0) / totalSales; const avgPsf = projectTxs.reduce((sum, tx) => sum + parseFloat(tx.unit_price), 0) / totalSales; return { ...p, totalSales, avgPrice: avgPrice.toFixed(0), avgPsf: avgPsf.toFixed(0) }; }); const prompt = ` As a senior real estate analyst reviewing data for the Singapore market, provide a structured analysis in JSON format. The user is looking at data filtered by: ${JSON.stringify(filters)}. The top 5 projects by Compound Annual Growth Rate (CAGR) under the current filter are: ${top5WithStats.length > 0 ? top5WithStats.map(p => `- ${p.projectName}: CAGR ${p.cagr.toFixed(2)}%, Avg Price S$${p.avgPrice}, Avg PSF S$${p.avgPsf}, Sales Volume ${p.totalSales}`).join('\n') : 'Not available.'} Based on this, populate the JSON object according to the provided schema. Be insightful and concise. - overallSummary: Write a summary that starts by directly referencing the key filters applied. Give a high-level takeaway. - topPerformers: Comment on why these projects might be performing well, referencing their stats. - potentialOpportunities: Suggest what an investor might look for next based on these trends. - keyObservations: List other notable points or patterns. `; const schema = { type: "OBJECT", properties: { "overallSummary": { "type": "STRING" }, "topPerformers": { "type": "STRING" }, "potentialOpportunities": { "type": "STRING" }, "keyObservations": { "type": "ARRAY", "items": { "type": "STRING" } } }, required: ["overallSummary", "topPerformers", "potentialOpportunities", "keyObservations"] }; const payload = { contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { responseMimeType: "application/json", responseSchema: schema } }; const apiKey = ""; const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`; const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(`API Error: ${response.status} ${await response.text()}`); const result = await response.json(); if (result.candidates?.[0]?.content?.parts?.[0]?.text) { const parsedJson = JSON.parse(result.candidates[0].content.parts[0].text); setSummary(parsedJson); setStatus('success'); } else { throw new Error("Invalid API response structure"); } } catch (err) { console.error("Error in AI Assistant component:", err); setStatus('error'); } }, [transactions, filters]); useEffect(() => { if (transactions.length > 0) { generateSummary(); } else { setStatus('idle'); setSummary(null); } }, [debouncedFilters, transactions.length, generateSummary]); const renderContent = () => { switch(status) { case 'loading': return
Generating insights...
; case 'success': return summary && (

Overall Summary

{summary.overallSummary}

Top Performers Analysis

{summary.topPerformers}

Potential Opportunities

{summary.potentialOpportunities}

{summary.keyObservations?.length > 0 && (

Key Observations

)}
); case 'error': return (

Oops! We couldn't generate the analysis.

Please try adjusting your filters or try again later.

); default: return (
{transactions.length > 0 ? 'Analysis will appear here once you apply filters.' : 'No data available to analyze. Please upload data in the Admin view.'}
); } }; return (

✨ AI Market Analysis

{renderContent()}
); }; const ProjectSelector = ({ projectOptions, selectedProjects, onSelectionChange }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const dropdownRef = React.useRef(null); const filteredOptions = useMemo(() => projectOptions.filter(opt => opt && String(opt).toLowerCase().includes(searchTerm.toLowerCase()) ), [projectOptions, searchTerm]); useEffect(() => { const handleClickOutside = (e) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleCheckboxChange = (project) => { const newSelection = selectedProjects.includes(project) ? selectedProjects.filter(p => p !== project) : [...selectedProjects, project]; onSelectionChange(newSelection); }; const summaryText = selectedProjects.length > 0 ? `${selectedProjects.length} selected` : 'Select Project(s)'; return (
{isOpen && (
setSearchTerm(e.target.value)} className="w-full p-2 border rounded-md"/>
{filteredOptions.map(option => (
handleCheckboxChange(option)} className="h-4 w-4 rounded border-gray-300 text-orange-600 focus:ring-orange-500"/>
))}
)}
); }; const DataManager = ({ db, appId, userId, scriptsReady }) => { // ... (This component remains unchanged) const [uploadMethod, setUploadMethod] = useState('file'); const [sheetUrl, setSheetUrl] = useState(''); const [selectedFile, setSelectedFile] = useState(null); const [isLoading, setIsLoading] = useState(false); const [feedback, setFeedback] = useState({ message: '', type: '' }); useEffect(() => { const loadUrl = async () => { if (!userId) return; const configDocRef = doc(db, 'artifacts', appId, 'users', userId, 'config', 'dataSource'); const docSnap = await getDoc(configDocRef); if (docSnap.exists()) { const url = docSnap.data().sheetUrl; setSheetUrl(url || ''); if (url) { handleGoogleSheetFetch(url); } } }; loadUrl(); }, [userId, appId, db]); const processAndUploadData = async (jsonData) => { if (!jsonData || jsonData.length === 0) { setFeedback({ message: 'No data found to process. Please check the sheet/file content.', type: 'error' }); return { success: false }; } let totalUploadedCount = 0; const BATCH_SIZE = 499; const PARALLEL_LIMIT = 10; try { const validRecords = jsonData.map(normalizeData).filter(row => ['project_name', 'price', 'area_sqft', 'date_of_sale'].every(field => row[field])); if (validRecords.length === 0) { setFeedback({ message: 'No valid records with required fields found.', type: 'error' }); return { success: false }; } const chunks = []; for (let i = 0; i < validRecords.length; i += BATCH_SIZE) chunks.push(validRecords.slice(i, i + BATCH_SIZE)); setFeedback({ message: `Starting upload of ${validRecords.length} records...`, type: 'info' }); for (let i = 0; i < chunks.length; i += PARALLEL_LIMIT) { const parallelGroup = chunks.slice(i, i + PARALLEL_LIMIT); const promises = parallelGroup.map(chunk => { const batch = writeBatch(db); chunk.forEach(row => batch.set(doc(db, 'artifacts', appId, 'public/data/transactions', generateDocumentId(row)), row)); return batch.commit(); }); await Promise.all(promises); totalUploadedCount += parallelGroup.reduce((acc, chunk) => acc + chunk.length, 0); setFeedback({ message: `Uploading... ${totalUploadedCount} of ${validRecords.length} processed.`, type: 'info' }); } return { success: true, count: totalUploadedCount }; } catch (error) { console.error("Error uploading: ", error); setFeedback({ message: `Upload error: ${error.message}`, type: 'error' }); return { success: false }; } }; const handleGoogleSheetFetch = async (urlToFetch) => { if (!urlToFetch) return setFeedback({ message: 'Please enter a Google Sheet URL.', type: 'error' }); const match = urlToFetch.match(/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); if (!match || !match[1]) return setFeedback({ message: 'Invalid Google Sheet URL.', type: 'error' }); const csvUrl = `https://docs.google.com/spreadsheets/d/${match[1]}/export?format=csv`; setIsLoading(true); setFeedback({ message: `Fetching data...`, type: 'info' }); try { const response = await fetch(csvUrl); if (!response.ok) throw new Error(`Fetch failed. Status: ${response.status}. Ensure sheet is public.`); if (!window.Papa) throw new Error('CSV parsing library not available.'); window.Papa.parse(await response.text(), { header: true, skipEmptyLines: true, complete: async (results) => { const uploadResult = await processAndUploadData(results.data); if (uploadResult.success) setFeedback({ message: `Refreshed from URL: ${uploadResult.count} records loaded.`, type: 'success' }); setIsLoading(false); }, error: (err) => { throw new Error(`Parsing error: ${err.message}`); } }); } catch (error) { setFeedback({ message: `Error: ${error.message}`, type: 'error' }); setIsLoading(false); } }; const handleFileChange = (event) => { const file = event.target.files[0]; if (file) { setSelectedFile(file); setFeedback({ message: `File selected: ${file.name}`, type: 'info' }); } }; const handleFileUpload = async () => { if (!selectedFile || !scriptsReady) return setFeedback({ message: 'Select a file first. Libraries must be ready.', type: 'error' }); setIsLoading(true); setFeedback({ message: `Processing ${selectedFile.name}...`, type: 'info' }); try { let jsonData; if (selectedFile.name.endsWith('.csv')) { if (!window.Papa) throw new Error("CSV parser not loaded."); jsonData = await new Promise((res, rej) => window.Papa.parse(selectedFile, { header: true, skipEmptyLines: true, complete: r => r.errors.length ? rej(new Error(r.errors.map(e => e.message).join(', '))) : res(r.data), error: rej })); } else if (selectedFile.name.endsWith('.xlsx') || selectedFile.name.endsWith('.xls')) { if (!window.XLSX) throw new Error("Excel parser not loaded."); jsonData = await new Promise((res, rej) => { const reader = new FileReader(); reader.onload = e => { try { const workbook = window.XLSX.read(new Uint8Array(e.target.result), { type: 'array', cellDates: true }); const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; const json = window.XLSX.utils.sheet_to_json(worksheet); res(json); } catch (err) { rej(err); } }; reader.onerror = rej; reader.readAsArrayBuffer(selectedFile); }); } else { throw new Error("Unsupported file type. Use .csv or .xlsx."); } const uploadResult = await processAndUploadData(jsonData); if (uploadResult?.success) setFeedback({ message: `Successfully uploaded ${uploadResult.count} records.`, type: 'success' }); } catch (error) { setFeedback({ message: `File processing error: ${error.message}`, type: 'error' }); } finally { setIsLoading(false); setSelectedFile(null); if (document.getElementById('file-upload-input')) document.getElementById('file-upload-input').value = ""; } }; const handleSaveUrl = async () => { if (!userId) return setFeedback({ message: 'User not authenticated.', type: 'error' }); setIsLoading(true); try { await setDoc(doc(db, 'artifacts', appId, 'users', userId, 'config', 'dataSource'), { sheetUrl }); setFeedback({ message: 'URL saved! Reloading data...', type: 'success' }); await handleGoogleSheetFetch(sheetUrl); } catch (error) { setFeedback({ message: `Error saving URL: ${error.message}`, type: 'error' }); } finally { setIsLoading(false); } }; return (

Admin: Live Data Source

{uploadMethod === 'file' ? (

Upload CSV or Excel File

) : (

Google Sheet URL

setSheetUrl(e.target.value)} placeholder="https://docs.google.com/spreadsheets/d/..." disabled={!scriptsReady || isLoading || !userId} className="flex-grow p-2 border rounded-md focus:ring-2 focus:ring-orange-500 disabled:opacity-50"/>
)} {feedback.message &&
{feedback.message}
}
); }; const CagrAIAssistant = ({ cagrData }) => { // ... (This component remains unchanged) const [summary, setSummary] = useState(null); const [status, setStatus] = useState('idle'); const debouncedData = useDebounce(cagrData, 1500); const generateCagrSummary = useCallback(async () => { if (!debouncedData || debouncedData.length === 0) { setStatus('idle'); setSummary(null); return; } setStatus('loading'); setSummary(null); try { const prompt = ` As a real estate investment analyst, provide a comparative analysis of the following projects based on their Compound Annual Growth Rate (CAGR). Data: ${debouncedData.map(d => `- Project: ${d.projectName}, CAGR: ${d.cagr}, Start Value: ${d.startValue}, End Value: ${d.endValue}, Period (Years): ${d.years}`).join('\n')} Based on this, populate the JSON object according to the provided schema. Be insightful and concise. - comparisonSummary: Briefly compare the growth rates. Which project shows stronger performance and why? - keyStrengths: For the top-performing project(s), what are the likely strengths driving their growth? (e.g., consistent high growth, rapid recent appreciation). - potentialRisks: What are potential risks or considerations for the projects, especially for any with negative or volatile CAGR? `; const schema = { type: "OBJECT", properties: { "comparisonSummary": { "type": "STRING" }, "keyStrengths": { "type": "STRING" }, "potentialRisks": { "type": "STRING" }, }, required: ["comparisonSummary", "keyStrengths", "potentialRisks"] }; const payload = { contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { responseMimeType: "application/json", responseSchema: schema } }; const apiKey = ""; const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`; const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { console.error(`API Error: ${response.status}`, await response.text()); setStatus('error'); return; } const result = await response.json(); if (result.candidates && result.candidates[0]?.content?.parts[0]?.text) { const parsedJson = JSON.parse(result.candidates[0].content.parts[0].text); setSummary(parsedJson); setStatus('success'); } else { console.error("Invalid API response structure", result); setStatus('error'); } } catch (err) { console.error("Error generating CAGR summary:", err); setStatus('error'); } }, [debouncedData]); useEffect(() => { generateCagrSummary(); }, [generateCagrSummary]); const renderContent = () => { switch(status) { case 'loading': return
Analyzing CAGR data...
; case 'success': return summary && (

Comparison Summary

{summary.comparisonSummary}

Key Strengths

{summary.keyStrengths}

Potential Risks & Considerations

{summary.potentialRisks}

); case 'error': return
Could not generate CAGR analysis at this time.
; case 'idle': default: return null; } }; return (

AI-Powered CAGR Summary

{renderContent()}
); }; const CAGRCalculator = ({ transactions }) => { // ... (This component remains unchanged) const [selectedProjects, setSelectedProjects] = useState([]); const [topCagrData, setTopCagrData] = useState(null); const [cagrDetailsTableData, setCagrDetailsTableData] = useState([]); const projectOptions = useMemo(() => [...new Set(transactions.map(tx => tx.project_name).filter(Boolean))].sort(), [transactions]); useEffect(() => { const details = selectedProjects.map(projectName => { const result = computeCagrFromTxs(transactions.filter(tx => tx.project_name === projectName)); return result ? { projectName, startValue: result.startValue.toLocaleString('en-US', { style: 'currency', currency: 'USD' }), endValue: result.endValue.toLocaleString('en-US', { style: 'currency', currency: 'USD' }), years: result.years.toFixed(2), cagr: result.cagr } : null; }).filter(Boolean).sort((a, b) => a.projectName.localeCompare(b.projectName)); setCagrDetailsTableData(details); }, [selectedProjects, transactions]); useEffect(() => { if (transactions.length > 0) { const allCagrs = projectOptions.map(p => { const r = computeCagrFromTxs(transactions.filter(tx => tx.project_name === p)); return r ? { projectName: p, cagr: r.cagr } : null; }).filter(Boolean); const top10 = allCagrs.sort((a, b) => b.cagr - a.cagr).slice(0, 10); if (top10.length > 0) setTopCagrData({ labels: top10.map(d => d.projectName), datasets: [{ label: 'CAGR (%)', data: top10.map(d => d.cagr), backgroundColor: 'rgba(249, 115, 22, 0.7)', borderColor: 'rgba(249, 115, 22, 1)', borderWidth: 1 }] }); else setTopCagrData(null); } }, [transactions, projectOptions]); return (

CAGR Analysis

Top 10 Projects by CAGR

{topCagrData ? `${c.label}: ${c.raw.toFixed(2)}%` } } }, scales: { x: { title: { display: true, text: 'CAGR (%)' } } } }}/> :

Calculating...

}

Manual Calculation

{cagrDetailsTableData.length > 0 &&
{cagrDetailsTableData.map((d, index) => { const cagrColorClass = d.cagr < 0 ? 'text-red-600' : 'text-green-700'; return ( ); })}
Project Start Value End Value Years CAGR
{d.projectName} {d.startValue} {d.endValue} {d.years} {d.cagr.toFixed(2)}%
}
); }; const FutureValueProjection = ({ transactions }) => { // ... (This component remains unchanged) const [selectedProjects, setSelectedProjects] = useState([]); const [projectionYears, setProjectionYears] = useState(10); const [initialInvestment, setInitialInvestment] = useState(0); const [chartData, setChartData] = useState({ labels: [], datasets: [] }); const projectOptions = useMemo(() => [...new Set(transactions.map(tx => tx.project_name).filter(Boolean))].sort(), [transactions]); useEffect(() => { if (selectedProjects.length > 0) { const latestPrices = selectedProjects.map(projectName => { const projectTxs = transactions.filter(tx => tx.project_name === projectName); const cagrResult = computeCagrFromTxs(projectTxs); return cagrResult ? cagrResult.endValue : null; }).filter(Boolean); if (latestPrices.length > 0) { const avgLatestPrice = latestPrices.reduce((sum, price) => sum + price, 0) / latestPrices.length; setInitialInvestment(Math.round(avgLatestPrice)); } else { setInitialInvestment(0); } } else { setInitialInvestment(0); } }, [selectedProjects, transactions]); useEffect(() => { const colorPalette = ['#3b82f6', '#10b981', '#ef4444', '#f97316', '#8b5cf6', '#d946ef', '#06b6d4', '#f59e0b', '#64748b', '#22c55e']; if (selectedProjects.length === 0 || projectionYears <= 0 || initialInvestment === 0) { setChartData({ labels: [], datasets: [] }); return; } const labels = Array.from({ length: projectionYears + 1 }, (_, i) => `Year ${i}`); const datasets = selectedProjects.map((projectName, index) => { const projectTxs = transactions.filter(tx => tx.project_name === projectName); const cagrResult = computeCagrFromTxs(projectTxs); if (!cagrResult) return null; const cagr = cagrResult.cagr / 100; const data = []; for (let i = 0; i <= projectionYears; i++) { data.push(initialInvestment * Math.pow(1 + cagr, i)); } return { label: `${projectName} (${cagrResult.cagr.toFixed(2)}% CAGR)`, data: data, borderColor: colorPalette[index % colorPalette.length], backgroundColor: `${colorPalette[index % colorPalette.length]}1A`, fill: false, tension: 0.1, }; }).filter(Boolean); setChartData({ labels, datasets }); }, [selectedProjects, projectionYears, initialInvestment, transactions]); return (

Future Value Projection

Projection is based on an initial value calculated from the average latest transacted price of the selected project(s).

setProjectionYears(Number(e.target.value))} className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-orange-500 focus:border-orange-500"/>
{chartData.datasets.length > 0 ? ( `${c.dataset.label}: $${parseFloat(c.raw).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}` } }, legend: { position: 'top' } }, scales: { y: { ticks: { callback: value => '$' + value.toLocaleString() } } } }} /> ) : (

Select one or more projects to see their future value projection.

)}
); }; const YoYAnalysis = ({ transactions }) => { // ... (This component remains unchanged) const [selectedProjects, setSelectedProjects] = useState([]); const [yoyData, setYoyData] = useState({ chartData: null, tableData: [] }); const projectOptions = useMemo(() => [...new Set(transactions.map(tx => tx.project_name).filter(Boolean))].sort(), [transactions]); useEffect(() => { if (selectedProjects.length === 0) { setYoyData({ chartData: null, tableData: [] }); return; } const colorPalette = ['#3b82f6', '#10b981', '#ef4444', '#f97316', '#8b5cf6', '#d946ef', '#06b6d4', '#f59e0b', '#64748b', '#22c55e']; let allYears = new Set(); const projectYearlyData = {}; selectedProjects.forEach(p => { projectYearlyData[p] = {}; const projectTxs = transactions.filter(tx => tx.project_name === p); projectTxs.forEach(tx => { const date = parseDate(tx.date_of_sale); const unitPrice = parseFloat(tx.unit_price); if (date && !isNaN(unitPrice)) { const year = date.getFullYear(); allYears.add(year); if (!projectYearlyData[p][year]) { projectYearlyData[p][year] = { total: 0, count: 0 }; } projectYearlyData[p][year].total += unitPrice; projectYearlyData[p][year].count++; } }); }); const sortedYears = [...allYears].sort((a, b) => a - b); let finalTableData = []; const datasets = selectedProjects.map((p, index) => { const yearlyAvgs = {}; for (const year in projectYearlyData[p]) { yearlyAvgs[year] = projectYearlyData[p][year].total / projectYearlyData[p][year].count; } const projectSortedYears = Object.keys(yearlyAvgs).sort((a, b) => Number(a) - Number(b)); let projectYoYData = []; for (let i = 1; i < projectSortedYears.length; i++) { const currentY = projectSortedYears[i]; const prevY = projectSortedYears[i-1]; const currentAvg = yearlyAvgs[currentY]; const prevAvg = yearlyAvgs[prevY]; const change = ((currentAvg - prevAvg) / prevAvg) * 100; finalTableData.push({ project: p, year: currentY, avgPrice: currentAvg.toLocaleString('en-US', { style: 'currency', currency: 'USD' }), prevAvgPrice: prevAvg.toLocaleString('en-US', { style: 'currency', currency: 'USD' }), change: change.toFixed(2) + '%' }); projectYoYData.push({ year: Number(currentY), change }); } const dataForChart = sortedYears.map(year => { const yoyEntry = projectYoYData.find(d => d.year === year); return yoyEntry ? yoyEntry.change : null; }); return { label: p, data: dataForChart, borderColor: colorPalette[index % colorPalette.length], backgroundColor: `${colorPalette[index % colorPalette.length]}1A`, fill: false, tension: 0.1, }; }); setYoyData({ chartData: { labels: sortedYears, datasets }, tableData: finalTableData.sort((a,b) => a.project.localeCompare(b.project) || b.year - a.year) }); }, [transactions, selectedProjects]); return (

Year-over-Year (YoY) Price Analysis by Property

{yoyData.chartData?.datasets.length > 0 ? ( ) : (

Select one or more projects to see YoY analysis.

)}
{yoyData.tableData.length > 0 &&

YoY Calculation Details

{yoyData.tableData.map((r, i) => ( ))}
Project Year Avg. Price (PSF) Prev. Year Avg. YoY Change
{r.project} {r.year} {r.avgPrice} {r.prevAvgPrice} {r.change}
}
); }; const ChartCard = ({ title, children }) => (

{title}

{children}
); const CheckboxFilter = ({ label, options, selected, onChange }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const dropdownRef = React.useRef(null); const filteredOptions = useMemo(() => options.filter(opt => opt && String(opt).toLowerCase().includes(searchTerm.toLowerCase())), [options, searchTerm]); useEffect(() => { const handleClickOutside = (e) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target)) setIsOpen(false); }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); return (
{isOpen &&
setSearchTerm(e.target.value)} className="w-full p-2 border rounded-md"/>
{filteredOptions.map(o => (
onChange(selected.includes(o) ? selected.filter(i => i !== o) : [...selected, o])} className="h-4 w-4 rounded border-gray-300 text-orange-600 focus:ring-orange-500"/>
))}
}
); }; const FilterBar = ({ filterOptions, filters, setFilters }) => { const [isOpen, setIsOpen] = useState(true); return (
{isOpen &&
Select filters to refine the data
{Object.keys(filterOptions.dropdowns).map(k => ( setFilters(p => ({...p, [k]:v}))}/>))}
setFilters(p => ({...p, area_sqft: {...p.area_sqft, min: e.target.value}}))} className="w-full p-2 border rounded-md"/> setFilters(p => ({...p, area_sqft: {...p.area_sqft, max: e.target.value}}))} className="w-full p-2 border rounded-md"/>
setFilters(p => ({...p, date_of_sale: {...p.date_of_sale, start: e.target.value}}))} className="w-full p-2 border rounded-md"/> setFilters(p => ({...p, date_of_sale: {...p.date_of_sale, end: e.target.value}}))} className="w-full p-2 border rounded-md"/>
}
); }; const Dashboard = ({ transactions, filters }) => { // ... (This component remains unchanged) const chartOptions = (title) => ({ maintainAspectRatio: false, responsive: true, plugins: { legend: { position: 'top' }, title: { display: false } }, scales: { y: { ticks: { callback: v => '$' + v.toLocaleString() } } } }); const colorPalette = ['#3b82f6', '#10b981', '#ef4444', '#f97316', '#8b5cf6', '#d946ef', '#06b6d4', '#f59e0b', '#64748b', '#22c55e']; const salesVolumeChartOptions = { indexAxis: 'y', maintainAspectRatio: false, responsive: true, plugins: { legend: { display: false }, }, scales: { x: { title: { display: true, text: 'Number of Sales' }, ticks: { // No currency formatting needed for a simple count } }, y: { ticks: { // No currency formatting needed for property names } } } }; const priceTrendByProjectData = () => { const { project_name: selectedProjects } = filters; if (!selectedProjects || selectedProjects.length === 0) return { labels: [], datasets: [] }; const allYears = [...new Set(transactions.map(r => parseDate(r.date_of_sale)?.getFullYear()))].filter(Boolean).sort((a, b) => a - b); const datasets = selectedProjects.map((p, i) => { const txs = transactions.filter(tx => tx.project_name === p); const yearlyData = {}; txs.forEach(r => { const d = parseDate(r.date_of_sale); if (!d || !r.unit_price) return; const y = d.getFullYear(); if (!yearlyData[y]) yearlyData[y] = { total: 0, count: 0 }; const up = parseFloat(r.unit_price); if (!isNaN(up)) { yearlyData[y].total += up; yearlyData[y].count++; }}); return { label: p, data: allYears.map(y => yearlyData[y] ? yearlyData[y].total / yearlyData[y].count : null), borderColor: colorPalette[i % colorPalette.length], backgroundColor: `${colorPalette[i % colorPalette.length]}1A`, fill: false, tension: 0.1 }; }); return { labels: allYears.map(String), datasets }; }; const priceTrendByDistrictData = () => { const { postal_district: selectedDistricts } = filters; if (!selectedDistricts || selectedDistricts.length === 0) return { labels: [], datasets: [] }; const allYears = [...new Set(transactions.map(r => parseDate(r.date_of_sale)?.getFullYear()))].filter(Boolean).sort((a, b) => a - b); const datasets = selectedDistricts.map((d, i) => { const txs = transactions.filter(tx => String(tx.postal_district) === String(d)); const yearlyData = {}; txs.forEach(r => { const date = parseDate(r.date_of_sale); if (!date || !r.unit_price) return; const y = date.getFullYear(); if (!yearlyData[y]) yearlyData[y] = { total: 0, count: 0 }; const up = parseFloat(r.unit_price); if (!isNaN(up)) { yearlyData[y].total += up; yearlyData[y].count++; }}); return { label: `District ${d}`, data: allYears.map(y => yearlyData[y] ? yearlyData[y].total / yearlyData[y].count : null), borderColor: colorPalette[i % colorPalette.length], backgroundColor: `${colorPalette[i % colorPalette.length]}1A`, fill: false, tension: 0.1 }; }); return { labels: allYears.map(String), datasets }; }; const salesVolumeByProjectData = () => { const counts = {}; transactions.forEach(r => { if(r.project_name) counts[r.project_name] = (counts[r.project_name] || 0) + 1; }); const sorted = Object.entries(counts).sort(([, a], [, b]) => b - a).slice(0, 10); return { labels: sorted.map(p => p[0]), datasets: [{ label: 'Sales', data: sorted.map(p => p[1]), backgroundColor: sorted.map((_, i) => colorPalette[i % colorPalette.length]) }] }; }; const typeDistData = () => { const counts = {}; transactions.forEach(r => { if(r.type) counts[r.type] = (counts[r.type] || 0) + 1; }); return { labels: Object.keys(counts), datasets: [{ data: Object.values(counts), backgroundColor: colorPalette }] }; }; const avgPriceVsFloorLevelData = () => { const { project_name: selectedProjects } = filters; if (!selectedProjects || selectedProjects.length === 0) return { labels: [], datasets: [] }; const getSortableFloor = (f) => parseInt(String(f).split('-')[0], 10) || 0; const allFloorLevels = [...new Set(transactions.map(tx => tx.floor_level))].filter(Boolean).sort((a, b) => getSortableFloor(a) - getSortableFloor(b)); const datasets = selectedProjects.map((p, i) => { const floorData = {}; transactions.filter(tx => tx.project_name === p).forEach(tx => { const price = parseFloat(tx.price); if (tx.floor_level && !isNaN(price)) { if (!floorData[tx.floor_level]) floorData[tx.floor_level] = []; floorData[tx.floor_level].push(price); }}); const avgPrices = {}; for (const l in floorData) avgPrices[l] = floorData[l].reduce((a, b) => a + b, 0) / floorData[l].length; return { label: p, data: allFloorLevels.map(l => avgPrices[l] || null), backgroundColor: colorPalette[i % colorPalette.length] }; }); return { labels: allFloorLevels, datasets }; }; const scatterPlotData = () => { const types = [...new Set(transactions.map(d => d.type).filter(Boolean))]; return { datasets: types.map((t, i) => ({ label: t, data: transactions.filter(d => d.type === t && parseFloat(d.area_sqft) > 0 && parseFloat(d.price) > 0).map(d => ({ x: parseFloat(d.area_sqft), y: parseFloat(d.price) })), backgroundColor: colorPalette[i % colorPalette.length], pointRadius: 5 })) }; }; if (transactions.length === 0) return

No Data to Display

Set a data source in Admin view.

; const projectTrendData = priceTrendByProjectData(), districtTrendData = priceTrendByDistrictData(), avgPriceFloorData = avgPriceVsFloorLevelData(); return (
{projectTrendData.datasets.length > 0 ? :

Select projects to see the trend.

}
{districtTrendData.datasets.length > 0 ? :

Select districts to see the trend.

}
{avgPriceFloorData.datasets.length > 0 ? :

Select projects to compare.

}
); }; const TransactionTable = ({ transactions, db, appId }) => { // ... (This component remains unchanged) const [showDeleteModal, setShowDeleteModal] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const handleDeleteAll = async () => { setIsDeleting(true); try { const colRef = collection(db, 'artifacts', appId, 'public/data/transactions'); const snapshot = await getDocs(colRef); const docs = snapshot.docs; for (let i = 0; i < docs.length; i += 499) { const batch = writeBatch(db); docs.slice(i, i + 499).forEach(doc => batch.delete(doc.ref)); await batch.commit(); } } catch (error) { console.error("Error deleting all: ", error); } finally { setIsDeleting(false); setShowDeleteModal(false); } }; if (transactions.length === 0) return null; return (

Admin: Data Management

{transactions.map((tx, index) => ( ))}
Project Date Price Area PSF Type Action
{tx.project_name} { parseDate(tx.date_of_sale)?.toLocaleDateString() || String(tx.date_of_sale) } ${Number(tx.price).toLocaleString()} {tx.area_sqft} ${tx.unit_price} {tx.type}
{showDeleteModal &&

Confirm Deletion

Are you sure? This cannot be undone.

}
); }; const AboutPage = () => { // ... (This component remains unchanged) return (

About

This section is under construction.

); }; const GuidesPage = () => { // ... (This component remains unchanged) return (

Guides

This section is under construction.

); }; // --- Article Viewer and Manager Components --- const BSDCalculator = () => { const [propertyValue, setPropertyValue] = useState(1500000); const bsdCalculation = useMemo(() => { const value = Number(propertyValue) || 0; let bsd = 0; const steps = []; if (value > 0) { let remainingValue = value; // Tier 1 const tier1Value = Math.min(remainingValue, 180000); const tier1Bsd = tier1Value * 0.01; bsd += tier1Bsd; steps.push({ tier: "First $180,000", rate: "1%", value: tier1Value, result: tier1Bsd }); remainingValue -= tier1Value; // Tier 2 if(remainingValue > 0){ const tier2Value = Math.min(remainingValue, 180000); const tier2Bsd = tier2Value * 0.02; bsd += tier2Bsd; steps.push({ tier: "Next $180,000", rate: "2%", value: tier2Value, result: tier2Bsd }); remainingValue -= tier2Value; } // Tier 3 if(remainingValue > 0){ const tier3Value = Math.min(remainingValue, 640000); const tier3Bsd = tier3Value * 0.03; bsd += tier3Bsd; steps.push({ tier: "Next $640,000", rate: "3%", value: tier3Value, result: tier3Bsd }); remainingValue -= tier3Value; } // Tier 4 if(remainingValue > 0){ const tier4Bsd = remainingValue * 0.04; bsd += tier4Bsd; steps.push({ tier: "Remaining Amount", rate: "4%", value: remainingValue, result: tier4Bsd }); } } return { steps, total: bsd }; }, [propertyValue]); return (

Interactive BSD Calculator

setPropertyValue(e.target.value)} className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-orange-500 focus:border-orange-500" placeholder="e.g., 1500000" />
{bsdCalculation.steps.map((step, i) => (
{step.tier} ({step.rate}): ${step.result.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}
))}
Total BSD Payable: ${bsdCalculation.total.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}
) } const ArticleViewer = ({ article, onBack }) => { const contentParts = useMemo(() => { if (!article || !article.content) return []; // Split the content by the calculator shortcode to inject the component return article.content.split(/(\[BSD_CALCULATOR\])/); }, [article]); return (
{contentParts.map((part, index) => { if (part === '[BSD_CALCULATOR]') { return ; } // Directly render the HTML content from Firestore return
; })}

); } const AdminArticleManager = ({ appId, userId, isAuthReady }) => { const [articles, setArticles] = useState([]); const [currentArticle, setCurrentArticle] = useState(null); const [isLoading, setIsLoading] = useState(false); const [feedback, setFeedback] = useState(''); const [articleToDelete, setArticleToDelete] = useState(null); const articlesRef = collection(db, 'artifacts', appId, 'public/data/articles'); const featuredCount = useMemo(() => articles.filter(a => a.isFeatured).length, [articles]); useEffect(() => { if (!isAuthReady) return; // Wait for auth const q = query(articlesRef, orderBy('createdAt', 'desc')); const unsubscribe = onSnapshot(q, (snapshot) => { const fetchedArticles = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); setArticles(fetchedArticles); }, (error) => { console.error("Error fetching articles:", error); }); return () => unsubscribe(); }, [appId, isAuthReady]); const handleToggleFeatured = async (article) => { if (!article.isFeatured && featuredCount >= 2) { setFeedback('You can only feature a maximum of 2 articles. Please un-feature one first.'); setTimeout(() => setFeedback(''), 3000); return; } setIsLoading(true); try { const articleDoc = doc(db, 'artifacts', appId, 'public/data/articles', article.id); await setDoc(articleDoc, { isFeatured: !article.isFeatured }, { merge: true }); } catch (error) { console.error("Error toggling featured status:", error); setFeedback('Error updating article.'); } finally { setIsLoading(false); } }; const handleEdit = (article) => { setCurrentArticle({ ...article }); setFeedback(''); }; const handleNew = () => { setCurrentArticle({ title: '', category: articleCategories[0], content: '

New Article

Start writing your content here using HTML.

', imageUrl: '' }); setFeedback(''); }; const handleSave = async () => { if (!currentArticle.title || !currentArticle.content || !currentArticle.category) { setFeedback('Title, Category, and Content are required.'); return; } setIsLoading(true); setFeedback('Saving...'); try { const dataToSave = { title: currentArticle.title, category: currentArticle.category, content: currentArticle.content, imageUrl: currentArticle.imageUrl || '', updatedAt: serverTimestamp() }; if (currentArticle.id) { const articleDoc = doc(db, 'artifacts', appId, 'public/data/articles', currentArticle.id); await setDoc(articleDoc, dataToSave, { merge: true }); } else { await addDoc(articlesRef, { ...dataToSave, createdAt: serverTimestamp(), isFeatured: false }); } setFeedback('Article saved successfully!'); setTimeout(() => { setCurrentArticle(null); setFeedback(''); }, 1500); } catch (error) { console.error("Error saving article:", error); setFeedback('Error saving article.'); } finally { setIsLoading(false); } }; const handleDelete = async () => { if (!articleToDelete) return; setIsLoading(true); try { await deleteDoc(doc(db, 'artifacts', appId, 'public/data/articles', articleToDelete.id)); setFeedback('Article deleted.'); } catch (error) { console.error("Error deleting article: ", error); setFeedback('Error deleting article.'); } finally { setIsLoading(false); setArticleToDelete(null); } }; if (currentArticle) { return (

{currentArticle.id ? 'Edit Article' : 'Create New Article'}

setCurrentArticle({...currentArticle, title: e.target.value})} className="w-full p-2 border rounded" /> setCurrentArticle({...currentArticle, imageUrl: e.target.value})} className="w-full p-2 border rounded" />

Live Preview

{feedback &&

{feedback}

}
) } return (

Admin: Article Content Manager

{feedback &&

{feedback}

}
{articles.length > 0 ? articles.map(article => (

{article.title}

{article.category} | Updated: {parseDate(article.updatedAt)?.toLocaleString() || 'N/A'}

)) :

No articles found. Click "Create New Article" to get started.

}
{articleToDelete && (

Confirm Deletion

Are you sure you want to delete the article "{articleToDelete.title}"? This action cannot be undone.

)}
) } const PropertyKnowledgeHubPage = ({ initialArticle, onNavigate, isAuthReady, appId }) => { const [selectedArticle, setSelectedArticle] = useState(initialArticle); const [selectedCategory, setSelectedCategory] = useState('All'); const articlesQuery = useMemo(() => { if (!isAuthReady) return null; return query(collection(db, 'artifacts', appId, 'public/data/articles'), orderBy('createdAt', 'desc')); }, [isAuthReady, appId]); const { data: articles, isLoading } = useFirestoreQuery(articlesQuery, [isAuthReady, appId]); useEffect(() => { setSelectedArticle(initialArticle); }, [initialArticle]); const dynamicCategories = useMemo(() => { const categories = [...new Set(articles.map(a => a.category || "Uncategorized"))]; return categories.sort((a, b) => { const aIndex = articleCategories.indexOf(a); const bIndex = articleCategories.indexOf(b); if (aIndex > -1 && bIndex > -1) return aIndex - bIndex; if (aIndex > -1) return -1; if (bIndex > -1) return 1; return a.localeCompare(b); }); }, [articles]); const filteredArticles = useMemo(() => { if (selectedCategory === 'All') return articles; return articles.filter(article => article.category === selectedCategory); }, [articles, selectedCategory]); const handleBack = () => { if(onNavigate) { onNavigate('Property Knowledge Hub', null); } else { setSelectedArticle(null); } }; const handleSelectArticle = (article) => { if(onNavigate) { onNavigate('Property Knowledge Hub', article); } else { setSelectedArticle(article); } } const renderContent = () => { if (isLoading) { return
Loading articles...
; } if (selectedArticle) { return ; } return ( <>
{dynamicCategories.map(category => ( ))}
{filteredArticles.length > 0 ? filteredArticles.map(article => ( )) :

No articles found in this category.

}
); }; return (

Property Knowledge Hub

{renderContent()}
); }; export default function App() { const { user, isAuthReady } = useAuth(); const [transactions, setTransactions] = useState([]); const [filteredTransactions, setFilteredTransactions] = useState([]); const [isAdminView, setIsAdminView] = useState(false); const [isLoading, setIsLoading] = useState(true); const [scriptsReady, setScriptsReady] = useState(false); const [currentView, setCurrentView] = useState('Home'); const [insightsSubView, setInsightsSubView] = useState('Property Market Insights'); const [activeArticle, setActiveArticle] = useState(null); const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-property-dashboard'; const initialFilters = { postal_district: [], project_name: [], floor_level: [], type_of_sale: [], type: [], tenure: [], market_segment: [], area_sqft: { min: '', max: '' }, date_of_sale: { start: '', end: '' } }; const [filters, setFilters] = useState(initialFilters); const [filterOptions, setFilterOptions] = useState({ dropdowns: {} }); useEffect(() => { const scripts = [ { src: 'https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js', id: 'xlsx' }, { src: 'https://cdn.jsdelivr.net/npm/papaparse@5.3.0/papaparse.min.js', id: 'papaparse' }, ]; let loadedCount = 0; scripts.forEach(s => { if (document.getElementById(s.id)) { if (++loadedCount === scripts.length) setScriptsReady(true); return; } const script = document.createElement('script'); script.src = s.src; script.id = s.id; script.async = true; script.onload = () => { if (++loadedCount === scripts.length) setScriptsReady(true); }; document.head.appendChild(script); }); }, []); useEffect(() => { if (!isAuthReady) return; setIsLoading(true); const q = query(collection(db, 'artifacts', appId, 'public/data/transactions')); const unsubDB = onSnapshot(q, (snapshot) => { const data = snapshot.docs.map(d => ({ id: d.id, ...d.data() })).sort((a,b) => (parseDate(b.date_of_sale)?.getTime() || 0) - (parseDate(a.date_of_sale)?.getTime() || 0)); setTransactions(data); const dropdowns = { postal_district: { label: 'Postal District', options: [...new Set(data.map(i => i.postal_district).filter(Boolean))].sort((a,b) => Number(a) - Number(b)) }, project_name: { label: 'Project Name', options: [...new Set(data.map(i => i.project_name).filter(Boolean))].sort() }, floor_level: { label: 'Floor Level', options: [...new Set(data.map(i => i.floor_level).filter(Boolean))].sort((a,b) => (parseInt(String(a).split('-')[0],10)||0) - (parseInt(String(b).split('-')[0],10)||0)) }, type_of_sale: { label: 'Type of Sale', options: [...new Set(data.map(i => i.type_of_sale).filter(Boolean))].sort() }, type: { label: 'Property Type', options: [...new Set(data.map(i => i.type).filter(Boolean))].sort() }, tenure: { label: 'Tenure', options: [...new Set(data.map(i => i.tenure).filter(Boolean))].sort() }, market_segment: { label: 'Market Segment', options: [...new Set(data.map(i => i.market_segment).filter(Boolean))].sort() }, }; setFilterOptions({ dropdowns }); setIsLoading(false); }, (error) => { console.error("Snapshot error: ", error); setIsLoading(false); }); return () => unsubDB(); }, [isAuthReady, appId]); useEffect(() => { setFilteredTransactions(transactions.filter(tx => { for (const key in filterOptions.dropdowns) if (filters[key].length > 0 && !filters[key].includes(String(tx[key]))) return false; const area = parseFloat(tx.area_sqft); if (filters.area_sqft.min !== '' && !isNaN(Number(filters.area_sqft.min)) && area < Number(filters.area_sqft.min)) return false; if (filters.area_sqft.max !== '' && !isNaN(Number(filters.area_sqft.max)) && area > Number(filters.area_sqft.max)) return false; if (filters.date_of_sale.start || filters.date_of_sale.end) { const txDate = parseDate(tx.date_of_sale); if (!txDate) return false; if (filters.date_of_sale.start && txDate < new Date(filters.date_of_sale.start)) return false; if (filters.date_of_sale.end && txDate > new Date(filters.date_of_sale.end)) return false; } return true; })); }, [filters, transactions, filterOptions]); const handleNavigate = (view, article = null) => { setCurrentView(view); setActiveArticle(article); }; const renderMainContent = () => { if (!isAuthReady) { return

Authenticating & Loading...

; } switch (currentView) { case 'Home': return ; case 'AI-Powered Market Insights': return (
{insightsSubView === 'Property Market Insights' && ( <> )} {insightsSubView === 'Investment Insights' && (

Investment Insights

This section is under construction.

)}
); case 'About': return ; case 'Guides': return ; case 'Property Knowledge Hub': return ; default: return ; } }; return (
setIsAdminView(!isAdminView)} isAuthReady={isAuthReady} userId={user?.uid} currentView={currentView} onNavigate={handleNavigate} />
{isAdminView && isAuthReady && currentView !== 'Property Knowledge Hub' && } {isAdminView && currentView === 'Property Knowledge Hub' && } {renderMainContent()} {isAdminView && currentView === 'AI-Powered Market Insights' && }
); }