/**
* ============================================================
* ESTATE PLATFORM — Full Stack Frontend
* A real estate plot management system for Nigerian developers
* ============================================================
*
* ARCHITECTURE OVERVIEW
* ─────────────────────
* 1. CONSTANTS & THEME — Design tokens, colors, fonts
* 2. MOCK DATA & STATE SEED — Initial app state (replace with API calls in production)
* 3. UTILITY FUNCTIONS — Formatters, helpers, date utils
* 4. CONTEXT (AppContext) — Global state: auth, estates, plots, notifications
* 5. HOOKS — useNotifications, usePaymentDue
* 6. SHARED UI COMPONENTS — Button, Badge, Modal, Input, Card, Table, etc.
* 7. AUTH VIEWS — Login screen
* 8. PRIMARY DEVELOPER VIEWS
* 8a. Dashboard — KPIs, revenue summary, recent activity
* 8b. Estate Manager — Create/open estates, upload layout
* 8c. Plot Grid — Visual estate map (the "seat selector")
* 8d. Payment Approvals — Approve/reject payment submissions
* 8e. Developer Accounts — Create, disable, allocate plots to secondary devs
* 8f. Sales Records — Full transaction history
* 8g. Notifications — Payment due alerts, approvals
* 9. SECONDARY DEVELOPER VIEWS
* 9a. My Dashboard — Their KPIs only
* 9b. Estate Map — View-only map + submit payments
* 9c. My Sales — Their own transaction history
* 9d. Submit Payment — Paystack / bank transfer flow
* 10. APP SHELL — Sidebar, TopBar, routing
* 11. ROOT EXPORT —
*
* PRODUCTION NOTES (for the developer inheriting this)
* ─────────────────────────────────────────────────────
* - Replace AppContext mock state with real API calls (e.g. Supabase, Firebase, or custom Node/Express backend)
* - Paystack integration: swap the mock handlePaystackPay() with the real Paystack Popup JS SDK
* - Auth: replace mock login with JWT-based auth (store token in httpOnly cookie, not localStorage)
* - Image uploads: hook up to Cloudinary or S3 for estate layout images
* - Notifications: connect to a real-time service (e.g. Pusher, Supabase Realtime, or WebSockets)
* - This file can be split into separate component files following the section numbers above
*/
import { useState, useContext, createContext, useCallback, useEffect, useRef } from "react";
// ============================================================
// SECTION 1: CONSTANTS & THEME
// ============================================================
const THEME = {
// Core palette — warm earth tones evoke Nigerian soil, gold evokes prosperity
bg: "#F7F4EF",
surface: "#FFFFFF",
surfaceAlt: "#F0ECE5",
border: "#E4DDD3",
borderDark: "#C8BFB0",
// Brand
primary: "#1C3A2B", // Deep forest green
primaryHov: "#254D3A",
gold: "#C8923A", // Warm gold accent
goldLight: "#F5E6D0",
// Status colors
available: { bg: "#EAF5EE", text: "#1A6636", dot: "#2DB05A", border: "#B8DECA" },
reserved: { bg: "#FEF7E6", text: "#7A5200", dot: "#F0A500", border: "#F5D88A" },
part_pay: { bg: "#E8EFFE", text: "#1A3580", dot: "#3D6FE8", border: "#A8BEF5" },
full_pay: { bg: "#FDEAEA", text: "#8A1A1A", dot: "#D93030", border: "#F5AAAA" },
pending: { bg: "#FEF3E6", text: "#7A4200", dot: "#E87830", border: "#F5C8A0" },
// Typography
fontDisplay: "'Playfair Display', Georgia, serif",
fontBody: "'DM Sans', system-ui, sans-serif",
fontMono: "'DM Mono', 'Courier New', monospace",
// Shadows
shadowSm: "0 1px 4px rgba(28,58,43,0.08)",
shadowMd: "0 4px 16px rgba(28,58,43,0.10)",
shadowLg: "0 12px 40px rgba(28,58,43,0.14)",
};
const PLOT_SIZE_CONFIG = {
"180sqm": { label: "180 m²", baseColor: "#E8F0D8", borderColor: "#8AAA58" },
"250sqm": { label: "250 m²", baseColor: "#D8EAF0", borderColor: "#5890AA" },
"500sqm": { label: "500 m²", baseColor: "#EAD8F0", borderColor: "#9058AA" },
"1000sqm": { label: "1,000 m²",baseColor: "#F0E4D8", borderColor: "#AA7058" },
};
const PAYMENT_METHODS = [
{ id: "paystack", label: "Paystack (Card / USSD / Transfer)" },
{ id: "bank_transfer", label: "Direct Bank Transfer" },
];
const PAYMENT_PLAN_TYPES = [
{ id: "outright", label: "Outright Payment" },
{ id: "6months", label: "6-Month Installment" },
{ id: "12months", label: "12-Month Installment" },
{ id: "24months", label: "24-Month Installment" },
{ id: "custom", label: "Custom Plan" },
];
const NAV_PRIMARY = [
{ id: "dashboard", label: "Dashboard", icon: "⬡" },
{ id: "estates", label: "My Estates", icon: "🏘" },
{ id: "approvals", label: "Approvals", icon: "✅", badge: "approvals" },
{ id: "developers", label: "Developers", icon: "👥" },
{ id: "sales", label: "Sales Records", icon: "📊" },
{ id: "notifications",label: "Notifications", icon: "🔔", badge: "notifications" },
];
const NAV_SECONDARY = [
{ id: "sec_dashboard",label: "Dashboard", icon: "⬡" },
{ id: "sec_estate", label: "Estate Map", icon: "🏘" },
{ id: "sec_sales", label: "My Sales", icon: "📊" },
{ id: "sec_notifications", label: "Alerts", icon: "🔔", badge: "notifications" },
];
// ============================================================
// SECTION 2: MOCK DATA & STATE SEED
// ============================================================
// In production: fetch this from your API on login
const MOCK_PRIMARY_DEV = {
id: "primary_001",
role: "primary",
name: "Okafor Holdings Ltd",
email: "admin@okaforholdings.com",
phone: "+234 803 000 0001",
logoInitial: "OH",
};
const MOCK_SECONDARY_DEVS = [
{ id: "sec_001", role: "secondary", name: "Apex Realty", email: "apex@email.com", phone: "+234 803 111 0001", color: "#C8923A", active: true, passwordHash: "pass123", allocatedPlots: [] },
{ id: "sec_002", role: "secondary", name: "Greenfield Homes",email: "green@email.com", phone: "+234 803 111 0002", color: "#2D8A5F", active: true, passwordHash: "pass123", allocatedPlots: [] },
{ id: "sec_003", role: "secondary", name: "Lagos Prestige", email: "prestige@email.com", phone: "+234 803 111 0003", color: "#3D6FE8", active: false, passwordHash: "pass123", allocatedPlots: [] },
{ id: "sec_004", role: "secondary", name: "Sunrise Plots", email: "sunrise@email.com", phone: "+234 803 111 0004", color: "#C83A6F", active: true, passwordHash: "pass123", allocatedPlots: [] },
];
// Generate a realistic 12x14 estate grid
function generateEstateGrid(estateId) {
const plots = [];
let plotNum = 1;
const layout = [
// Each row: array of cell types. "R"=road, "A"=amenity, sizes=plot type
["R","R","R","R","R","R","R","R","R","R","R","R","R","R"],
["R","180sqm","180sqm","180sqm","180sqm","180sqm","R","180sqm","180sqm","180sqm","180sqm","180sqm","180sqm","R"],
["R","180sqm","180sqm","180sqm","180sqm","180sqm","R","180sqm","180sqm","180sqm","180sqm","180sqm","180sqm","R"],
["R","180sqm","180sqm","180sqm","180sqm","180sqm","R","180sqm","180sqm","180sqm","180sqm","180sqm","180sqm","R"],
["R","R","R","R","R","R","R","R","R","R","R","R","R","R"],
["R","250sqm","250sqm","250sqm","250sqm","250sqm","R","500sqm","500sqm","500sqm","500sqm","A","A","R"],
["R","250sqm","250sqm","250sqm","250sqm","250sqm","R","500sqm","500sqm","500sqm","500sqm","A","A","R"],
["R","250sqm","250sqm","250sqm","250sqm","250sqm","R","500sqm","500sqm","500sqm","500sqm","A","A","R"],
["R","R","R","R","R","R","R","R","R","R","R","R","R","R"],
["R","1000sqm","1000sqm","1000sqm","R","250sqm","250sqm","250sqm","250sqm","R","180sqm","180sqm","180sqm","R"],
["R","1000sqm","1000sqm","1000sqm","R","250sqm","250sqm","250sqm","250sqm","R","180sqm","180sqm","180sqm","R"],
["R","1000sqm","1000sqm","1000sqm","R","250sqm","250sqm","250sqm","250sqm","R","180sqm","180sqm","180sqm","R"],
["R","R","R","R","R","R","R","R","R","R","R","R","R","R"],
];
layout.forEach((row, ri) => {
row.forEach((cell, ci) => {
if (cell === "R" || cell === "A") return;
plots.push({
id: `${estateId}_P${String(plotNum).padStart(3,"0")}`,
estateId,
plotNumber: plotNum++,
row: ri,
col: ci,
size: cell,
status: "available", // available | reserved | part_pay | full_pay
assignedDevId: null, // which secondary dev "owns" this plot slot
buyerName: null,
buyerPhone: null,
paymentPlan: null,
planStartDate: null,
planEndDate: null,
totalPrice: cell === "180sqm" ? 8500000
: cell === "250sqm" ? 12000000
: cell === "500sqm" ? 22000000
: 40000000,
amountPaid: 0,
transactions: [], // Array of payment records
});
});
});
return { layout, plots };
}
const { plots: seed_plots } = generateEstateGrid("est_001");
const MOCK_ESTATES = [
{
id: "est_001",
name: "Palm Grove Estate",
location: "Lekki Phase 2, Lagos",
totalHa: 10,
status: "active",
layoutImage: null, // URL to uploaded image in production
createdAt: "2024-01-15",
plots: seed_plots,
},
];
const MOCK_NOTIFICATIONS_SEED = [
{
id: "notif_001",
type: "payment_due",
title: "Payment Plan Expiring Soon",
body: "Buyer Chidi Okeke's 12-month plan on Plot P004 expires in 7 days.",
plotId: "est_001_P004",
read: false,
date: new Date().toISOString(),
for: ["primary_001", "sec_001"],
},
];
const MOCK_PENDING_APPROVALS_SEED = [
{
id: "appr_001",
plotId: "est_001_P007",
estateId: "est_001",
submittedBy: "sec_001",
buyerName: "Emeka Nwachukwu",
buyerPhone: "+234 803 222 0001",
amount: 4250000,
method: "bank_transfer",
paymentPlan: "6months",
planStartDate: "2025-05-01",
planEndDate: "2025-11-01",
proofNote: "Transfer made to GTBank account. Teller ref: GTB2025042200184",
submittedAt: new Date(Date.now() - 3600000).toISOString(),
status: "pending", // pending | approved | rejected
},
];
// ============================================================
// SECTION 3: UTILITY FUNCTIONS
// ============================================================
/** Format number as Nigerian Naira */
const formatNaira = (n) =>
"₦" + Number(n || 0).toLocaleString("en-NG");
/** Format ISO date string to readable form */
const formatDate = (iso) => {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-NG", {
day: "numeric", month: "short", year: "numeric",
});
};
/** Calculate days remaining from today to a date string */
const daysUntil = (dateStr) => {
if (!dateStr) return null;
const diff = new Date(dateStr) - new Date();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
};
/** Truncate long strings with ellipsis */
const truncate = (str, len = 24) =>
str && str.length > len ? str.slice(0, len) + "…" : str;
/** Get status config object from THEME */
const getStatusConfig = (status) => ({
available: THEME.available,
reserved: THEME.reserved,
part_pay: THEME.part_pay,
full_pay: THEME.full_pay,
pending: THEME.pending,
}[status] || THEME.available);
/** Compute payment percentage */
const payPct = (plot) =>
plot.totalPrice > 0
? Math.min(100, Math.round((plot.amountPaid / plot.totalPrice) * 100))
: 0;
/** Get initials from a name */
const initials = (name = "") =>
name.split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
// ============================================================
// SECTION 4: APP CONTEXT (Global State)
// ============================================================
// In production: replace setState calls with API mutations + optimistic updates
const AppContext = createContext(null);
function AppProvider({ children }) {
// ── Auth ──────────────────────────────────────────────────
const [currentUser, setCurrentUser] = useState(null);
// ── Core Data ─────────────────────────────────────────────
const [estates, setEstates] = useState(MOCK_ESTATES);
const [developers, setDevelopers] = useState(MOCK_SECONDARY_DEVS);
const [approvals, setApprovals] = useState(MOCK_PENDING_APPROVALS_SEED);
const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS_SEED);
// ── Navigation ────────────────────────────────────────────
const [activePage, setActivePage] = useState("dashboard");
const [activeEstateId, setActiveEstateId] = useState("est_001");
// ── UI State ──────────────────────────────────────────────
const [selectedPlot, setSelectedPlot] = useState(null);
const [toast, setToast] = useState(null);
// ─── Helpers: show a toast message ──────────────────────
const showToast = useCallback((message, type = "success") => {
setToast({ message, type, id: Date.now() });
setTimeout(() => setToast(null), 3500);
}, []);
// ─── Auth Actions ─────────────────────────────────────────
const login = useCallback((email, password) => {
// Primary developer login
if (email === MOCK_PRIMARY_DEV.email && password === "admin123") {
setCurrentUser(MOCK_PRIMARY_DEV);
setActivePage("dashboard");
return true;
}
// Secondary developer login
const dev = MOCK_SECONDARY_DEVS.find(
d => d.email === email && d.passwordHash === password
);
if (dev) {
if (!dev.active) return "disabled";
setCurrentUser(dev);
setActivePage("sec_dashboard");
return true;
}
return false;
}, []);
const logout = useCallback(() => {
setCurrentUser(null);
setActivePage("dashboard");
setSelectedPlot(null);
}, []);
// ─── Estate Actions ───────────────────────────────────────
const createEstate = useCallback((estateData) => {
const id = `est_${Date.now()}`;
const { plots } = generateEstateGrid(id);
const newEstate = {
id,
...estateData,
status: "active",
createdAt: new Date().toISOString().split("T")[0],
plots,
};
setEstates(prev => [...prev, newEstate]);
showToast(`Estate "${estateData.name}" created successfully!`);
return id;
}, [showToast]);
const updatePlotPrice = useCallback((estateId, size, newPrice) => {
setEstates(prev => prev.map(e => {
if (e.id !== estateId) return e;
return {
...e,
plots: e.plots.map(p =>
p.size === size ? { ...p, totalPrice: newPrice } : p
),
};
}));
showToast("Plot prices updated.");
}, [showToast]);
// ─── Plot Actions ─────────────────────────────────────────
/** Primary dev directly updates a plot (e.g. after approving payment) */
const updatePlot = useCallback((estateId, plotId, changes) => {
setEstates(prev => prev.map(e => {
if (e.id !== estateId) return e;
return {
...e,
plots: e.plots.map(p =>
p.id === plotId ? { ...p, ...changes } : p
),
};
}));
}, []);
// ─── Approval Actions ─────────────────────────────────────
/** Secondary dev submits a payment for approval */
const submitPaymentForApproval = useCallback((submission) => {
const newApproval = {
id: `appr_${Date.now()}`,
submittedAt: new Date().toISOString(),
status: "pending",
...submission,
};
setApprovals(prev => [...prev, newApproval]);
// Also mark plot as "pending" visually
updatePlot(submission.estateId, submission.plotId, { status: "reserved" });
showToast("Payment submitted for approval.");
}, [updatePlot, showToast]);
/** Primary dev approves a pending payment */
const approvePayment = useCallback((approvalId) => {
const appr = approvals.find(a => a.id === approvalId);
if (!appr) return;
// Find current plot
const estate = estates.find(e => e.id === appr.estateId);
const plot = estate?.plots.find(p => p.id === appr.plotId);
if (!plot) return;
const newAmountPaid = (plot.amountPaid || 0) + appr.amount;
const newStatus = newAmountPaid >= plot.totalPrice ? "full_pay" : "part_pay";
const transaction = {
id: `txn_${Date.now()}`,
approvalId: appr.id,
amount: appr.amount,
method: appr.method,
devId: appr.submittedBy,
date: new Date().toISOString(),
note: appr.proofNote,
};
updatePlot(appr.estateId, appr.plotId, {
status: newStatus,
buyerName: appr.buyerName,
buyerPhone: appr.buyerPhone,
paymentPlan: appr.paymentPlan,
planStartDate: appr.planStartDate,
planEndDate: appr.planEndDate,
assignedDevId: appr.submittedBy,
amountPaid: newAmountPaid,
transactions: [...(plot.transactions || []), transaction],
});
setApprovals(prev =>
prev.map(a => a.id === approvalId ? { ...a, status: "approved" } : a)
);
showToast("Payment approved ✓");
}, [approvals, estates, updatePlot, showToast]);
/** Primary dev rejects a pending payment */
const rejectPayment = useCallback((approvalId, reason) => {
setApprovals(prev =>
prev.map(a => a.id === approvalId ? { ...a, status: "rejected", rejectReason: reason } : a)
);
const appr = approvals.find(a => a.id === approvalId);
if (appr) updatePlot(appr.estateId, appr.plotId, { status: "available" });
showToast("Payment rejected.", "error");
}, [approvals, updatePlot, showToast]);
// ─── Developer Account Actions ────────────────────────────
/** Primary dev creates a new secondary developer account */
const createDeveloper = useCallback((devData) => {
const newDev = {
id: `sec_${Date.now()}`,
role: "secondary",
active: true,
passwordHash: devData.password, // In production: hash this server-side
allocatedPlots: devData.allocatedPlots || [],
color: devData.color || "#888888",
...devData,
};
setDevelopers(prev => [...prev, newDev]);
showToast(`Account created for ${devData.name}`);
}, [showToast]);
/** Primary dev toggles a secondary dev's active status */
const toggleDeveloperStatus = useCallback((devId) => {
setDevelopers(prev =>
prev.map(d => d.id === devId ? { ...d, active: !d.active } : d)
);
}, []);
/** Primary dev allocates specific plots to a developer */
const allocatePlots = useCallback((devId, plotIds) => {
setDevelopers(prev =>
prev.map(d => d.id === devId ? { ...d, allocatedPlots: plotIds } : d)
);
showToast("Plots allocated successfully.");
}, [showToast]);
// ─── Notification Actions ─────────────────────────────────
const markNotifRead = useCallback((notifId) => {
setNotifications(prev =>
prev.map(n => n.id === notifId ? { ...n, read: true } : n)
);
}, []);
// ─── Derived Values ───────────────────────────────────────
const activeEstate = estates.find(e => e.id === activeEstateId) || estates[0];
const pendingCount = approvals.filter(a => a.status === "pending").length;
const unreadNotifCount = notifications.filter(n =>
!n.read && (n.for?.includes(currentUser?.id))
).length;
// ─── Context Value ────────────────────────────────────────
const value = {
// State
currentUser, estates, developers, approvals, notifications,
activePage, activeEstate, activeEstateId, selectedPlot, toast,
pendingCount, unreadNotifCount,
// Setters / Actions
setActivePage, setActiveEstateId, setSelectedPlot,
login, logout,
createEstate, updatePlotPrice,
updatePlot, submitPaymentForApproval, approvePayment, rejectPayment,
createDeveloper, toggleDeveloperStatus, allocatePlots,
markNotifRead, showToast,
};
return {children};
}
/** Hook for easy context access */
const useApp = () => useContext(AppContext);
// ============================================================
// SECTION 5: HOOKS
// ============================================================
/** Check for plans expiring within 14 days and surface notifications */
function usePaymentDueChecker() {
const { estates, notifications, setNotifications, currentUser } = useApp();
useEffect(() => {
if (!currentUser) return;
estates.forEach(estate => {
estate.plots.forEach(plot => {
if (!plot.planEndDate || plot.status === "full_pay") return;
const days = daysUntil(plot.planEndDate);
if (days !== null && days <= 14 && days >= 0) {
const existsAlready = notifications.some(n =>
n.plotId === plot.id && n.type === "payment_due"
);
if (!existsAlready) {
setNotifications(prev => [...prev, {
id: `notif_auto_${plot.id}`,
type: "payment_due",
title: "Payment Plan Expiring",
body: `${plot.buyerName || "A buyer"}'s plan on Plot ${plot.id} expires in ${days} day(s).`,
plotId: plot.id,
read: false,
date: new Date().toISOString(),
for: ["primary_001", plot.assignedDevId].filter(Boolean),
}]);
}
}
});
});
}, [estates, currentUser]);
}
// ============================================================
// SECTION 6: SHARED UI COMPONENTS
// ============================================================
// ── Font Injector ─────────────────────────────────────────────
function FontLoader() {
useEffect(() => {
if (document.getElementById("estate-fonts")) return;
const link = document.createElement("link");
link.id = "estate-fonts";
link.rel = "stylesheet";
link.href = "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap";
document.head.appendChild(link);
}, []);
return null;
}
// ── Toast Notification ────────────────────────────────────────
function Toast() {
const { toast } = useApp();
if (!toast) return null;
return (
{toast.type === "error" ? "✕" : "✓"}
{toast.message}
);
}
// ── Button ────────────────────────────────────────────────────
function Btn({ children, onClick, variant = "primary", size = "md", disabled, style: extraStyle }) {
const variants = {
primary: { bg: THEME.primary, color: "#fff", border: "none" },
gold: { bg: THEME.gold, color: "#fff", border: "none" },
outline: { bg: "transparent", color: THEME.primary, border: `1.5px solid ${THEME.primary}` },
ghost: { bg: "transparent", color: THEME.primary, border: "none" },
danger: { bg: "#8A1A1A", color: "#fff", border: "none" },
};
const sizes = {
sm: { padding: "6px 12px", fontSize: 12 },
md: { padding: "9px 18px", fontSize: 13 },
lg: { padding: "12px 24px", fontSize: 14 },
};
const v = variants[variant];
const s = sizes[size];
return (
);
}
// ── Text Input ────────────────────────────────────────────────
function Input({ label, value, onChange, type = "text", placeholder, required, style: extraStyle }) {
return (
{label && (
)}
e.target.style.borderColor = THEME.gold}
onBlur={e => e.target.style.borderColor = THEME.border}
/>
);
}
// ── Select ────────────────────────────────────────────────────
function Select({ label, value, onChange, options, required }) {
return (
{label && (
)}
);
}
// ── Modal ─────────────────────────────────────────────────────
function Modal({ title, onClose, children, width = 520 }) {
return (
{/* Modal Header */}
{title}
{/* Modal Body */}
{children}
);
}
// ── Card ──────────────────────────────────────────────────────
function Card({ children, style: extraStyle }) {
return (
{children}
);
}
// ── KPI Stat Card ─────────────────────────────────────────────
function StatCard({ label, value, sub, color }) {
return (
{value}
{label}
{sub && (
{sub}
)}
);
}
// ── Status Badge ──────────────────────────────────────────────
function StatusBadge({ status, label }) {
const cfg = getStatusConfig(status);
const labels = {
available: "Available", reserved: "Reserved",
part_pay: "Part Payment", full_pay: "Fully Sold", pending: "Pending",
};
return (
{label || labels[status] || status}
);
}
// ── Developer Avatar Badge ─────────────────────────────────────
function DevAvatar({ dev, size = 28 }) {
if (!dev) return null;
return (
{initials(dev.name)}
);
}
// ── Payment Progress Bar ──────────────────────────────────────
function PayBar({ plot }) {
const pct = payPct(plot);
const color = pct === 100 ? THEME.full_pay.dot : pct > 0 ? THEME.part_pay.dot : THEME.available.dot;
return (
{formatNaira(plot.amountPaid)}
{pct}%
);
}
// ── Section Header ────────────────────────────────────────────
function SectionHeader({ title, subtitle, action }) {
return (
{title}
{subtitle && (
{subtitle}
)}
{action}
);
}
// ── Empty State ───────────────────────────────────────────────
function EmptyState({ icon = "📭", message }) {
return (
);
}
// ============================================================
// SECTION 7: AUTH — LOGIN SCREEN
// ============================================================
function LoginScreen() {
const { login } = useApp();
const [email, setEmail] = useState("");
const [password,setPassword]= useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
if (!email || !password) { setError("Please fill in all fields."); return; }
setLoading(true);
setTimeout(() => {
const result = login(email, password);
setLoading(false);
if (result === "disabled") setError("This account has been disabled. Contact the primary developer.");
else if (result === false) setError("Invalid email or password.");
}, 600);
};
return (
{/* Left — Branding Panel */}
Estate Platform
Okafor
Holdings Ltd
Manage your estates, track plot sales, and coordinate your
developer network — all in one place.
{/* Feature bullets */}
{[
"Visual estate map with real-time plot status",
"Secondary developer account management",
"Payment approval workflow",
"Automatic payment plan reminders",
].map(f => (
✓
{f}
))}
{/* Right — Login Form */}
);
}
// ============================================================
// SECTION 8a: PRIMARY DEV — DASHBOARD
// ============================================================
function PrimaryDashboard() {
const { estates, developers, approvals, pendingCount } = useApp();
// Aggregate across all estates
const allPlots = estates.flatMap(e => e.plots);
const totalPlots = allPlots.length;
const soldFull = allPlots.filter(p => p.status === "full_pay").length;
const partPaid = allPlots.filter(p => p.status === "part_pay").length;
const available = allPlots.filter(p => p.status === "available").length;
const totalRevenue = allPlots.reduce((s, p) => s + p.amountPaid, 0);
const totalValue = allPlots.reduce((s, p) => s + p.totalPrice, 0);
const activeDev = developers.filter(d => d.active).length;
// Recent transactions across all plots
const recentTxns = allPlots
.flatMap(p => (p.transactions || []).map(t => ({ ...t, plotId: p.id, plotSize: p.size })))
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 6);
return (
{/* KPI Row */}
{/* Revenue Card */}
{formatNaira(totalRevenue)}
Total revenue collected of {formatNaira(totalValue)} total value
{totalValue > 0 ? Math.round((totalRevenue / totalValue) * 100) : 0}%
{/* Revenue bar */}
0 ? `${(totalRevenue / totalValue) * 100}%` : "0%",
transition: "width 0.6s ease",
}} />
{/* Recent Transactions */}
Recent Transactions
{recentTxns.length === 0
?
: recentTxns.map(txn => (
{formatNaira(txn.amount)}
{txn.plotId} · {formatDate(txn.date)}
))}
{/* Developer Performance */}
Developer Performance
{developers.map(dev => {
const devPlots = allPlots.filter(p => p.assignedDevId === dev.id);
const devRevenue = devPlots.reduce((s, p) => s + p.amountPaid, 0);
return (
{dev.name}
{devPlots.length} plots
{formatNaira(devRevenue)}
{!dev.active && (
)}
);
})}
);
}
// ============================================================
// SECTION 8b: PRIMARY DEV — ESTATE MANAGER
// ============================================================
function EstateManager() {
const { estates, createEstate, setActivePage, setActiveEstateId, updatePlotPrice } = useApp();
const [showCreate, setShowCreate] = useState(false);
const [showPrices, setShowPrices] = useState(false);
const [editEstate, setEditEstate] = useState(null);
// New estate form state
const [form, setForm] = useState({
name: "", location: "", totalHa: "", layoutImage: null,
});
// Price editor state
const [prices, setPrices] = useState({
"180sqm": 8500000, "250sqm": 12000000,
"500sqm": 22000000, "1000sqm": 40000000,
});
const handleCreateEstate = () => {
if (!form.name || !form.location) return;
const id = createEstate({ ...form, totalHa: Number(form.totalHa) });
setShowCreate(false);
setForm({ name: "", location: "", totalHa: "", layoutImage: null });
};
const handleLayoutUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
setForm(f => ({ ...f, layoutImage: url }));
};
const handleSavePrices = () => {
Object.entries(prices).forEach(([size, price]) => {
updatePlotPrice(editEstate.id, size, Number(price));
});
setShowPrices(false);
};
return (
setShowCreate(true)} variant="gold">
+ Open New Estate
}
/>
{/* Estate Cards */}
{estates.map(estate => {
const plots = estate.plots;
const sold = plots.filter(p => p.status === "full_pay").length;
const available = plots.filter(p => p.status === "available").length;
const revenue = plots.reduce((s, p) => s + p.amountPaid, 0);
return (
{/* Gold top bar */}
{/* Layout Image Preview */}
{estate.layoutImage && (
)}
{!estate.layoutImage && (
🗺 No layout uploaded
)}
{estate.name}
{estate.location}
{[
{ l: "Total Plots", v: plots.length },
{ l: "Sold", v: sold },
{ l: "Available", v: available },
].map(s => (
))}
Revenue: {formatNaira(revenue)}
{ setActiveEstateId(estate.id); setActivePage("plot_grid"); }}>
View Map
{ setEditEstate(estate); setShowPrices(true); }}>
Edit Prices
);
})}
{/* ── Modal: Create Estate ── */}
{showCreate && (
setShowCreate(false)}>
)}
{/* ── Modal: Edit Plot Prices ── */}
{showPrices && editEstate && (
setShowPrices(false)}>
Changing prices here updates all available plots of that size.
Plots with active payments are not affected.
{Object.entries(PLOT_SIZE_CONFIG).map(([size, cfg]) => (
{cfg.label}
setPrices(p => ({ ...p, [size]: e.target.value }))}
placeholder="Price in ₦"
style={{ flex: 1 }}
/>
{formatNaira(prices[size])}
))}
setShowPrices(false)}>Cancel
Save Prices
)}
);
}
// ============================================================
// SECTION 8c: PLOT GRID — Visual Estate Map
// Used by both primary and secondary devs
// ============================================================
function PlotGrid({ readOnly = false }) {
const { activeEstate, developers, currentUser, selectedPlot, setSelectedPlot, submitPaymentForApproval } = useApp();
const [showPayModal, setShowPayModal] = useState(false);
if (!activeEstate) return ;
const plots = activeEstate.plots;
const layout = activeEstate.plots; // we re-derive the grid from stored row/col
// For secondary devs: only show allocated plots if allocation is set
const myDev = developers.find(d => d.id === currentUser?.id);
const myAllocated = myDev?.allocatedPlots || [];
const hasAllocation = myAllocated.length > 0;
// Row range for grid
const maxRow = Math.max(...plots.map(p => p.row));
const maxCol = Math.max(...plots.map(p => p.col));
// Build lookup: [row][col] → plot
const plotMap = {};
plots.forEach(p => {
if (!plotMap[p.row]) plotMap[p.row] = {};
plotMap[p.row][p.col] = p;
});
// Road/amenity rows and cols (hardcoded to match layout generation)
const ROAD_ROWS = new Set([0, 4, 8, 12]);
const isRoadCol = (ri, ci) => {
if (ci === 0 || ci === maxCol) return true;
if (ri >= 1 && ri <= 3 && ci === 6) return true;
if (ri >= 5 && ri <= 7 && ci === 6) return true;
if (ri >= 9 && ri <= 11 && (ci === 4 || ci === 9)) return true;
return false;
};
const isAmenity = (ri, ci) => ri >= 5 && ri <= 7 && ci >= 11 && ci <= 12;
return (
{/* Legend */}
{["available","reserved","part_pay","full_pay"].map(s => {
const cfg = getStatusConfig(s);
const lbl = { available:"Available", reserved:"Reserved", part_pay:"Part Payment", full_pay:"Fully Sold" }[s];
return (
{lbl}
);
})}
Click a plot to {readOnly ? "view details" : "submit payment"}
{/* Scrollable Grid */}
{Array.from({ length: maxRow + 1 }).map((_, ri) => (
{Array.from({ length: maxCol + 1 }).map((_, ci) => {
// Road cell
if (ROAD_ROWS.has(ri) || isRoadCol(ri, ci)) {
return (
);
}
// Amenity
if (isAmenity(ri, ci)) {
return (
🌳
);
}
// Plot cell
const plot = plotMap[ri]?.[ci];
if (!plot) return
;
const isSelected = selectedPlot?.id === plot.id;
const isDimmed = hasAllocation && !myAllocated.includes(plot.id);
const dev = developers.find(d => d.id === plot.assignedDevId);
const cfg = getStatusConfig(plot.status);
const sizeW = plot.size === "1000sqm" ? 70 : plot.size === "500sqm" ? 56 : plot.size === "250sqm" ? 50 : 42;
const sizeH = plot.size === "1000sqm" ? 62 : plot.size === "500sqm" ? 50 : plot.size === "250sqm" ? 44 : 36;
return (
{
setSelectedPlot(plot);
if (!readOnly && currentUser?.role === "secondary") setShowPayModal(true);
}}
title={`${plot.id} · ${PLOT_SIZE_CONFIG[plot.size]?.label} · ${formatNaira(plot.totalPrice)}`}
style={{
width: sizeW, height: sizeH, flexShrink: 0,
background: isSelected ? THEME.gold : cfg.bg,
border: `1.5px solid ${isSelected ? THEME.gold : cfg.border}`,
borderRadius: 3, cursor: "pointer",
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", gap: 2,
opacity: isDimmed ? 0.35 : 1,
boxShadow: isSelected ? `0 0 0 3px ${THEME.gold}50, ${THEME.shadowSm}` : THEME.shadowSm,
transform: isSelected ? "scale(1.06)" : "scale(1)",
transition: "all 0.15s ease", zIndex: isSelected ? 5 : 1, position: "relative",
}}
>
{plot.plotNumber}
{dev && }
{plot.status !== "available" && (
)}
);
})}
))}
{/* ── Detail Side Panel ── */}
{selectedPlot && (
{ setSelectedPlot(null); setShowPayModal(false); }}
onSubmitPayment={() => setShowPayModal(true)}
/>
)}
{/* ── Submit Payment Modal (secondary dev) ── */}
{showPayModal && selectedPlot && currentUser?.role === "secondary" && (
setShowPayModal(false)}
onSubmit={(data) => {
submitPaymentForApproval({
plotId: selectedPlot.id,
estateId: selectedPlot.estateId,
submittedBy: currentUser.id,
...data,
});
setShowPayModal(false);
setSelectedPlot(null);
}}
/>
)}
);
}
// ── Plot Detail Side Panel ────────────────────────────────────
function PlotDetailPanel({ plot, onClose, readOnly, onSubmitPayment }) {
const { developers, currentUser } = useApp();
const dev = developers.find(d => d.id === plot.assignedDevId);
const pct = payPct(plot);
return (
{/* Header */}
Plot {plot.plotNumber}
{PLOT_SIZE_CONFIG[plot.size]?.label} · {plot.id}
{/* Body */}
{/* Price & Payment */}
Total Price
{formatNaira(plot.totalPrice)}
Balance
{formatNaira(plot.totalPrice - plot.amountPaid)}
{/* Buyer Info */}
{plot.buyerName && (
Buyer
{plot.buyerName}
{plot.buyerPhone &&
{plot.buyerPhone}
}
)}
{/* Developer */}
{dev && (
Assigned Developer
{dev.name}
)}
{/* Payment Plan */}
{plot.paymentPlan && (
Payment Plan
{PAYMENT_PLAN_TYPES.find(p => p.id === plot.paymentPlan)?.label || plot.paymentPlan}
{plot.planEndDate && (
Ends: {formatDate(plot.planEndDate)}
{daysUntil(plot.planEndDate) !== null && daysUntil(plot.planEndDate) >= 0 && (
({daysUntil(plot.planEndDate)} days left)
)}
)}
)}
{/* Transaction History */}
{plot.transactions?.length > 0 && (
Payment History
{plot.transactions.map(txn => (
{formatNaira(txn.amount)}
{formatDate(txn.date)} · {txn.method}
))}
)}
{/* Footer CTA — secondary dev only */}
{!readOnly && currentUser?.role === "secondary" && plot.status !== "full_pay" && (
Submit Payment
)}
);
}
// ============================================================
// SECTION 9d: SUBMIT PAYMENT MODAL
// Used by secondary devs to submit payment for approval
// ============================================================
function SubmitPaymentModal({ plot, onClose, onSubmit }) {
const [form, setForm] = useState({
buyerName: "",
buyerPhone: "",
amount: "",
method: "bank_transfer",
paymentPlan: "outright",
planStartDate: new Date().toISOString().split("T")[0],
planEndDate: "",
proofNote: "",
});
const balance = plot.totalPrice - plot.amountPaid;
const handlePay = () => {
if (!form.buyerName || !form.amount) return;
if (form.method === "paystack") {
// In production: initialize Paystack popup here, then call onSubmit in the callback
alert("Paystack integration: In production, the Paystack popup opens here. Proceeding as submitted.");
}
onSubmit({
...form,
amount: Number(form.amount),
});
};
return (
);
}
// ============================================================
// SECTION 8d: PRIMARY DEV — PAYMENT APPROVALS
// ============================================================
function PaymentApprovals() {
const { approvals, estates, developers, approvePayment, rejectPayment } = useApp();
const [rejectId, setRejectId] = useState(null);
const [rejectNote, setRejectNote] = useState("");
const [filterStatus, setFilterStatus] = useState("pending");
const filtered = approvals.filter(a => filterStatus === "all" || a.status === filterStatus);
const getPlot = (estateId, plotId) =>
estates.find(e => e.id === estateId)?.plots.find(p => p.id === plotId);
return (
{/* Filter Tabs */}
{["pending","approved","rejected","all"].map(f => (
))}
{filtered.length === 0 &&
}
{filtered.map(appr => {
const plot = getPlot(appr.estateId, appr.plotId);
const dev = developers.find(d => d.id === appr.submittedBy);
return (
{/* Top row */}
{formatNaira(appr.amount)}
{/* Details grid */}
Plot
#{plot?.plotNumber || "?"} — {PLOT_SIZE_CONFIG[plot?.size]?.label || ""}
Buyer
{appr.buyerName}
{appr.buyerPhone}
Developer
{dev && }
{dev?.name || "—"}
Method
{PAYMENT_METHODS.find(m => m.id === appr.method)?.label || appr.method}
Payment Plan
{PAYMENT_PLAN_TYPES.find(p => p.id === appr.paymentPlan)?.label || appr.paymentPlan}
Submitted
{formatDate(appr.submittedAt)}
{/* Proof note */}
{appr.proofNote && (
"{appr.proofNote}"
)}
{/* Reject reason */}
{appr.rejectReason && (
Rejected: {appr.rejectReason}
)}
{/* Action Buttons */}
{appr.status === "pending" && (
approvePayment(appr.id)}>
✓ Approve
setRejectId(appr.id)}>
✕ Reject
)}
);
})}
{/* ── Reject Modal ── */}
{rejectId && (
setRejectId(null)} width={400}>
Please provide a reason. The developer will be notified.
setRejectNote(e.target.value)}
placeholder="e.g. Transfer proof not matching, wrong amount..." />
setRejectId(null)}>Cancel
{
rejectPayment(rejectId, rejectNote);
setRejectId(null);
setRejectNote("");
}}>Confirm Rejection
)}
);
}
// ============================================================
// SECTION 8e: PRIMARY DEV — DEVELOPER ACCOUNTS
// ============================================================
function DeveloperAccounts() {
const { developers, estates, createDeveloper, toggleDeveloperStatus, allocatePlots, activeEstate } = useApp();
const [showCreate, setShowCreate] = useState(false);
const [allocDev, setAllocDev] = useState(null);
const [selectedIds, setSelectedIds] = useState([]);
// New developer form
const [form, setForm] = useState({
name: "", email: "", phone: "", password: "",
color: "#C8923A",
});
const allPlots = activeEstate?.plots.filter(p => p.status === "available") || [];
const handleCreate = () => {
if (!form.name || !form.email || !form.password) return;
createDeveloper(form);
setShowCreate(false);
setForm({ name: "", email: "", phone: "", password: "", color: "#C8923A" });
};
const handleAllocate = () => {
allocatePlots(allocDev.id, selectedIds);
setAllocDev(null);
setSelectedIds([]);
};
const openAllocate = (dev) => {
setAllocDev(dev);
setSelectedIds(dev.allocatedPlots || []);
};
const togglePlotSelect = (plotId) => {
setSelectedIds(prev =>
prev.includes(plotId) ? prev.filter(i => i !== plotId) : [...prev, plotId]
);
};
return (
setShowCreate(true)}>+ Add Developer}
/>
{developers.map(dev => {
const devPlots = estates.flatMap(e => e.plots).filter(p => p.assignedDevId === dev.id);
const devRevenue = devPlots.reduce((s, p) => s + p.amountPaid, 0);
const isActive = dev.active;
return (
{[
{ l: "Plots Sold", v: devPlots.filter(p => p.status === "full_pay").length },
{ l: "Active", v: devPlots.filter(p => p.status === "part_pay").length },
{ l: "Allocated", v: (dev.allocatedPlots || []).length },
].map(s => (
))}
Revenue: {formatNaira(devRevenue)}
openAllocate(dev)}>
Allocate Plots
toggleDeveloperStatus(dev.id)}
>
{isActive ? "Disable" : "Enable"}
);
})}
{/* ── Modal: Create Developer ── */}
{showCreate && (
setShowCreate(false)}>
setForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. Apex Realty Ltd" />
setForm(f => ({ ...f, email: e.target.value }))}
placeholder="developer@email.com" />
setForm(f => ({ ...f, phone: e.target.value }))}
placeholder="+234 803 000 0000" />
setForm(f => ({ ...f, password: e.target.value }))}
placeholder="They'll use this to log in" />
{["#C8923A","#2D8A5F","#3D6FE8","#C83A6F","#8A3AC8","#3AAEC8"].map(c => (
setForm(f => ({ ...f, color: c }))} style={{
width: 28, height: 28, borderRadius: "50%", background: c,
cursor: "pointer", border: form.color === c ? "3px solid #1A1A2E" : "3px solid transparent",
transition: "border 0.15s",
}} />
))}
setShowCreate(false)}>Cancel
Create Account
)}
{/* ── Modal: Allocate Plots ── */}
{allocDev && (
setAllocDev(null)} width={560}>
Select available plots to assign exclusively to this developer.
Leave empty to give them access to all plots.
{allPlots.map(p => (
togglePlotSelect(p.id)}
style={{
padding: "5px 10px", borderRadius: 6, cursor: "pointer",
fontFamily: THEME.fontMono, fontSize: 11,
border: `1.5px solid ${selectedIds.includes(p.id) ? THEME.gold : THEME.border}`,
background: selectedIds.includes(p.id) ? THEME.goldLight : THEME.surfaceAlt,
color: selectedIds.includes(p.id) ? THEME.gold : "#666",
transition: "all 0.12s",
}}
>
#{p.plotNumber} {PLOT_SIZE_CONFIG[p.size]?.label}
))}
{selectedIds.length} plot(s) selected
setSelectedIds([])}>Clear All
setAllocDev(null)}>Cancel
Save Allocation
)}
);
}
// ============================================================
// SECTION 8f: SALES RECORDS
// Primary dev sees all; secondary dev sees their own
// ============================================================
function SalesRecords({ devFilter }) {
const { estates, developers, currentUser } = useApp();
// Build flat list of all transactions with enriched data
const allTxns = estates.flatMap(estate =>
estate.plots.flatMap(plot =>
(plot.transactions || []).map(txn => ({
...txn,
estateId: estate.id,
estateName: estate.name,
plotId: plot.id,
plotNum: plot.plotNumber,
plotSize: plot.size,
buyerName: plot.buyerName,
buyerPhone: plot.buyerPhone,
plotStatus: plot.status,
totalPrice: plot.totalPrice,
amountPaid: plot.amountPaid,
payPlan: plot.paymentPlan,
planEnd: plot.planEndDate,
dev: developers.find(d => d.id === txn.devId),
}))
)
);
// Filter by developer if secondary or if a filter is passed
const filterDevId = devFilter || (currentUser?.role === "secondary" ? currentUser.id : null);
const txns = filterDevId
? allTxns.filter(t => t.devId === filterDevId)
: allTxns;
const sorted = [...txns].sort((a, b) => new Date(b.date) - new Date(a.date));
const totalRevenue = sorted.reduce((s, t) => s + t.amount, 0);
return (
{/* Summary bar */}
Total Collected
{formatNaira(totalRevenue)}
Transactions
{sorted.length}
{sorted.length === 0 &&
}
{/* Table */}
{sorted.length > 0 && (
{["Date","Plot","Size","Estate","Buyer","Amount","Method","Plan","Status","Developer"].map(h => (
| {h} |
))}
{sorted.map((txn, i) => (
| {formatDate(txn.date)} |
#{txn.plotNum} |
{PLOT_SIZE_CONFIG[txn.plotSize]?.label} |
{truncate(txn.estateName, 18)} |
{txn.buyerName || "—"} |
{formatNaira(txn.amount)}
|
{txn.method === "paystack" ? "Paystack" : "Bank"} |
{PAYMENT_PLAN_TYPES.find(p => p.id === txn.payPlan)?.label || "—"}
|
|
{txn.dev
?
{txn.dev.name}
: "—"}
|
))}
)}
);
}
const tdS = {
padding: "10px 14px", fontSize: 12,
color: "#444", whiteSpace: "nowrap",
};
// ============================================================
// SECTION 8g / 9d: NOTIFICATIONS
// ============================================================
function NotificationsPage() {
const { notifications, currentUser, markNotifRead } = useApp();
const mine = notifications.filter(n =>
!n.for || n.for.includes(currentUser?.id)
);
return (
{mine.length === 0 &&
}
{mine.map(n => (
{n.title}
{n.body}
{formatDate(n.date)}
{!n.read && (
markNotifRead(n.id)}>
Mark read
)}
))}
);
}
// ============================================================
// SECTION 9a: SECONDARY DEV — DASHBOARD
// ============================================================
function SecondaryDashboard() {
const { currentUser, estates, developers } = useApp();
const dev = developers.find(d => d.id === currentUser?.id);
const allPlots = estates.flatMap(e => e.plots);
const myPlots = allPlots.filter(p => p.assignedDevId === currentUser?.id);
const myRevenue = myPlots.reduce((s, p) => s + p.amountPaid, 0);
const myFullSold = myPlots.filter(p => p.status === "full_pay").length;
const myPartPaid = myPlots.filter(p => p.status === "part_pay").length;
const myTxns = myPlots
.flatMap(p => (p.transactions || []).map(t => ({ ...t, plotNum: p.plotNumber, plotSize: p.size })))
.sort((a, b) => new Date(b.date) - new Date(a.date));
// Plans expiring in 14 days
const expiringSoon = myPlots.filter(p => {
const d = daysUntil(p.planEndDate);
return d !== null && d <= 14 && d >= 0;
});
return (
{expiringSoon.length > 0 && (
⚠️ {expiringSoon.length} payment plan(s) expiring soon
{expiringSoon.map(p => (
Plot #{p.plotNumber} — {p.buyerName} — {daysUntil(p.planEndDate)} day(s) left
))}
)}
My Recent Transactions
{myTxns.length === 0 && }
{myTxns.slice(0, 8).map(txn => (
{formatNaira(txn.amount)}
Plot #{txn.plotNum} · {formatDate(txn.date)}
))}
);
}
// ============================================================
// SECTION 10: APP SHELL — Sidebar, TopBar, Routing
// ============================================================
function Sidebar({ navItems }) {
const { activePage, setActivePage, currentUser, logout, pendingCount, unreadNotifCount } = useApp();
return (
{/* Logo */}
Estate Platform
{currentUser?.name}
{/* Nav Items */}
{/* Logout */}
);
}
function MainContent({ children }) {
return (
{children}
);
}
// ============================================================
// SECTION 11: ROOT — App Entry Point
// ============================================================
function AppInner() {
const { currentUser, activePage, setActivePage, activeEstateId, setActiveEstateId, estates } = useApp();
// Inject fonts & run payment due checker
usePaymentDueChecker();
if (!currentUser) return ;
const isPrimary = currentUser.role === "primary";
const navItems = isPrimary ? NAV_PRIMARY : NAV_SECONDARY;
// ── Page Router ───────────────────────────────────────────
const renderPage = () => {
if (isPrimary) {
switch (activePage) {
case "dashboard": return ;
case "estates": return ;
case "plot_grid": return ;
case "approvals": return ;
case "developers": return ;
case "sales": return ;
case "notifications": return ;
default: return ;
}
} else {
switch (activePage) {
case "sec_dashboard": return ;
case "sec_estate": return ;
case "sec_sales": return ;
case "sec_notifications": return ;
default: return ;
}
}
};
return (
{renderPage()}
);
}
export default function App() {
return (
);
}