/* Immobase App — modules 1/2 : Dashboard, Tracker, PropertyModal */ function toast(msg) { const el = document.getElementById('toast'); if (!el) return; el.querySelector('.tmsg').textContent = msg; el.classList.add('show'); clearTimeout(window.__toastT); window.__toastT = setTimeout(() => el.classList.remove('show'), 2200); } const STATUS_DOT = { 'a-visiter': 'var(--sage)', 'visite': 'var(--blue)', 'offre-envoyee': 'var(--clay)', 'compromis-signe': 'var(--clay-deep)', 'financement-en-cours': 'var(--blue)', 'signe-acte': 'var(--sage-deep)', 'elimine': 'var(--muted)', }; /* ---------- Grille de visite ---------- */ const VISITE_CRITERES = [ 'Luminosité', 'État général', 'Agencement', 'Calme / voisinage', 'Vis-à-vis', 'Extérieur', 'Cuisine & salle de bain', 'Rangements', 'Quartier & commerces', 'Transports', ]; // Liste des critères actifs = critères par défaut + critères perso (globaux, dans le profil) function activeCriteres(store) { return VISITE_CRITERES.concat((store && store.state && store.state.visiteCriteres) || []); } function visiteScore(grille, valid) { const r = grille && grille.ratings ? grille.ratings : null; if (!r) return null; // moyenne des critères actifs notés (on ignore d'éventuelles notes orphelines) const keys = valid && valid.length ? valid : Object.keys(r); const vals = keys.map((k) => r[k]).filter((v) => typeof v === 'number' && v > 0); if (!vals.length) return null; const avg = vals.reduce((a, b) => a + b, 0) / vals.length; return { avg: Math.round(avg * 10) / 10, count: vals.length }; } function GrilleModal({ p, store, onClose }) { const customList = store.state.visiteCriteres || []; // liste globale (profil) const [grille, setGrille] = React.useState(() => ({ ratings: { ...((p.visiteGrille && p.visiteGrille.ratings) || {}) }, notes: (p.visiteGrille && p.visiteGrille.notes) || '', })); const [newCrit, setNewCrit] = React.useState(''); const setRating = (c, v) => setGrille((g) => ({ ...g, ratings: { ...g.ratings, [c]: g.ratings[c] === v ? 0 : v } })); const sc = visiteScore(grille, activeCriteres(store)); function addCustom() { const name = newCrit.trim(); if (!name) return; const exists = VISITE_CRITERES.concat(customList).some((c) => c.toLowerCase() === name.toLowerCase()); if (exists) { toast('Ce critère existe déjà'); return; } store.setVisiteCriteres([...customList, name]); // global → présent sur tous les biens setNewCrit(''); } function removeCustom(name) { store.setVisiteCriteres(customList.filter((c) => c !== name)); } function save() { store.updateProperty(p.id, { visiteGrille: grille }); toast('Grille de visite enregistrée ✓'); onClose(); } const renderDots = (c) => (
{[1, 2, 3, 4, 5].map((v) => ( ))}
); const modalRef = useModalA11y(onClose); return (
e.stopPropagation()}>

Grille de visite

{p.title}

{sc ? sc.avg.toFixed(1) : '—'}/5 {sc ? `moyenne sur ${sc.count} critère${sc.count > 1 ? 's' : ''}` : 'notez les critères ci-dessous'}
{VISITE_CRITERES.map((c) => (
{c} {renderDots(c)}
))} {customList.map((c) => (
{c} {renderDots(c)}
))}
setNewCrit(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }} placeholder="Ajouter un critère perso (box, fibre, exposition…)" />

Les critères perso sont communs à tous vos biens (réutilisés dans le comparateur).

); } /* ---------- Onboarding première session (s9q09 / s9q10 / s9q12) ---------- */ function WelcomeTour({ store, go, openModal }) { const steps = [ { n: '1', t: 'Suivez vos biens', d: 'Ajoutez chaque annonce qui vous intéresse : prix, surface, agent, notes. Fini le tableur.' }, { n: '2', t: 'Préparez vos documents', d: 'La checklist liste tout ce qu’il faut réunir, par interlocuteur, selon votre situation.' }, { n: '3', t: 'Comparez et décidez', d: 'Notez vos visites, mettez deux biens face à face et tranchez sereinement.' }, ]; return (

Bienvenue sur Immoly 👋

Votre visite guidée en trois étapes — vous êtes opérationnel en une minute.

{steps.map((s) => (
{s.n} {s.t} {s.d}
))}
); } /* ---------- Dashboard ---------- */ function Dashboard({ store, go, openModal }) { const { properties, documents } = store.state; const prenom = window.IMMOBASE_USER?.user_metadata?.prenom || window.IMMOBASE_USER?.user_metadata?.full_name?.split(' ')[0] || 'vous'; const active = properties.filter((p) => p.status !== 'elimine'); const favs = properties.filter((p) => p.favori); const doneDocs = documents.filter((d) => d.done).length; const pct = documents.length > 0 ? Math.round((doneDocs / documents.length) * 100) : 0; const upcoming = active .filter((p) => p.visitDate && new Date(p.visitDate + 'T00:00') >= new Date(new Date().toDateString())) .sort((a, b) => a.visitDate.localeCompare(b.visitDate)); const nextDocs = documents.filter((d) => !d.done).slice(0, 3); return (

Bonjour {prenom} 👋

Voici où en est votre projet aujourd'hui.

{properties.length === 0 && }
{active.length}
Biens suivis
{favs.length}
Coups de cœur
{upcoming.length}
Visites à venir
{pct}%
Documents prêts

Prochaines visites

{upcoming.length === 0 ? (

Aucune visite programmée pour l'instant.

) : (
{upcoming.map((p) => ( ))}
)}

À faire ensuite

{window.isPro() && ( )} {window.isPro() && ( )} {!window.isPro() && ( Checklist, emails, comparateurDisponibles dans le plan Complet )}

Vos documents

{window.isPro() && }
{!window.isPro() ? (

La checklist de documents est disponible dans le plan Complet.

) : ( <>
{pct}%
{doneDocs} sur {documents.length} prêts {documents.length - doneDocs} document{documents.length - doneDocs > 1 ? 's' : ''} encore à réunir
{nextDocs.map((d) => (
store.toggleDoc(d.id)} style={{ cursor: 'pointer' }}> {d.name}{d.note} {RECIPIENTS[d.recipient].label}
))} {nextDocs.length === 0 && (
Tous vos documents sont prêts !
)} )}
); } /* ---------- Tracker ---------- */ function Tracker({ store, openModal }) { const { properties } = store.state; const [filter, setFilter] = React.useState('tous'); const [openStatus, setOpenStatus] = React.useState(null); const [detail, setDetail] = React.useState(null); const [wallDismissed, setWallDismissed] = React.useState(false); // « Plus tard » (s5q13) const filtered = filter === 'tous' ? properties : properties.filter((p) => p.status === filter); const counts = { tous: properties.length, ...STATUS_ORDER.reduce((a, s) => ({ ...a, [s]: properties.filter((p) => p.status === s).length }), {}), }; // Le bien d'exemple ne compte pas dans la limite du plan gratuit (s9q10) const limitReached = !window.isPro() && properties.filter((p) => !p.demo).length >= window.FREE_PLAN_MAX_PROPERTIES; return (
{limitReached && !wallDismissed && (
Votre {window.FREE_PLAN_MAX_PROPERTIES + 1}e bien ne tient plus dans le plan Découverte. Passez au Complet pour suivre tous vos biens — et les comparer.
)}
{STATUS_ORDER.map((s) => ( ))}
{!limitReached && ( )} {limitReached && ( )}
{filtered.length === 0 ? (
{properties.length === 0 ? ( <>

Votre projet commence ici

Ajoutez le premier bien que vous suivez — annonce, prix, notes. Cela prend 30 secondes.

) : ( <>

Aucun bien dans cette catégorie

Changez de filtre ou ajoutez un nouveau bien depuis le bouton « Ajouter un bien ».

)}
) : (
{filtered.map((p) => ( setDetail(p)} openStatus={openStatus} setOpenStatus={setOpenStatus} /> ))}
)} {detail && ( setDetail(null)} onEdit={() => { openModal(detail); setDetail(null); }} /> )}
); } function PropertyCard({ p, store, openModal, openDetail, openStatus, setOpenStatus }) { const ppm = p.surface ? Math.round(p.price / p.surface) : 0; const isFav = !!p.favori; const initials = (p.agentName || '').split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase(); return (
{p.photoUrl && ( { e.target.style.display = 'none'; e.target.nextSibling.style.display = 'flex'; }} /> )} photo · annonce {p.demo && Exemple} {(() => { const vs = visiteScore(p.visiteGrille, activeCriteres(store)); return vs ? ★ {vs.avg.toFixed(1)} : null; })()}
{fmtEur(p.price)} {ppm > 0 && {fmtEur2(ppm)}/m²}
{p.title} · {p.type}
{p.city}
{p.surface > 0 && {p.surface} m²} {p.rooms > 0 && {p.rooms} pièces} {p.visitDate && Visite {fmtDate(p.visitDate)}}
{p.agentName && (
{initials} {p.agentName}{p.agentAgency}
)}
e.stopPropagation()}>
{openStatus === p.id && (
{STATUS_ORDER.map((s) => ( ))}
)}
); } /* ---------- Property detail ---------- */ function PropertyDetail({ p, store, onClose, onEdit }) { const live = store.state.properties.find((x) => x.id === p.id) || p; const ppm = p.surface ? Math.round(p.price / p.surface) : 0; const annonceUrl = p.url ? (p.url.startsWith('http') ? p.url : 'https://' + p.url) : null; const initials = (p.agentName || '').split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase(); const [showGrille, setShowGrille] = React.useState(false); const sc = visiteScore(live.visiteGrille, activeCriteres(store)); function handleDelete() { // Le bien d'exemple se supprime en un clic, sans confirmation (s9q10) if (p.demo || confirm('Supprimer ce bien ?')) { store.removeProperty(p.id); toast(p.demo ? "Bien d'exemple supprimé" : 'Bien supprimé'); onClose(); } } const modalRef = useModalA11y(onClose); return ( <>
e.stopPropagation()}>

{p.title}

{STATUS_LABELS[p.status]} {p.type && {p.type}}
{p.photoUrl && (
)}
{fmtEur(p.price)} {ppm > 0 && {fmtEur2(ppm)}/m²}
{p.city && {p.city}} {p.surface > 0 && {p.surface} m²} {p.rooms > 0 && {p.rooms} pièces} {p.floor > 0 && Étage {p.floor}}
{p.visitDate && (
Visite prévue le {fmtDate(p.visitDate)}
)} {p.agentName && (
{initials}
{p.agentName} {p.agentAgency && {p.agentAgency}} {p.agentPhone && ( e.stopPropagation()}> {p.agentPhone} )}
)} {annonceUrl && ( e.stopPropagation()}> Voir l'annonce )} {p.notes && (
Notes personnelles

{p.notes}

)} {window.isPro() ? ( ) : ( )}
{showGrille && window.isPro() && setShowGrille(false)} />} ); } /* ---------- Property modal (add / edit) ---------- */ function PropertyModal({ initial, store, onClose }) { const blank = { title: '', type: 'Appartement', city: '', price: '', surface: '', rooms: '', status: 'a-visiter', favori: false, visitDate: '', agentName: '', agentAgency: '', agentPhone: '', notes: '', url: '', photoUrl: '', photoPath: '', fees: '', charges: '', dpe: '', travaux: '' }; const [f, setF] = React.useState(initial ? { ...initial } : blank); const [uploadingPhoto, setUploadingPhoto] = React.useState(false); const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value })); async function handlePhotoUpload(e) { const file = e.target.files?.[0]; if (!file) return; setUploadingPhoto(true); try { const userId = window.IMMOBASE_USER?.id; if (!userId) throw new Error('Session expirée — reconnectez-vous'); // Bucket PRIVÉ (P0-08) : chemin userId/uuid.ext + URL signée (jamais publique) const ext = (file.name.split('.').pop() || 'jpg').toLowerCase(); const uuid = (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2)); const path = `${userId}/${uuid}.${ext}`; const { error } = await window.sb.storage .from('property-photos') .upload(path, file, { upsert: false }); if (error) throw error; const { data, error: signErr } = await window.sb.storage .from('property-photos') .createSignedUrl(path, 60 * 60 * 24); if (signErr) throw signErr; setF((prev) => ({ ...prev, photoPath: path, photoUrl: data.signedUrl })); toast('Photo ajoutée ✓'); } catch (err) { toast('Erreur upload : ' + err.message); } finally { setUploadingPhoto(false); } } function save() { if (!f.title.trim()) { toast('Donnez un titre au bien'); return; } // Frais / charges / travaux : saisis par l'utilisateur. Vide = null (« — », // exclu du verdict du comparateur). Plus AUCUNE valeur inventée (P0-15). const data = { ...f, price: Number(f.price) || 0, surface: Number(f.surface) || 0, rooms: Number(f.rooms) || 0, fees: f.fees === '' || f.fees == null ? null : Number(f.fees), charges: f.charges === '' || f.charges == null ? null : Number(f.charges), travaux: f.travaux === '' || f.travaux == null ? null : Number(f.travaux), dpe: f.dpe || '', favori: !!f.favori, }; if (initial) { store.updateProperty(initial.id, data); toast('Bien mis à jour ✓'); } else { store.addProperty(data); toast('Bien ajouté ✓'); } onClose(); } const modalRef = useModalA11y(onClose); return (
e.stopPropagation()}>

{initial ? 'Modifier le bien' : 'Ajouter un bien'}

{f.photoUrl && (
)} {!f.photoUrl && ( )}
); } Object.assign(window, { Dashboard, Tracker, PropertyCard, PropertyDetail, PropertyModal, toast, STATUS_DOT });