/* ====================================================================== KOREALM — Seed data + global store Exposes window.StoreProvider, window.useApp, window.fmt helpers ====================================================================== */ const { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } = React; /* ----------------------------- helpers ----------------------------- */ const money = (n) => "\u00A3" + Math.round(n).toLocaleString("en-GB"); const money2 = (n) => "\u00A3" + n.toLocaleString("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const uid = (p) => p + "_" + Math.random().toString(36).slice(2, 8); const today = () => new Date("2026-06-05T10:00:00"); const fmtDate = (d) => new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); const fmtShort = (d) => new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short" }); const fmtTime = (d) => new Date(d).toLocaleTimeString("en-GB", { hour: "numeric", minute: "2-digit", hour12: false }); // UK deposit caps under the Tenant Fees Act 2019 (annual rent \u00f7 52 \u00d7 weeks) const weeksRent = (monthly, weeks) => Math.round((monthly * 12 / 52) * weeks); const daysAgo = (n) => { const d = today(); d.setDate(d.getDate() - n); return d.toISOString(); }; const daysAhead = (n) => { const d = today(); d.setDate(d.getDate() + n); return d.toISOString(); }; const initials = (name) => name.split(" ").map(s => s[0]).slice(0, 2).join("").toUpperCase(); window.fmt = { money, money2, fmtDate, fmtShort, fmtTime, initials, uid, today }; /* ----------------------------- seed ----------------------------- */ const NEIGHBORHOODS = ["Ancoats", "Castlefield", "Northern Quarter", "Didsbury", "Salford Quays", "Chorlton"]; /* ----------------------------- pricing ----------------------------- */ const VAT_RATE = 0.20; const COORD_RATE = 0.15; // maintenance coordination — within the 10%–20% UK range const vatOf = (n) => Math.round(n * VAT_RATE); const withVat = (n) => n + vatOf(n); // Management tiers — the source of truth for each property's commission. const PLANS = [ { id: "full", name: "Full Management", rate: 0.12, range: "8%–15% of monthly rent", icon: "shield", tagline: "Hands-off, end to end.", includes: ["Rent collection & arrears chasing", "Tenant find & referencing", "Routine inspections", "Maintenance coordination", "Compliance tracking"] }, { id: "collection", name: "Rent Collection", rate: 0.06, range: "3%–8% of monthly rent", icon: "wallet", tagline: "We collect, you manage.", includes: ["Monthly rent collection", "Arrears chasing", "Payment statements", "Annual rent review"] }, { id: "find", name: "Tenant Find", rate: 0, oneOff: 495, range: "£300–£700 per tenancy", icon: "users", tagline: "Find a tenant, then it's yours.", includes: ["Advertise & conduct viewings", "Reference & vet applicants", "Draw up the tenancy", "Move-in & deposit protection"] }, ]; const PLAN_RATE = Object.fromEntries(PLANS.map(p => [p.id, p.rate])); const PLAN_BY = Object.fromEntries(PLANS.map(p => [p.id, p])); // À-la-carte + compliance catalogue. `price` is Korealm's set price (ex-VAT); `low`/`high` are the typical UK range. const SERVICES = [ { id: "tenant_find", name: "Tenant Find", cat: "Letting", low: 300, high: 700, price: 495, unit: "per tenancy", icon: "users", desc: "Advertise, run viewings and secure a fully-referenced tenant." }, { id: "referencing", name: "Tenant Referencing", cat: "Letting", low: 20, high: 60, price: 45, unit: "per applicant", icon: "user", desc: "Credit, employment and previous-landlord checks." }, { id: "inventory", name: "Inventory Report", cat: "Letting", low: 75, high: 250, price: 150, unit: "per report", icon: "doc", desc: "Photographic schedule of condition at the start of a tenancy." }, { id: "listing", name: "Property Listing Service", cat: "Letting", low: 75, high: 300, price: 180, unit: "per listing", icon: "building", desc: "Professional listing across the major UK portals." }, { id: "checkin", name: "Check-In Service", cat: "Tenancy", low: 75, high: 200, price: 120, unit: "per tenancy", icon: "key", desc: "Meet the tenant, hand over keys and agree the inventory." }, { id: "checkout", name: "Check-Out Service", cat: "Tenancy", low: 75, high: 250, price: 140, unit: "per tenancy", icon: "key", desc: "End-of-tenancy inspection and deposit-deduction report." }, { id: "inspection", name: "Property Inspection", cat: "Management", low: 50, high: 150, price: 90, unit: "per visit", icon: "eye", desc: "Periodic visit with a written condition report." }, { id: "appraisal", name: "Property Appraisal / Valuation", cat: "Management", low: 150, high: 500, price: 250, unit: "per visit", icon: "trend", desc: "Rental valuation and lettings-market advice." }, { id: "emergency", name: "Emergency Call Out", cat: "Management", low: 50, high: 150, price: 95, unit: "per call out", icon: "alert", desc: "Out-of-hours response to urgent issues." }, { id: "gas", name: "Gas Safety Certificate (CP12)", cat: "Compliance", low: 60, high: 120, price: 90, unit: "renew yearly", icon: "flame", compliance: true, validMonths: 12, desc: "Annual landlord gas safety record — a legal requirement." }, { id: "eicr", name: "Electrical Installation Condition Report (EICR)", cat: "Compliance", low: 150, high: 350, price: 230, unit: "every 5 years", icon: "zap", compliance: true, validMonths: 60, desc: "Five-yearly electrical safety inspection — a legal requirement." }, { id: "epc", name: "EPC Certificate", cat: "Compliance", low: 60, high: 120, price: 85, unit: "every 10 years", icon: "leaf", compliance: true, validMonths: 120, desc: "Energy Performance Certificate — required to let a property." }, ]; const SVC = Object.fromEntries(SERVICES.map(s => [s.id, s])); const COMPLIANCE_TYPES = ["gas", "eicr", "epc"]; window.PRICING = { VAT_RATE, COORD_RATE, vatOf, withVat, PLANS, PLAN_BY, SERVICES, SVC, COMPLIANCE_TYPES }; function seed() { const landlords = [ { id: "ll_1", role: "landlord", name: "Daniel Okafor", email: "daniel.okafor@gmail.com", phone: "0161 496 0192", verified: true, joined: daysAgo(210), avatar: "DO", company: "Okafor Holdings" }, { id: "ll_2", role: "landlord", name: "Priya Nair", email: "priya.nair@outlook.com", phone: "0161 496 0148", verified: true, joined: daysAgo(120), avatar: "PN", company: "Nair Residential" }, { id: "ll_3", role: "landlord", name: "Marcus Bell", email: "m.bell@gmail.com", phone: "0161 496 0177", verified: true, joined: daysAgo(64), avatar: "MB", company: "Bell Property Group" }, ]; const tenants = [ { id: "tn_1", role: "tenant", name: "Aisha Rahman", email: "aisha.rahman@gmail.com", phone: "07700 900110", verified: true, joined: daysAgo(40), avatar: "AR" }, { id: "tn_2", role: "tenant", name: "Tom Whitfield", email: "tom.whitfield@gmail.com", phone: "07700 900131", verified: true, joined: daysAgo(22), avatar: "TW" }, { id: "tn_3", role: "tenant", name: "Lena Park", email: "lena.park@gmail.com", phone: "07700 900166", verified: false, joined: daysAgo(3), avatar: "LP" }, ]; const admin = { id: "ad_1", role: "admin", name: "Korealm Ops", email: "ops@korealm.co.uk", phone: "0808 196 0100", verified: true, joined: daysAgo(400), avatar: "KO" }; const P = (o) => { const plan = o.plan || "full"; return { beds: 2, baths: 1, sqft: 900, furnished: false, status: "available", amenities: ["Washing machine", "Allocated parking"], photos: 5, epc: "C", councilTax: "C", listedDate: daysAgo(20), ...o, plan, commissionRate: PLAN_RATE[plan], deposit: o.deposit ?? weeksRent(o.price, 5), holdingDeposit: weeksRent(o.price, 1), }; }; const curated = [ P({ id: "pr_1", title: "Sunlit 2-Bed off Cutting Room Square", type: "Flat", address: "Flat 4B, Sefton House, 12 Blossom Street", postcode: "M4 6BF", neighborhood: "Ancoats", price: 1495, beds: 2, baths: 1, sqft: 940, landlordId: "ll_1", furnished: true, epc: "B", councilTax: "C", amenities: ["Washing machine", "Balcony", "Allocated parking", "Dishwasher"], photos: 6, listedDate: daysAgo(8) }), P({ id: "pr_2", title: "Modern Loft on the Bridgewater Canal", type: "Loft", address: "Apartment 12, Potato Wharf, Castle Street", postcode: "M3 4NB", neighborhood: "Castlefield", price: 1750, beds: 1, baths: 1, sqft: 1080, landlordId: "ll_1", epc: "B", councilTax: "D", amenities: ["Gym", "Roof terrace", "Concierge", "Pet friendly"], photos: 7, listedDate: daysAgo(14) }), P({ id: "pr_3", title: "Cosy Studio in the Northern Quarter", type: "Studio", address: "8 Lantern Court, Tib Street", postcode: "M4 1LA", neighborhood: "Northern Quarter", price: 925, beds: 0, baths: 1, sqft: 520, landlordId: "ll_2", plan: "collection", epc: "D", councilTax: "A", amenities: ["Gas central heating", "Pet friendly"], photos: 4, listedDate: daysAgo(30) }), P({ id: "pr_4", title: "Family Townhouse in Didsbury Village", type: "Townhouse", address: "47 Birch Lane", postcode: "M20 2WE", neighborhood: "Didsbury", price: 2350, beds: 3, baths: 2, sqft: 1620, landlordId: "ll_2", furnished: false, epc: "C", councilTax: "E", amenities: ["Private garden", "Garage", "Washing machine", "Wood burner"], photos: 8, listedDate: daysAgo(5), status: "under_application" }), P({ id: "pr_5", title: "Waterside 2-Bed at Salford Quays", type: "Flat", address: "Apartment 9A, NV Buildings, The Quays", postcode: "M50 3AZ", neighborhood: "Salford Quays", price: 1595, beds: 2, baths: 2, sqft: 1100, landlordId: "ll_3", epc: "B", councilTax: "D", amenities: ["Waterside view", "Gym", "Allocated parking", "Lift"], photos: 6, listedDate: daysAgo(11) }), P({ id: "pr_6", title: "Bright 1-Bed in a Converted Mill", type: "Flat", address: "Flat 3, Royal Mills, 17 Redhill Street", postcode: "M4 5BA", neighborhood: "Ancoats", price: 1195, beds: 1, baths: 1, sqft: 720, landlordId: "ll_3", plan: "collection", epc: "C", councilTax: "B", amenities: ["Exposed brick", "Pet friendly", "Bike storage"], photos: 5, listedDate: daysAgo(2) }), P({ id: "pr_7", title: "Garden Studio in Chorlton", type: "Studio", address: "9 Willow Court, Beech Road", postcode: "M21 9EQ", neighborhood: "Chorlton", price: 845, beds: 0, baths: 1, sqft: 480, landlordId: "ll_1", epc: "D", councilTax: "A", amenities: ["Shared garden", "Gas central heating"], photos: 4, listedDate: daysAgo(18), status: "leased" }), P({ id: "pr_8", title: "Spacious 3-Bed near Didsbury Park", type: "Flat", address: "Flat 7, Elm Court, 118 Wilmslow Road", postcode: "M20 6RD", neighborhood: "Didsbury", price: 2150, beds: 3, baths: 2, sqft: 1480, landlordId: "ll_2", plan: "find", furnished: true, epc: "C", councilTax: "E", amenities: ["Washing machine", "Balcony", "Allocated parking", "Dishwasher", "Gas central heating"], photos: 7, listedDate: daysAgo(9) }), ]; // Bulk marketplace catalogue so browsing reflects the “240+ listings” claim (paginated on the public site). const _adj = ["Sunlit", "Modern", "Cosy", "Bright", "Spacious", "Stylish", "Riverside", "Converted", "Contemporary", "Charming", "Elegant", "Airy", "Refurbished", "Warehouse", "Period"]; const _street = ["Blossom Street", "Canal Road", "Tib Street", "Birch Lane", "Potato Wharf", "Redhill Street", "Beech Road", "Wilmslow Road", "Oldham Road", "Deansgate", "Chapel Street", "Pollard Street", "George Leigh Street", "Cambridge Street", "Hulme High Street"]; const _types = ["Flat", "Loft", "Studio", "Townhouse", "House", "Maisonette"]; const _amen = ["Washing machine", "Balcony", "Allocated parking", "Lift", "Pet friendly", "Dishwasher", "Gym", "Roof terrace", "Bike storage", "Private garden"]; const extra = []; const TARGET = 240; for (let i = 0; i < TARGET - curated.length; i++) { const hood = NEIGHBORHOODS[i % NEIGHBORHOODS.length]; const type = _types[i % _types.length]; const beds = type === "Studio" ? 0 : 1 + (i % 4); const price = 800 + ((i * 137) % 1800); const status = i % 11 === 0 ? "leased" : (i % 7 === 0 ? "under_application" : "available"); extra.push(P({ id: "prg_" + i, title: `${_adj[i % _adj.length]} ${beds === 0 ? "Studio" : beds + "-Bed"} in ${hood}`, type, address: `${10 + (i % 89)} ${_street[i % _street.length]}`, postcode: "M" + (1 + (i % 40)) + " " + (1 + (i % 8)) + "AB", neighborhood: hood, price, beds, baths: 1 + (beds > 2 ? 1 : 0), sqft: 480 + ((i * 53) % 1400), landlordId: landlords[i % landlords.length].id, furnished: i % 2 === 0, epc: ["A", "B", "C", "D"][i % 4], councilTax: ["A", "B", "C", "D", "E"][i % 5], photos: 4 + (i % 5), status, listedDate: daysAgo((i % 45) + 1), amenities: [_amen[i % _amen.length], _amen[(i + 3) % _amen.length], _amen[(i + 6) % _amen.length]], })); } const properties = curated.concat(extra); // ---------- Real photography (Unsplash) ---------- const _uimg = (id, w = 900) => `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&q=70`; const IMG = { kitchen: ["1484154218962-a197022b5858", "1556909212-d5b604d0c90d", "1567767292278-a4f21aa2d36e", "1600607687939-ce8a6c25118c", "1556912167-f556f1f39fdf", "1600489000022-c2086d79f9d4"], bedroom: ["1600566753086-00f18fb6b3ea", "1600566752355-35792bedcfea", "1600047509807-ba8f99d2cdde", "1505693416388-ac5ce068fe85", "1522771739844-6a9f6d5f14af", "1618221195710-dd6b41faaea6"], bath: ["1600210492486-724fe5c67fb0", "1583608205776-bfd35f0d9f83", "1584622650111-993a426fbf0a", "1552321554-5fefe8c9ef14", "1595515106969-1ce29566ff1c"], }; // One large distinct cover pool — 30 unique photos, assigned by index so no page of 12 ever repeats. const COVERS = [ "1568605114967-8130f3a36994", "1512917774080-9991f1c4c750", "1570129477492-45c003edd2be", "1580587771525-78b9dba3b914", "1560185007-cde436f6a4d0", "1616486338812-3dadae4b4ace", "1523217582562-09d0def993a6", "1493809842364-78817add7ffb", "1522708323590-d24dbb6b0267", "1600585154340-be6161a56a0c", "1502005229762-cf1b2da7c5d6", "1554995207-c18c203602cb", "1560448204-e02f11c3d0e2", "1502672260266-1c1ef2d93688", "1600607687939-ce8a6c25118c", "1600566753086-00f18fb6b3ea", "1600047509807-ba8f99d2cdde", "1449844908441-8829872d2607", "1512918728675-ed5a9ecdebfd", "1600566752355-35792bedcfea", "1600210492486-724fe5c67fb0", "1580041065738-e72023775cdc", "1600585152915-d208bec867a1", "1600573472550-8090b5e0745e", "1600585153490-76fb20a32601", "1600121848594-d8644e57abab", "1600607687920-4e2a09cf159d", "1600596542815-ffad4c1539a9", "1556909212-d5b604d0c90d", "1613977257363-707ba9348227", ]; const _pick = (arr, n) => arr[((n % arr.length) + arr.length) % arr.length]; properties.forEach((p, idx) => { const cover = COVERS[idx % COVERS.length]; p.img = _uimg(cover, 900); p.gallery = [ _uimg(cover, 1400), _uimg(_pick(IMG.kitchen, idx), 700), _uimg(_pick(IMG.bedroom, idx + 1), 700), _uimg(_pick(IMG.bath, idx + 2), 700), ]; }); const applications = [ { id: "ap_1", propertyId: "pr_4", tenantId: "tn_1", status: "pending", submittedDate: daysAgo(2), income: 6200, employment: "Senior Designer, Lumen Studio", moveIn: daysAhead(25), occupants: 3, pets: "1 cat", notes: "Relocating for work, flexible on start date.", creditConsent: true }, { id: "ap_2", propertyId: "pr_5", tenantId: "tn_2", status: "approved", submittedDate: daysAgo(9), decisionDate: daysAgo(7), decisionBy: "Korealm Ops", income: 4400, employment: "Software Engineer, Northwind", moveIn: daysAhead(10), occupants: 1, pets: "None", notes: "", creditConsent: true }, { id: "ap_3", propertyId: "pr_2", tenantId: "tn_2", status: "rejected", submittedDate: daysAgo(20), decisionDate: daysAgo(18), decisionBy: "Korealm Ops", income: 3200, employment: "Freelance Writer", moveIn: daysAhead(5), occupants: 1, pets: "None", notes: "Income below the affordability threshold.", reason: "Income did not meet the 2.5× monthly rent requirement.", creditConsent: true }, ]; const inspections = [ { id: "in_1", propertyId: "pr_1", tenantId: "tn_1", slot: daysAhead(2) + "|10:30 AM", requestedDate: daysAgo(1), status: "requested", notes: "Prefer a morning viewing." }, { id: "in_2", propertyId: "pr_6", tenantId: "tn_2", slot: daysAhead(3) + "|2:00 PM", requestedDate: daysAgo(1), status: "requested", notes: "" }, { id: "in_3", propertyId: "pr_5", tenantId: "tn_2", slot: daysAgo(8) + "|11:00 AM", requestedDate: daysAgo(11), status: "completed", notes: "", confirmedBy: "Korealm Ops" }, { id: "in_4", propertyId: "pr_8", tenantId: "tn_1", slot: daysAhead(1) + "|4:00 PM", requestedDate: daysAgo(2), status: "confirmed", notes: "", confirmedBy: "Korealm Ops" }, ]; const payments = [ { id: "pm_1", tenantId: "tn_2", propertyId: "pr_5", type: "onboarding", amount: 368, status: "paid", method: "Card ····4242", date: daysAgo(6), reference: "KRM-OB-50291", commissionRate: 1, commissionAmount: 368, landlordPayout: 0 }, { id: "pm_2", tenantId: "tn_2", propertyId: "pr_5", type: "rent", amount: 1595, status: "paid", method: "Card ····4242", date: daysAgo(2), reference: "KRM-RENT-50318", commissionRate: 0.12, commissionAmount: 191, landlordPayout: 1404, period: "June 2026" }, { id: "pm_3", tenantId: "tn_1", propertyId: "pr_7", type: "rent", amount: 845, status: "paid", method: "Bank transfer", date: daysAgo(1), reference: "KRM-RENT-50320", commissionRate: 0.12, commissionAmount: 101, landlordPayout: 744, period: "June 2026" }, { id: "pm_4", tenantId: "tn_1", propertyId: "pr_7", type: "rent", amount: 845, status: "pending", method: "—", date: daysAhead(25), reference: "KRM-RENT-50402", commissionRate: 0.12, commissionAmount: 101, landlordPayout: 744, period: "July 2026" }, ]; const kyc = [ { id: "kyc_1", landlordId: "ll_1", status: "verified", submittedDate: daysAgo(180), decisionDate: daysAgo(178), docs: [ { type: "Photo ID (passport / licence)", name: "passport.pdf", status: "verified" }, { type: "Proof of ownership (title deeds)", name: "title_blossomst.pdf", status: "verified" }, { type: "Bank account (for payouts)", name: "payout_details.pdf", status: "verified" } ] }, { id: "kyc_2", landlordId: "ll_2", status: "submitted", submittedDate: daysAgo(1), docs: [ { type: "Photo ID (passport / licence)", name: "driving_licence.jpg", status: "pending" }, { type: "Proof of ownership (title deeds)", name: "title_birchlane.pdf", status: "pending" }, { type: "Bank account (for payouts)", name: "bank_statement.pdf", status: "pending" } ] }, { id: "kyc_3", landlordId: "ll_3", status: "not_started", docs: [] }, ]; const vendors = [ { id: "vn_1", name: "BlueLine Plumbing", trade: "Plumbing", rating: 4.8, jobs: 132 }, { id: "vn_2", name: "Volt & Co Electric", trade: "Electrical", rating: 4.6, jobs: 89 }, { id: "vn_3", name: "ClimateCare HVAC", trade: "HVAC", rating: 4.7, jobs: 64 }, { id: "vn_4", name: "FixRight Handyman", trade: "General", rating: 4.5, jobs: 210 }, { id: "vn_5", name: "ClearView Appliance", trade: "Appliance", rating: 4.4, jobs: 51 }, ]; const maintenance = [ { id: "mt_1", propertyId: "pr_7", tenantId: "tn_1", category: "Plumbing", priority: "high", title: "Kitchen sink leaking under cabinet", description: "Water pooling under the sink overnight. Towel soaked by morning.", status: "assigned", vendorId: "vn_1", createdDate: daysAgo(2) }, { id: "mt_2", propertyId: "pr_7", tenantId: "tn_1", category: "Electrical", priority: "medium", title: "Bedroom outlet not working", description: "Right-side outlet in main bedroom has no power.", status: "open", vendorId: null, createdDate: daysAgo(1) }, { id: "mt_3", propertyId: "pr_5", tenantId: "tn_2", category: "HVAC", priority: "low", title: "AC filter needs replacing", description: "Routine — airflow is weak.", status: "resolved", vendorId: "vn_3", createdDate: daysAgo(12), resolvedDate: daysAgo(6), invoiceAmount: 140, coordFee: 21 }, ]; // Compliance certificates per property. days = days until expiry (negative = expired, null = missing). const cmp = (propertyId, type, status, days) => { const v = SVC[type].validMonths; const expires = days == null ? null : (days >= 0 ? daysAhead(days) : daysAgo(-days)); let issued = null; if (status !== "missing") { const d = new Date(expires || today()); d.setMonth(d.getMonth() - v); issued = d.toISOString(); } return { id: uid("cmp"), propertyId, type, status, issued, expires }; }; const compliance = [ cmp("pr_1", "gas", "valid", 210), cmp("pr_1", "eicr", "valid", 1400), cmp("pr_1", "epc", "valid", 3200), cmp("pr_2", "gas", "expiring", 25), cmp("pr_2", "eicr", "valid", 1100), cmp("pr_2", "epc", "valid", 2600), cmp("pr_3", "gas", "expired", -12), cmp("pr_3", "eicr", "valid", 900), cmp("pr_3", "epc", "missing", null), cmp("pr_4", "gas", "valid", 300), cmp("pr_4", "eicr", "valid", 1600), cmp("pr_4", "epc", "valid", 3000), cmp("pr_5", "gas", "valid", 180), cmp("pr_5", "eicr", "expiring", 40), cmp("pr_5", "epc", "valid", 2200), cmp("pr_6", "gas", "missing", null), cmp("pr_6", "eicr", "valid", 1200), cmp("pr_6", "epc", "valid", 2900), cmp("pr_7", "gas", "valid", 260), cmp("pr_7", "eicr", "valid", 1500), cmp("pr_7", "epc", "valid", 3300), cmp("pr_8", "gas", "valid", 150), cmp("pr_8", "eicr", "valid", 1000), cmp("pr_8", "epc", "expiring", 50), ]; const so = (propertyId, serviceId, landlordId, status, date) => { const s = SVC[serviceId]; const vat = vatOf(s.price); return { id: uid("so"), propertyId, serviceId, landlordId, name: s.name, cat: s.cat, unit: s.unit, amount: s.price, vat, total: s.price + vat, status, orderedDate: date, reference: "KRM-SVC-" + Math.floor(50000 + Math.random() * 9999) }; }; const serviceOrders = [ so("pr_5", "inventory", "ll_3", "completed", daysAgo(10)), so("pr_5", "checkin", "ll_3", "completed", daysAgo(6)), so("pr_4", "referencing", "ll_2", "completed", daysAgo(3)), so("pr_1", "appraisal", "ll_1", "scheduled", daysAgo(1)), ]; return { users: [...landlords, ...tenants, admin], properties, applications, inspections, payments, kyc, vendors, maintenance, compliance, serviceOrders, notifications: [], }; } /* ----------------------------- store ----------------------------- */ const AppCtx = createContext(null); const LS_KEY = "korealm_state_v8_uk"; function StoreProvider({ children }) { const [db, setDb] = useState(() => { try { const s = localStorage.getItem(LS_KEY); if (s) return JSON.parse(s); } catch (e) {} return seed(); }); // session: who am I per role + current role + view const [session, setSession] = useState(() => { try { const s = localStorage.getItem(LS_KEY + "_session3"); if (s) return JSON.parse(s); } catch (e) {} return { role: "public", tenantId: null, landlordId: null, view: "home", params: {} }; }); const [toasts, setToasts] = useState([]); useEffect(() => { try { localStorage.setItem(LS_KEY, JSON.stringify(db)); } catch (e) {} }, [db]); useEffect(() => { try { localStorage.setItem(LS_KEY + "_session3", JSON.stringify(session)); } catch (e) {} }, [session]); const toast = useCallback((msg, kind = "ok") => { const id = uid("t"); setToasts(t => [...t, { id, msg, kind }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3400); }, []); // navigation const go = useCallback((view, params = {}) => setSession(s => ({ ...s, view, params })), []); const setRole = useCallback((role) => setSession(s => ({ ...s, role, view: defaultView(role), params: {} })), []); // Real-product entry: Public is open; Tenant/Landlord require a signed-in account. const enterRole = useCallback((role) => setSession(s => { if (role === "public" || role === "launch" || role === "admin") return { ...s, role, view: defaultView(role), params: {} }; const idKey = role === "tenant" ? "tenantId" : "landlordId"; if (s[idKey]) return { ...s, role, view: "dashboard", params: {} }; return { ...s, role: "public", view: "auth", params: { mode: "signin", intent: { role } } }; }), []); // generic patch const patch = useCallback((coll, id, changes) => { setDb(d => ({ ...d, [coll]: d[coll].map(x => x.id === id ? { ...x, ...changes } : x) })); }, []); const insert = useCallback((coll, item) => { setDb(d => ({ ...d, [coll]: [item, ...d[coll]] })); }, []); const actions = useMemo(() => ({ // ---------- auth ---------- registerUser({ name, email, role, phone, password }) { const id = uid(role === "tenant" ? "tn" : "ll"); const u = { id, role, name, email, phone, password, verified: false, joined: today().toISOString(), avatar: initials(name), pendingVerify: true, registered: true }; setDb(d => ({ ...d, users: [...d.users, u] })); return u; }, verifyEmail(userId) { patch("users", userId, { verified: true, pendingVerify: false }); }, // ---------- inspections ---------- requestInspection({ propertyId, tenantId, slot, notes }) { const item = { id: uid("in"), propertyId, tenantId, slot, notes, requestedDate: today().toISOString(), status: "requested" }; insert("inspections", item); return item; }, confirmInspection(id) { patch("inspections", id, { status: "confirmed", confirmedBy: "Korealm Ops" }); }, declineInspection(id) { patch("inspections", id, { status: "declined" }); }, completeInspection(id) { patch("inspections", id, { status: "completed" }); }, rescheduleInspection(id, slot) { patch("inspections", id, { slot, status: "confirmed", confirmedBy: "Korealm Ops" }); }, // ---------- applications ---------- submitApplication(data) { const item = { id: uid("ap"), status: "pending", submittedDate: today().toISOString(), ...data }; insert("applications", item); setDb(d => ({ ...d, properties: d.properties.map(p => p.id === data.propertyId ? { ...p, status: "under_application" } : p) })); return item; }, approveApplication(id) { patch("applications", id, { status: "approved", decisionDate: today().toISOString(), decisionBy: "Korealm Ops" }); }, rejectApplication(id, reason) { patch("applications", id, { status: "rejected", decisionDate: today().toISOString(), decisionBy: "Korealm Ops", reason }); setDb(d => { const ap = d.applications.find(a => a.id === id); return { ...d, properties: d.properties.map(p => p.id === ap.propertyId ? { ...p, status: "available" } : p) }; }); }, // ---------- payments ---------- addPayment(data) { const ref = "KRM-" + (data.type === "onboarding" ? "OB" : "RENT") + "-" + Math.floor(50000 + Math.random() * 9999); const rate = data.type === "onboarding" ? 1 : (data.commissionRate ?? 0.10); const commissionAmount = Math.round(data.amount * rate); const item = { id: uid("pm"), status: "paid", date: today().toISOString(), reference: ref, commissionRate: rate, commissionAmount, landlordPayout: data.amount - commissionAmount, ...data }; insert("payments", item); // mark property leased on onboarding if (data.type === "onboarding") { setDb(d => ({ ...d, properties: d.properties.map(p => p.id === data.propertyId ? { ...p, status: "leased" } : p) })); } return item; }, payRent(paymentId, method) { patch("payments", paymentId, { status: "paid", method, date: today().toISOString() }); }, // ---------- kyc ---------- submitKyc(landlordId, docs) { setDb(d => { const exists = d.kyc.find(k => k.landlordId === landlordId); if (exists) return { ...d, kyc: d.kyc.map(k => k.landlordId === landlordId ? { ...k, status: "submitted", submittedDate: today().toISOString(), docs } : k) }; return { ...d, kyc: [...d.kyc, { id: uid("kyc"), landlordId, status: "submitted", submittedDate: today().toISOString(), docs }] }; }); }, verifyKyc(id) { setDb(d => ({ ...d, kyc: d.kyc.map(k => k.id === id ? { ...k, status: "verified", decisionDate: today().toISOString(), docs: k.docs.map(x => ({ ...x, status: "verified" })) } : k) })); }, rejectKyc(id, reason) { setDb(d => ({ ...d, kyc: d.kyc.map(k => k.id === id ? { ...k, status: "rejected", decisionDate: today().toISOString(), rejectReason: reason } : k) })); }, // ---------- properties ---------- addProperty(data) { const item = { id: uid("pr"), status: "available", listedDate: today().toISOString(), photos: 5, furnished: false, amenities: ["Washing machine", "Allocated parking"], commissionRate: 0.10, epc: "C", councilTax: "C", deposit: weeksRent(+data.price, 5), holdingDeposit: weeksRent(+data.price, 1), ...data, price: +data.price, beds: +data.beds, baths: +data.baths, sqft: +data.sqft }; insert("properties", item); return item; }, // ---------- maintenance ---------- addMaintenance(data) { const item = { id: uid("mt"), status: "open", vendorId: null, createdDate: today().toISOString(), ...data }; insert("maintenance", item); return item; }, assignVendor(id, vendorId) { patch("maintenance", id, { status: "assigned", vendorId }); }, setMaintenanceStatus(id, status) { const extra = status === "resolved" ? { resolvedDate: today().toISOString() } : {}; patch("maintenance", id, { status, ...extra }); }, // log a contractor invoice on resolution and take the coordination fee resolveMaintenance(id, invoiceAmount) { const amt = +invoiceAmount || 0; patch("maintenance", id, { status: "resolved", resolvedDate: today().toISOString(), invoiceAmount: amt, coordFee: Math.round(amt * COORD_RATE) }); }, // ---------- plans & services ---------- setPropertyPlan(propertyId, planId) { patch("properties", propertyId, { plan: planId, commissionRate: PLAN_RATE[planId] }); }, orderService({ propertyId, serviceId, landlordId }) { const s = SVC[serviceId]; if (!s) return null; const vat = vatOf(s.price); const item = { id: uid("so"), propertyId, serviceId, landlordId, name: s.name, cat: s.cat, unit: s.unit, amount: s.price, vat, total: s.price + vat, status: s.compliance ? "scheduled" : "ordered", orderedDate: today().toISOString(), reference: "KRM-SVC-" + Math.floor(50000 + Math.random() * 9999) }; insert("serviceOrders", item); // a compliance order renews that certificate if (s.compliance) { const d = new Date(today()); d.setMonth(d.getMonth() + s.validMonths); setDb(db2 => { const exists = db2.compliance.find(c => c.propertyId === propertyId && c.type === serviceId); const rec = { status: "valid", issued: today().toISOString(), expires: d.toISOString() }; if (exists) return { ...db2, compliance: db2.compliance.map(c => (c.propertyId === propertyId && c.type === serviceId) ? { ...c, ...rec } : c) }; return { ...db2, compliance: [{ id: uid("cmp"), propertyId, type: serviceId, ...rec }, ...db2.compliance] }; }); } return item; }, // ---------- system ---------- reset() { setDb(seed()); toast("Demo data reset to defaults", "info"); }, }), [patch, insert, toast]); const value = { db, session, setSession, setRole, enterRole, go, toast, toasts, actions, me: (role) => { if (role === "tenant") return db.users.find(u => u.id === session.tenantId); if (role === "landlord") return db.users.find(u => u.id === session.landlordId); if (role === "admin") return db.users.find(u => u.role === "admin"); return null; }, user: (id) => db.users.find(u => u.id === id), property: (id) => db.properties.find(p => p.id === id), }; return {children}; } function defaultView(role) { if (role === "launch") return "launch"; return role === "public" ? "home" : "dashboard"; } function useApp() { return useContext(AppCtx); } window.StoreProvider = StoreProvider; window.useApp = useApp; window.NEIGHBORHOODS = NEIGHBORHOODS;