/* 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 (
);
}
/* ---------- 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 }) => (
);
const Opt = ({ k, v, label, icon }) => (
set(k, v)}>
{icon && {icon} }{label}
);
return (
e.stopPropagation()}>
Votre profil d'achat
Personnalise votre checklist et vos emails automatiquement.
Annuler
Enregistrer mon profil
);
}
/* ---------- 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.
setShowProfile(true)}>
Configurer mon profil
)}
{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é'}
setShowProfile(true)}>Modifier
)}
Progression globale {done} / {documents.length} documents
{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 (
store.toggleDoc(d.id)}>
{d.name}
{d.note}
{hasMail && (
go('emails', d.id)}>
Demander
)}
{d.custom && (
store.removeCustomDoc(d.id)}>
)}
);
})}
);
})}
{/* Ligne personnalisée : l'utilisateur complète sa propre checklist (s10q08) */}
{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 (
setSel(d.id)}>
{d.name} {r.label}
);
})}
{properties.length > 0 && (
Bien concerné
setBienId(e.target.value)}>
{properties.map((p) => (
{p.favori ? '❤ ' : ''}{p.title}{p.city ? ' · ' + p.city.split(',')[0] : ''}
))}
)}
À {tpl.to}
Objet {tpl.subject}
{body}
Copier l'email
{doc && (
{ store.toggleDoc(doc.id); toast(doc.done ? 'Marqué à faire' : 'Marqué comme demandé ✓'); }}>
{doc.done ? 'Marquer à faire' : 'Marquer demandé'}
)}
);
}
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 (
{fmtEur(value)}
);
}
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
setBienType('ancien')}>Ancien ~7,5 %
setBienType('neuf')}>Neuf ~2,5 %
);
return (
setMode('mensualite')}>Estimer ma mensualité
setMode('capacite')}>Connaître mon budget
{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 (
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 mois net avant impôt
Crédits en cours
setCharges(Number(e.target.value))} />
0 € auto, conso… /mois
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.
go('tracker')}>Aller au Tracker
);
}
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 (
Bien A
setA(e.target.value)}>
{props.map((p) => {p.title} · {p.city.split(',')[0]} )}
Bien B
setB(e.target.value)}>
{props.map((p) => {p.title} · {p.city.split(',')[0]} )}
{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.
go('tracker')}>Aller à Mes biens
);
}
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 (
Vos données
Exportez l'ensemble de vos données (biens, checklist, simulateur) au format JSON. Gratuit.
Exporter mes données (JSON)
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 });