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
{navLinks.map(link => (
onNavigate(link)}
className={`py-2 px-1 border-b-2 font-medium transition-colors ${currentView === link ? 'text-orange-600 border-orange-500' : 'text-gray-600 border-transparent hover:text-orange-600'}`}
>
{link}
))}
{isAuthReady && userId && (
{isAdminView ? 'Public View' : 'Admin View'}
)}
);
});
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 */}
{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 ? (
{ e.target.onerror = null; e.target.style.display='none'; }} />
) : (
)}
{post.title}
{getArticleExcerpt(post.content)}
onNavigate('Property Knowledge Hub', post)} className="inline-block bg-orange-500 text-white py-2 px-4 rounded-md font-semibold hover:bg-orange-600 transition-colors">
Read More
)) : !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
{summary.keyObservations.map((obs, index) => {obs} )}
)}
);
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 (
setIsOpen(!isOpen)} className="w-full p-2 border border-gray-300 rounded-md bg-white text-left flex justify-between items-center">
{summaryText}
▼
{isOpen && (
)}
);
};
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
setUploadMethod('file')} className={`py-2 px-4 font-semibold ${uploadMethod === 'file' ? 'border-b-2 border-orange-500 text-orange-600' : 'text-gray-500 hover:text-orange-600'}`}>Upload File
setUploadMethod('url')} className={`py-2 px-4 font-semibold ${uploadMethod === 'url' ? 'border-b-2 border-orange-500 text-orange-600' : 'text-gray-500 hover:text-orange-600'}`}>Google Sheet URL
{uploadMethod === 'file' ? (
) : (
)}
{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
Select projects for a detailed breakdown.
{cagrDetailsTableData.length > 0 &&
Project
Start Value
End Value
Years
CAGR
{cagrDetailsTableData.map((d, index) => {
const cagrColorClass = d.cagr < 0 ? 'text-red-600' : 'text-green-700';
return (
{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
{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
Select projects to compare YoY performance.
{yoyData.chartData?.datasets.length > 0 ? (
) : (
Select one or more projects to see YoY analysis.
)}
{yoyData.tableData.length > 0 &&
YoY Calculation Details
Project
Year
Avg. Price (PSF)
Prev. Year Avg.
YoY Change
{yoyData.tableData.map((r, i) => (
{r.project}
{r.year}
{r.avgPrice}
{r.prevAvgPrice}
{r.change}
))}
}
);
};
const ChartCard = ({ 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 (
{label}
setIsOpen(!isOpen)} className="w-full p-2 border border-gray-300 rounded-md bg-white text-left flex justify-between items-center">{selected.length > 0 ? `${selected.length} selected` : 'All'} ▼
{isOpen &&
}
);
};
const FilterBar = ({ filterOptions, filters, setFilters }) => {
const [isOpen, setIsOpen] = useState(true);
return (
setIsOpen(!isOpen)} className="w-full p-4 text-left font-bold text-lg text-gray-700 flex justify-between items-center bg-gray-50 rounded-t-lg hover:bg-gray-100">What are you interested in? ▼
{isOpen &&
Select filters to refine the data
{Object.keys(filterOptions.dropdowns).map(k => (
setFilters(p => ({...p, [k]:v}))}/>))} }
);
};
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
setShowDeleteModal(true)} className="bg-red-600 text-white py-2 px-4 rounded-md font-semibold hover:bg-red-700">Delete All Data
Project
Date
Price
Area
PSF
Type
Action
{transactions.map((tx, index) => (
{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}
deleteDoc(doc(db, 'artifacts', appId, 'public/data/transactions', tx.id))} className="text-red-500 hover:text-red-700 font-semibold">
Delete
))}
{showDeleteModal &&
Confirm Deletion Are you sure? This cannot be undone.
setShowDeleteModal(false)} className="py-2 px-4 bg-gray-200 rounded-md hover:bg-gray-300">Cancel {isDeleting ? 'Deleting...' : 'Delete All'}
}
);
};
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
Property Price (S$)
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 (
Back to All Topics
{contentParts.map((part, index) => {
if (part === '[BSD_CALCULATOR]') {
return
;
}
// Directly render the HTML content from Firestore
return
;
})}
Back to All Topics
);
}
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, category: e.target.value})} className="w-full p-2 border rounded bg-white">
{articleCategories.map(category => (
{category}
))}
setCurrentArticle({...currentArticle, imageUrl: e.target.value})} className="w-full p-2 border rounded" />
{isLoading ? 'Saving...' : 'Save Article'}
setCurrentArticle(null)} className="bg-gray-500 text-white py-2 px-4 rounded-md font-semibold hover:bg-gray-600">Cancel
{feedback &&
{feedback}
}
)
}
return (
Admin: Article Content Manager
Create New Article
{feedback &&
{feedback}
}
{articles.length > 0 ? articles.map(article => (
{article.title}
{article.category} | Updated: {parseDate(article.updatedAt)?.toLocaleString() || 'N/A'}
handleToggleFeatured(article)} disabled={isLoading} className={`text-sm py-1 px-3 rounded-md transition-colors ${article.isFeatured ? 'bg-yellow-400 text-yellow-900 hover:bg-yellow-500' : 'bg-gray-200 hover:bg-gray-300'}`}>{article.isFeatured ? '★ Featured' : '☆ Feature'}
handleEdit(article)} className="text-sm bg-gray-500 text-white py-1 px-3 rounded-md hover:bg-gray-600">Edit
setArticleToDelete(article)} className="text-sm bg-red-500 text-white py-1 px-3 rounded-md hover:bg-red-600">Delete
)) :
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.
setArticleToDelete(null)} disabled={isLoading} className="py-2 px-4 bg-gray-200 rounded-md hover:bg-gray-300">Cancel
{isLoading ? 'Deleting...' : 'Delete'}
)}
)
}
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 (
<>
setSelectedCategory('All')}
className={`py-2 px-5 rounded-full font-semibold text-sm transition-all duration-200 ease-in-out ${selectedCategory === 'All' ? 'bg-orange-600 text-white shadow-md' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
>
All
{dynamicCategories.map(category => (
setSelectedCategory(category)}
className={`py-2 px-5 rounded-full font-semibold text-sm transition-all duration-200 ease-in-out ${selectedCategory === category ? 'bg-orange-600 text-white shadow-md' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
>
{category}
))}
{filteredArticles.length > 0 ? filteredArticles.map(article => (
handleSelectArticle(article)} className="block text-left bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 border border-gray-200">
{article.title}
Published: {parseDate(article.createdAt)?.toLocaleDateString() || 'N/A'}
)) :
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 (
setInsightsSubView('Property Market Insights')} className={`${insightsSubView === 'Property Market Insights' ? 'border-orange-500 text-orange-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}>Property Market Insights
setInsightsSubView('Investment Insights')} className={`${insightsSubView === 'Investment Insights' ? 'border-orange-500 text-orange-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}>Investment Insights
{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' && }
);
}