/* Immobase App — modules 2/2 : Checklist, Emails, Simulateur, Comparateur */ const RECIP_BG = { sage: 'bg-sage', clay: 'bg-clay', blue: 'bg-blue' }; /* ---------- PaywallGate ---------- */ const PAYWALL_FEATURES = { checklist: { title: 'Checklist de documents', desc: 'Accédez à la liste complète des documents à préparer, personnalisée selon votre profil (type de bien, financement, statut).', }, emails: { title: 'Générateur d\'emails', desc: 'Des emails prêts à envoyer à votre notaire, votre banque, le syndic ou votre assureur — rédigés pour vous.', }, comparateur: { title: 'Comparateur de biens', desc: 'Comparez deux biens côte à côte sur tous les critères clés pour trancher en toute clarté.', }, }; function PaywallGate({ feature, children }) { const pro = window.isPro(); React.useEffect(() => { if (!pro) window.track?.('paywall_viewed', { feature }); }, [feature, pro]); if (pro) return children; const info = PAYWALL_FEATURES[feature] || {}; return (

{info.title}

{info.desc}

Biens illimités Checklist personnalisée Emails prêts à envoyer Comparateur de biens

Déjà abonné ? { e.preventDefault(); await window.sb.auth.refreshSession(); window.location.reload(); }}>Rafraîchir la session

); } /* ---------- ProfileSetup ---------- */ function ProfileSetup({ store, onClose }) { const [p, setP] = React.useState({ ...store.state.profile }); const set = (k, v) => setP(prev => ({ ...prev, [k]: v })); const isComplete = p.type_bien && p.financement && p.statut_emploi; function save() { store.setProfile(p); onClose(); } const modalRef = useModalA11y(onClose); const Q = ({ label, children }) => (
{label}
{children}
); const Opt = ({ k, v, label, icon }) => ( ); return (
e.stopPropagation()}>

Votre profil d'achat

Personnalise votre checklist et vos emails automatiquement.

); } /* ---------- Checklist ---------- */ function Checklist({ store, go }) { const { documents, profile } = store.state; const [showProfile, setShowProfile] = React.useState(false); const [newLine, setNewLine] = React.useState(''); // ligne perso (s10q08) function addLine() { store.addCustomDoc(newLine); setNewLine(''); } const profileComplete = profile?.type_bien && profile?.financement && profile?.statut_emploi; const done = documents.filter((d) => d.done).length; const pct = documents.length > 0 ? Math.round((done / documents.length) * 100) : 0; const groups = {}; documents.forEach((d) => { (groups[d.recipient] = groups[d.recipient] || []).push(d); }); return (
{!profileComplete && (
Personnalisez votre checklist Appartement ou maison, crédit ou cash, salarié ou indépendant — votre liste s'adapte à votre situation réelle.
)} {profileComplete && (
{profile.type_bien === 'appartement' ? '🏢 Appartement' : '🏡 Maison'} {profile.financement === 'credit' ? '🏦 Crédit' : '💶 Cash'} {profile.statut_emploi === 'salarie' ? '💼 Salarié' : profile.statut_emploi === 'independant' ? '🧑‍💻 Indépendant' : '🌴 Retraité'}
)}
Progression globale{done} / {documents.length} documents
{pct}%
{Object.keys(groups).map((rec) => { const r = RECIPIENTS[rec]; const items = groups[rec]; const gdone = items.filter((d) => d.done).length; return (

{r.label}

{gdone} / {items.length}
{items.map((d) => { const hasMail = !!EMAIL_TEMPLATES[d.id]; return (
{d.name} {d.note}
{hasMail && ( )} {d.custom && ( )}
); })}
); })} {/* Ligne personnalisée : l'utilisateur complète sa propre checklist (s10q08) */}

Il manque un document ?

setNewLine(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addLine(); } }} placeholder="Ajoutez votre propre ligne (ex. attestation de donation…)" aria-label="Nouvelle ligne de checklist" style={{ flex: 1, minWidth: 220, fontFamily: 'inherit', fontSize: 14.5, padding: '11px 14px', border: '1.5px solid var(--hair)', borderRadius: 12, background: 'var(--card)', color: 'var(--ink)' }} />
{showProfile && setShowProfile(false)} />}
); } /* ---------- Emails ---------- */ function Emails({ store, focusDoc }) { const { documents, properties } = store.state; const mailDocs = documents.filter((d) => EMAIL_TEMPLATES[d.id]); if (mailDocs.length === 0) { return (

Chargement des documents…

); } const [sel, setSel] = React.useState(() => { if (focusDoc && EMAIL_TEMPLATES[focusDoc]) return focusDoc; return mailDocs[0].id; }); const tpl = EMAIL_TEMPLATES[sel] || EMAIL_TEMPLATES[mailDocs[0].id]; // Document sélectionné (accessible dans tout le composant) const doc = documents.find(d => d.id === sel); // Bien concerné : sélectionnable, défaut = coup de cœur (sinon 1er bien) const fav = properties.find((p) => p.favori) || properties[0]; const [bienId, setBienId] = React.useState(fav?.id || ''); const selectedBien = properties.find((p) => p.id === bienId) || fav; const bien = selectedBien ? `${selectedBien.title}, ${selectedBien.city}` : 'le bien concerné'; const userName = window.IMMOBASE_USER?.user_metadata?.full_name || window.IMMOBASE_USER?.email || 'Vous'; const body = renderBody(tpl, bien, userName); function copy() { const text = `À: ${tpl.to}\nObjet: ${tpl.subject}\n\n${body}`; window.track?.('email_copied', { doc: sel }); navigator.clipboard?.writeText(text).then(() => toast('Email copié ✓'), () => toast('Email copié ✓')); } return (
{mailDocs.map((d) => { const r = RECIPIENTS[d.recipient]; return ( ); })}
{properties.length > 0 && (
Bien concerné
)}
À{tpl.to}
Objet{tpl.subject}
{body}
{doc && ( )}
); } function renderBody(tpl, bien, nom) { return tpl.body.replace(/\{\{bien\}\}/g, bien).replace(/\{\{nom\}\}/g, nom); } /* ---------- Simulateur ---------- */ const NOTAIRE_RATES = { ancien: 0.075, neuf: 0.025 }; /* Montant affiché formaté, éditable au clic pour une saisie précise */ function EditableAmount({ value, onCommit, min, max }) { const [editing, setEditing] = React.useState(false); const [draft, setDraft] = React.useState(String(value)); function start() { setDraft(String(value)); setEditing(true); } function commit() { let n = parseInt(draft.replace(/[^\d]/g, ''), 10); if (isNaN(n)) n = value; if (typeof min === 'number') n = Math.max(min, n); if (typeof max === 'number') n = Math.min(max, n); onCommit(n); setEditing(false); } if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setEditing(false); } }} /> ); } return ( ); } function Simulateur({ store }) { const sim = store.state.sim; const set = (k) => (e) => store.setSim({ [k]: Number(e.target.value) }); const [mode, setMode] = React.useState('mensualite'); // mensualite | capacite const [bienType, setBienType] = React.useState('ancien'); const [revenus, setRevenus] = React.useState(4000); const [charges, setCharges] = React.useState(0); const [tauxEndett, setTauxEndett] = React.useState(35); const notaireRate = NOTAIRE_RATES[bienType]; // Bloc type de bien (commun aux deux modes) const TypeBloc = (
Type de bien
); return (
{mode === 'mensualite' ? (() => { const res = computeLoan(sim); const capShare = res.total > 0 ? (res.principal / res.total) * 100 : 100; const fraisNotaire = Math.round(sim.price * notaireRate); const budgetTotal = sim.price + fraisNotaire; const apportCouvreFrais = sim.apport >= fraisNotaire; return (
{TypeBloc}
Prix du bien store.setSim({ price: n })} />
10 000 €1 500 000 €
Apport personnel store.setSim({ apport: Math.min(n, sim.price) })} />
0 €{Math.round((sim.apport / sim.price) * 100) || 0} % du prix
Durée{sim.duree} ans
5 ans30 ans
Taux d'intérêt{sim.taux.toFixed(2)} %
0,5 %6 %
Mensualité estimée
{fmtEur(res.monthly)}/mois
Sur {sim.duree} ans · capital emprunté {fmtEur(res.principal)}
Prix du bien{fmtEur(sim.price)}
Frais de notaire · {bienType === 'ancien' ? 'ancien' : 'neuf'}{fmtEur(fraisNotaire)}
Budget total à prévoir{fmtEur(budgetTotal)}
{apportCouvreFrais ? ✓ Votre apport couvre les frais de notaire : ⚠ Prévoyez au moins {fmtEur(fraisNotaire)} d'apport pour les frais de notaire}
Coût total du crédit{fmtEur(res.total)}
Dont intérêts{fmtEur(res.interest)}
Nombre d'échéances{res.n}
Capital Intérêts
); })() : (() => { const r = sim.taux / 100 / 12; const n = sim.duree * 12; const mensualiteMax = Math.max(0, Math.round((revenus * tauxEndett) / 100 - charges)); const principalMax = r === 0 ? mensualiteMax * n : (mensualiteMax * (1 - Math.pow(1 + r, -n))) / r; const budgetMax = Math.max(0, Math.round((principalMax + sim.apport) / (1 + notaireRate))); const fraisNotaireMax = Math.round(budgetMax * notaireRate); return (
{TypeBloc}
Revenus nets du foyer
setRevenus(Number(e.target.value))} />
par moisnet avant impôt
Crédits en cours
setCharges(Number(e.target.value))} />
0 €auto, conso… /mois
Apport personnel store.setSim({ apport: n2 })} />
0 ێpargne dispo
Durée{sim.duree} ans
5 ans30 ans
Taux d'endettement max{tauxEndett} %
setTauxEndett(Number(e.target.value))} />
25 %plafond conseillé 35 %
Budget d'achat maximum
{fmtEur(budgetMax)}
Frais de notaire inclus · apport {fmtEur(sim.apport)}
Mensualité maximale{fmtEur(mensualiteMax)}/mois
Capital empruntable{fmtEur(Math.round(principalMax))}
+ Apport{fmtEur(sim.apport)}
− Frais de notaire{fmtEur(fraisNotaireMax)}
Prix de bien atteignable{fmtEur(budgetMax)}
{mensualiteMax <= 0 ? ⚠ Vos crédits en cours dépassent votre capacité — réduisez-les ou augmentez vos revenus : Calcul au taux d'endettement de {tauxEndett} % (assurance de prêt non incluse)}
); })()}
); } /* ---------- Comparateur ---------- */ function Comparateur({ store, go }) { const props = store.state.properties.filter((p) => p.status !== 'elimine'); const favs = props.filter((p) => p.favori); const [aId, setA] = React.useState((favs[0] || props[0] || {}).id); const [bId, setB] = React.useState((favs[1] || props[1] || props[0] || {}).id); if (props.length < 2) { return (

Il faut au moins deux biens à comparer

Ajoutez des biens dans le Tracker, puis revenez ici pour les mettre face à face.

); } const a = props.find((p) => p.id === aId) || props[0]; const b = props.find((p) => p.id === bId) || props[1]; const ppm = (p) => (p.surface ? Math.round(p.price / p.surface) : 0); const valid = (typeof activeCriteres !== 'undefined') ? activeCriteres(store) : null; const sa = (typeof visiteScore !== 'undefined') ? visiteScore(a.visiteGrille, valid) : null; const sb = (typeof visiteScore !== 'undefined') ? visiteScore(b.visiteGrille, valid) : null; // Verdict par critère : « win » attribué UNIQUEMENT si les deux valeurs sont // renseignées. Toute valeur manquante → « — » et le critère est exclu du score // (pas de double comptage, pas de valeur inventée — s10q01, s10q04). const simP = store.state.sim || {}; const mensualite = (p) => { if (!p.price) return null; const r = computeLoan({ price: p.price, apport: Math.min(simP.apport || 0, p.price), duree: simP.duree || 20, taux: simP.taux || 3.4 }); return Math.round(r.monthly); }; const dpeRank = (d) => (d ? 'ABCDEFG'.indexOf(d) : -1); // A = meilleur const ma = mensualite(a), mb = mensualite(b); const nz = (v) => (v === 0 || v == null ? null : v); // 0/absent traité comme « non renseigné » const dash = (v) => (v == null ? '—' : fmtEur(v)); const lowWin = (x, y) => (x == null || y == null) ? null : (x < y ? 'a' : y < x ? 'b' : null); const highWin = (x, y) => (x == null || y == null) ? null : (x > y ? 'a' : y > x ? 'b' : null); const rows = [ { k: 'Prix', a: fmtEur(a.price), b: fmtEur(b.price), win: lowWin(a.price || null, b.price || null) }, { k: 'Prix au m²', a: ppm(a) ? fmtEur2(ppm(a)) + '/m²' : '—', b: ppm(b) ? fmtEur2(ppm(b)) + '/m²' : '—', win: lowWin(nz(ppm(a)), nz(ppm(b))) }, { k: 'Surface', a: a.surface ? a.surface + ' m²' : '—', b: b.surface ? b.surface + ' m²' : '—', win: highWin(nz(a.surface), nz(b.surface)) }, { k: 'Pièces', a: a.rooms || '—', b: b.rooms || '—', win: highWin(nz(a.rooms), nz(b.rooms)) }, { k: 'DPE', a: a.dpe || '—', b: b.dpe || '—', win: (a.dpe && b.dpe) ? (dpeRank(a.dpe) < dpeRank(b.dpe) ? 'a' : dpeRank(b.dpe) < dpeRank(a.dpe) ? 'b' : null) : null }, { k: "Frais d'agence", a: dash(a.fees), b: dash(b.fees), win: lowWin(a.fees, b.fees) }, { k: 'Charges / mois', a: dash(a.charges), b: dash(b.charges), win: lowWin(a.charges, b.charges) }, { k: 'Budget travaux', a: dash(a.travaux), b: dash(b.travaux), win: lowWin(a.travaux, b.travaux) }, { k: 'Mensualité estimée', a: ma == null ? '—' : fmtEur(ma) + '/mois', b: mb == null ? '—' : fmtEur(mb) + '/mois', win: lowWin(ma, mb) }, { k: 'Note de visite', a: sa ? sa.avg.toFixed(1) + '/5' : '—', b: sb ? sb.avg.toFixed(1) + '/5' : '—', win: (sa && sb) ? (sa.avg > sb.avg ? 'a' : sb.avg > sa.avg ? 'b' : null) : null }, { k: 'Localisation', a: a.city || '—', b: b.city || '—', win: null }, { k: 'Statut', a: STATUS_LABELS[a.status] || '—', b: STATUS_LABELS[b.status] || '—', win: null }, ]; const scoreA = rows.filter((r) => r.win === 'a').length; const scoreB = rows.filter((r) => r.win === 'b').length; const overall = scoreA > scoreB ? 'a' : scoreB > scoreA ? 'b' : null; return (
{scoreA} vs {scoreB} critères
{overall === 'a' && Recommandé}
{fmtEur(a.price)}
{a.title}
{a.city}
{overall === 'b' && Recommandé}
{fmtEur(b.price)}
{b.title}
{b.city}
{rows.map((r, i) => (
{r.k}
{r.a}{r.win === 'a' && Mieux}
{r.b}{r.win === 'b' && Mieux}
))}
); } /* ---------- Carte des biens ---------- */ const GEO_CACHE_KEY = 'immobase_geocode_v1'; function Carte({ store, go }) { const props = store.state.properties.filter((p) => p.status !== 'elimine' && p.city); const mapEl = React.useRef(null); const mapObj = React.useRef(null); const layer = React.useRef(null); const [coords, setCoords] = React.useState({}); const [geocoding, setGeocoding] = React.useState(false); const geoKey = props.map((p) => p.id + ':' + p.city).join('|'); // Init Leaflet (une seule fois) React.useEffect(() => { if (!mapEl.current || mapObj.current || !window.L) return; const map = window.L.map(mapEl.current, { scrollWheelZoom: true }).setView([46.6, 2.4], 5); window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19, }).addTo(map); layer.current = window.L.layerGroup().addTo(map); mapObj.current = map; setTimeout(() => map.invalidateSize(), 150); return () => { map.remove(); mapObj.current = null; layer.current = null; }; }, []); // Géocodage des villes (Nominatim) avec cache localStorage React.useEffect(() => { let cancelled = false; let cache = {}; try { cache = JSON.parse(localStorage.getItem(GEO_CACHE_KEY) || '{}'); } catch (e) {} async function run() { const fromCache = {}; let needNetwork = false; props.forEach((p) => { const q = p.city.trim() + ', France'; if (cache[q]) fromCache[p.id] = cache[q]; else needNetwork = true; }); if (!cancelled) setCoords(fromCache); if (!needNetwork) return; if (!cancelled) setGeocoding(true); for (const p of props) { if (cancelled) break; const q = p.city.trim() + ', France'; if (cache[q]) continue; try { const res = await fetch('https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(q)); const data = await res.json(); if (data && data[0]) { cache[q] = { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }; try { localStorage.setItem(GEO_CACHE_KEY, JSON.stringify(cache)); } catch (e) {} if (!cancelled) setCoords((prev) => ({ ...prev, [p.id]: cache[q] })); } } catch (e) { /* ville introuvable : on ignore ce bien */ } await new Promise((r) => setTimeout(r, 1100)); // respecte la limite Nominatim (1 req/s) } if (!cancelled) setGeocoding(false); } run(); return () => { cancelled = true; }; }, [geoKey]); // (Re)dessine les marqueurs React.useEffect(() => { const map = mapObj.current, lg = layer.current; if (!map || !lg) return; lg.clearLayers(); const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const pts = []; props.forEach((p) => { const c = coords[p.id]; if (!c) return; const color = (typeof STATUS_DOT !== 'undefined' && STATUS_DOT[p.status]) || 'var(--sage)'; const icon = window.L.divIcon({ className: 'map-pin', html: '', iconSize: [22, 22], iconAnchor: [11, 11], }); const html = '' + esc(p.title) + '
' + fmtEur(p.price) + (p.surface ? ' · ' + p.surface + ' m²' : '') + '
' + (STATUS_LABELS[p.status] || '') + (p.city ? ' · ' + esc(p.city.split(',')[0]) : '') + ''; window.L.marker([c.lat, c.lng], { icon }).addTo(lg).bindPopup(html); pts.push([c.lat, c.lng]); }); if (pts.length) map.fitBounds(pts, { padding: [50, 50], maxZoom: 13 }); }, [coords, geoKey]); if (!window.L) { return (

Carte momentanément indisponible

Vérifiez votre connexion puis rechargez la page.

); } if (props.length === 0) { return (

Aucun bien à cartographier

Ajoutez des biens (avec une ville) dans Mes biens pour les voir sur la carte.

); } const located = props.filter((p) => coords[p.id]).length; return (
{located} / {props.length} bien{props.length > 1 ? 's' : ''} localisé{located > 1 ? 's' : ''} {geocoding && Localisation en cours…}

Localisation approximative basée sur la ville / le quartier saisi. Fonds de carte © OpenStreetMap.

); } /* ---------- Mon compte (s9q13) ---------- */ function Account({ store }) { const u = window.IMMOBASE_USER || {}; const [pwd, setPwd] = React.useState(''); const [pwd2, setPwd2] = React.useState(''); const [busy, setBusy] = React.useState(false); const pro = window.isPro(); async function changePassword() { if (pwd.length < 8) { toast('Le mot de passe doit contenir au moins 8 caractères'); return; } if (pwd !== pwd2) { toast('Les deux mots de passe ne correspondent pas'); return; } setBusy(true); const { error } = await window.sb.auth.updateUser({ password: pwd }); setBusy(false); if (error) { toast(window.frAuthError ? window.frAuthError(error.message) : 'Erreur'); return; } setPwd(''); setPwd2(''); toast('Mot de passe mis à jour ✓'); } function exportJson() { const data = { compte: { email: u.email || null, exporte_le: new Date().toISOString() }, biens: store.state.properties, checklist: store.state.checkedDocs, profil: store.state.profile, simulateur: store.state.sim, criteres_visite_perso: store.state.visiteCriteres, }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'immoly-mes-donnees.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast('Export JSON téléchargé ✓'); } const deleteHref = 'mailto:pierre.alexis@gmail.com?subject=' + encodeURIComponent('Suppression de mon compte Immoly') + '&body=' + encodeURIComponent('Bonjour,\n\nJe souhaite la suppression définitive de mon compte Immoly (' + (u.email || '') + ') et de toutes mes données.\n\nMerci de confirmer la prise en compte.\n'); return (

Identifiants

Votre plan : {pro ? 'Complet' : 'Découverte'}

Changer de mot de passe

setPwd(e.target.value)} placeholder="8 caractères minimum" />
setPwd2(e.target.value)} placeholder="Retapez le mot de passe" />

Vos données

Exportez l'ensemble de vos données (biens, checklist, simulateur) au format JSON. Gratuit.

Supprimer mon compte

La suppression se fait sur demande par email. Nous supprimons votre compte et vos données après confirmation, sous 48 h ouvrées.

Demander la suppression
); } Object.assign(window, { Checklist, Emails, Simulateur, Comparateur, Carte, PaywallGate, renderBody, Account });