/* 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) => (
= v ? ' on' : '')}
onClick={() => setRating(c, v)} aria-label={c + ' : ' + v + '/5'}>
))}
);
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}
removeCustom(c)} title="Retirer ce critère (de tous les biens)">
{renderDots(c)}
))}
setNewCrit(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
placeholder="Ajouter un critère perso (box, fibre, exposition…)" />
Ajouter
Les critères perso sont communs à tous vos biens (réutilisés dans le comparateur).
Commentaire libre
Annuler
Enregistrer
);
}
/* ---------- 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}
))}
openModal(null)}> Ajoutez votre premier bien
{ store.addDemoProperty(); go('tracker'); }}>Voir un bien d'exemple
);
}
/* ---------- 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
Prochaines visites
go('tracker')}>Tous les biens
{upcoming.length === 0 ? (
Aucune visite programmée pour l'instant.
) : (
{upcoming.map((p) => (
go('tracker')}>
{p.title} · {p.city.split(',')[0]}
{fmtDate(p.visitDate)} · {fmtEur(p.price)}
{STATUS_LABELS[p.status]}
))}
)}
À faire ensuite
{window.isPro() && (
go('emails')}>
Demander 3 documents en attente Les emails sont déjà rédigés pour vous
)}
go('simulateur')}>
Vérifier votre mensualité Ajustez apport, durée et taux
{window.isPro() && (
go('comparateur')}>
Comparer vos deux favoris Tranchez sur les critères clés
)}
{!window.isPro() && (
Checklist, emails, comparateur Disponibles dans le plan Complet
)}
Vos documents
{window.isPro() && go('checklist')}>Voir tout }
{!window.isPro() ? (
La checklist de documents est disponible dans le plan Complet .
window.openUpgrade()}>Passer au 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.
window.openUpgrade()}>Passer au plan Complet
setWallDismissed(true)}>Plus tard
)}
setFilter('tous')}>Tous · {counts.tous}
{STATUS_ORDER.map((s) => (
setFilter(s)}>
{STATUS_LABELS[s]} · {counts[s]}
))}
{!limitReached && (
openModal(null)}>
Ajouter un bien
)}
{limitReached && (
window.openUpgrade()}>
Limite atteinte
)}
{filtered.length === 0 ? (
{properties.length === 0 ? (
<>
Votre projet commence ici
Ajoutez le premier bien que vous suivez — annonce, prix, notes. Cela prend 30 secondes.
openModal(null)}>
Ajoutez votre premier bien
>
) : (
<>
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 }
{ e.stopPropagation(); store.updateProperty(p.id, { favori: !isFav }); }}>
{(() => { 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()}>
setOpenStatus(openStatus === p.id ? null : p.id)}>
{STATUS_LABELS[p.status]}
{openStatus === p.id && (
{STATUS_ORDER.map((s) => (
{ store.updateProperty(p.id, { status: s }); setOpenStatus(null); }}>
{STATUS_LABELS[s]}
))}
)}
openModal(p)}>
{ if (p.demo || confirm('Supprimer ce bien ?')) { store.removeProperty(p.id); toast(p.demo ? "Bien d'exemple supprimé" : 'Bien supprimé'); } }}>
);
}
/* ---------- 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 && (
)}
{annonceUrl && (
e.stopPropagation()}>
Voir l'annonce
)}
{p.notes && (
Notes personnelles
{p.notes}
)}
{window.isPro() ? (
setShowGrille(true)}>
Note d'évaluation
{sc ? `Note ${sc.avg.toFixed(1)}/5 · ${sc.count} critère${sc.count > 1 ? 's' : ''}` : 'Pas encore évaluée'}
{sc && {sc.avg.toFixed(1)} }
) : (
window.openUpgrade()}>
Note d'évaluation
Évaluez vos biens (grille de visite) avec le plan Complet
)}
Supprimer
Modifier
{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'}
Annuler
{initial ? 'Enregistrer' : 'Ajouter le bien'}
);
}
Object.assign(window, { Dashboard, Tracker, PropertyCard, PropertyDetail, PropertyModal, toast, STATUS_DOT });