/* Immobase App — store (Supabase + profil situationnel) */ /* localStorage namespacé par utilisateur (P0-09) : chaque compte a sa propre * clé, jamais partagée. La clé « anon » ne sert qu'avant résolution de session * et n'est jamais synchronisée en base. */ const IMMO_PREFIX = 'immobase_state_v2_'; const IMMO_LEGACY_KEYS = ['immobase_state_v2', 'immobase_geocode_v1']; const stateKeyFor = (uid) => IMMO_PREFIX + (uid || 'anon'); /* Purge TOTALE des données locales Immoly (appelée au signOut, P0-09). */ function purgeImmobaseLocal() { try { const toRemove = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && (k.startsWith('immobase_') || k.startsWith(IMMO_PREFIX))) toRemove.push(k); } toRemove.forEach((k) => localStorage.removeItem(k)); } catch (e) {} } /* Statuts du pipeline d'achat (s4q02). « Coup de cœur » n'est PLUS un statut : * c'est un tag distinct « favori » (s10q10). */ const STATUS_LABELS = { 'a-visiter': 'À visiter', 'visite': 'Visité', 'offre-envoyee': 'Offre envoyée', 'compromis-signe': 'Compromis signé', 'financement-en-cours': 'Financement en cours', 'signe-acte': 'Signé / acte authentique', 'elimine': 'Éliminé', }; const STATUS_ORDER = ['a-visiter', 'visite', 'offre-envoyee', 'compromis-signe', 'financement-en-cours', 'signe-acte', 'elimine']; /* Normalise un statut hérité (ancien « coup-de-coeur ») vers le nouveau modèle. */ const normalizeStatus = (s) => (s === 'coup-de-coeur' ? 'a-visiter' : (STATUS_LABELS[s] ? s : 'a-visiter')); /* ── Liste complète des documents avec conditions ─────────── */ /* name = intitulé du document (gras) · note = précision (sous-titre) */ const ALL_DOCS = [ // ── Notaire ────────────────────────────────────────────── { id: 'piece_identite', recipient: 'notaire', name: "Pièce d'identité", note: "carte d'identité ou passeport en cours de validité", conditions: [] }, { id: 'compromis', recipient: 'notaire', name: 'Compromis de vente', note: 'avant-contrat signé entre acheteur et vendeur', conditions: [] }, { id: 'titre_propriete', recipient: 'notaire', name: 'Titre de propriété du vendeur', note: "acte de propriété actuel du bien", conditions: [] }, { id: 'etat_hypothecaire', recipient: 'notaire', name: 'État hypothécaire', note: "vérifie l'absence de dettes grevant le bien", conditions: [] }, { id: 'acte_authentique', recipient: 'notaire', name: 'Acte authentique de vente', note: 'signature définitive chez le notaire', conditions: [] }, { id: 'reglement_copro', recipient: 'notaire', name: 'Règlement de copropriété', note: 'règles de vie et répartition des charges', conditions: ['appartement'] }, { id: 'pv_ag', recipient: 'notaire', name: 'PV des 3 dernières AG', note: 'décisions et travaux votés en copropriété', conditions: ['appartement'] }, { id: 'pre_etat_date', recipient: 'notaire', name: 'Pré-état daté', note: 'situation financière du lot en copropriété', conditions: ['appartement'] }, { id: 'carnet_entretien', recipient: 'notaire', name: "Carnet d'entretien de l'immeuble", note: 'historique des travaux de la copropriété', conditions: ['appartement'] }, // ── Diagnostics (DDT — fournis par le vendeur) ─────────── { id: 'dpe', recipient: 'diagnostics', name: 'DPE', note: 'diagnostic de performance énergétique', conditions: [] }, { id: 'erp', recipient: 'diagnostics', name: 'État des risques (ERP)', note: 'risques naturels, miniers et technologiques', conditions: [] }, { id: 'amiante', recipient: 'diagnostics', name: 'Diagnostic amiante', note: 'pour les biens construits avant 1997', conditions: [] }, { id: 'plomb', recipient: 'diagnostics', name: 'CREP (plomb)', note: 'pour les biens construits avant 1949', conditions: [] }, { id: 'electricite', recipient: 'diagnostics', name: 'Diagnostic électricité', note: 'installation de plus de 15 ans', conditions: [] }, { id: 'gaz', recipient: 'diagnostics', name: 'Diagnostic gaz', note: 'installation de plus de 15 ans', conditions: [] }, { id: 'termites', recipient: 'diagnostics', name: 'Diagnostic termites', note: 'si le bien est situé en zone concernée', conditions: [] }, { id: 'carrez', recipient: 'diagnostics', name: 'Mesurage Loi Carrez', note: 'surface privative exacte du lot', conditions: ['appartement'] }, { id: 'assainissement', recipient: 'diagnostics', name: 'Diagnostic assainissement', note: 'maison non raccordée au tout-à-l\'égout', conditions: ['maison'] }, // ── Banque (financement) ───────────────────────────────── { id: 'avis_imposition', recipient: 'banque', name: "Avis d'imposition", note: 'vos deux derniers avis', conditions: ['credit'] }, { id: 'releves_bancaires', recipient: 'banque', name: 'Relevés bancaires (3 mois)', note: 'vos trois derniers relevés', conditions: ['credit'] }, { id: 'justif_apport', recipient: 'banque', name: "Justificatif d'apport", note: 'épargne, donation, prêt familial…', conditions: ['credit'] }, { id: 'bulletins_salaire', recipient: 'banque', name: 'Bulletins de salaire (3 mois)', note: 'vos trois derniers bulletins', conditions: ['credit', 'salarie'] }, { id: 'contrat_travail', recipient: 'banque', name: 'Contrat de travail', note: 'ou attestation employeur', conditions: ['credit', 'salarie'] }, { id: 'bilans', recipient: 'banque', name: 'Bilans comptables (2-3 ans)', note: 'pour les indépendants et dirigeants', conditions: ['credit', 'independant'] }, { id: 'kbis', recipient: 'banque', name: 'Extrait Kbis', note: "justificatif d'immatriculation de l'entreprise", conditions: ['credit', 'independant'] }, { id: 'justif_pension', recipient: 'banque', name: 'Justificatifs de pension', note: 'relevés de retraite', conditions: ['credit', 'retraite'] }, { id: 'offre_pret', recipient: 'banque', name: 'Offre de prêt', note: 'émise par la banque après accord', conditions: ['credit'] }, { id: 'tableau_amort', recipient: 'banque', name: "Tableau d'amortissement", note: 'échéancier détaillé du crédit', conditions: ['credit'] }, // ── Assurance ──────────────────────────────────────────── { id: 'assurance_hab', recipient: 'assurance', name: "Attestation d'assurance habitation", note: 'obligatoire le jour de la signature', conditions: [] }, { id: 'assurance_empr', recipient: 'assurance', name: 'Assurance emprunteur', note: 'couvre le remboursement du prêt', conditions: ['credit'] }, ]; /* Filtre les docs selon le profil de l'utilisateur */ function matchesProfile(doc, profile) { if (!doc.conditions || doc.conditions.length === 0) return true; if (!profile || (!profile.type_bien && !profile.financement && !profile.statut_emploi)) return true; // Pas encore de profil = tout afficher return doc.conditions.every(cond => { switch (cond) { case 'appartement': return profile.type_bien === 'appartement'; case 'maison': return profile.type_bien === 'maison'; case 'credit': return profile.financement === 'credit'; case 'cash': return profile.financement === 'cash'; case 'salarie': return profile.statut_emploi === 'salarie'; case 'independant': return profile.statut_emploi === 'independant'; case 'retraite': return profile.statut_emploi === 'retraite'; default: return true; } }); } function computeDocuments(profile, checkedDocs, customDocs) { const base = ALL_DOCS .filter(doc => matchesProfile(doc, profile)) .map(doc => ({ ...doc, done: checkedDocs?.[doc.id] || false })); // Lignes personnalisées ajoutées par l'utilisateur (s10q08) const custom = (customDocs || []).map((d) => ({ id: d.id, recipient: 'perso', name: d.name, note: 'ajouté par vous', conditions: [], custom: true, done: checkedDocs?.[d.id] || false, })); return base.concat(custom); } const EMPTY_PROFILE = { type_bien: null, financement: null, statut_emploi: null, }; function emptyState() { const profile = { ...EMPTY_PROFILE }; return { properties: [], checkedDocs: {}, profile, documents: computeDocuments(profile, {}, []), sim: { price: 300000, apport: 60000, duree: 20, taux: 3.4 }, visiteCriteres: [], customDocs: [], }; } /* Bien d'exemple réaliste (s9q10) : clairement marqué, jamais synchronisé en * base, supprimable en un clic. */ function demoProperty() { return { id: 'demo-exemple', demo: true, title: 'Exemple — Maison lumineuse', type: 'Maison', city: 'Nantes, Loire-Atlantique', price: 298000, surface: 110, rooms: 5, floor: 0, status: 'a-visiter', favori: false, visitDate: '', agentName: 'Camille Durand', agentAgency: 'Agence du Port', agentPhone: '', notes: "Bien d'exemple pour découvrir Immoly : ouvrez la fiche, changez le statut, testez la grille de visite… puis supprimez-le en un clic.", url: '', photoUrl: '', fees: 8900, charges: null, dpe: 'C', travaux: 15000, description: '', visiteGrille: null, }; } /* ── Conversion biens ────────────────────────────────────── */ function dbToProperty(row) { return { id: row.id, title: row.titre || '', type: row.type_bien || 'Appartement', city: row.localisation || '', price: row.prix || 0, surface: row.surface || 0, rooms: row.pieces || 0, floor: row.etage || 0, status: normalizeStatus(row.statut || 'a-visiter'), favori: (row.favori ?? (row.statut === 'coup-de-coeur')) || false, visitDate: row.date_visite || '', agentName: row.nom_agent || '', agentAgency: row.nom_agence || '', agentPhone: row.telephone_agent || '', notes: row.notes || '', url: row.url_source || '', // photo_principale : soit une URL http (image externe), soit un chemin // « userId/uuid.ext » dans le bucket privé (résolu en URL signée, P0-08) photoPath: (row.photo_principale && !/^https?:/i.test(row.photo_principale)) ? row.photo_principale : '', photoUrl: (row.photo_principale && /^https?:/i.test(row.photo_principale)) ? row.photo_principale : '', fees: row.frais_agence != null ? row.frais_agence : null, charges: row.charges_mensuelles != null ? row.charges_mensuelles : null, dpe: row.dpe || '', travaux: row.budget_travaux != null ? row.budget_travaux : null, description: row.description || '', visiteGrille: row.visite_grille || null, }; } /* Convertit une valeur « frais/charges/travaux » saisie en entier ou null. * Chaîne vide / non renseigné → null (affiché « — », exclu du verdict, s10q01). */ function numOrNull(v) { if (v === '' || v === null || v === undefined) return null; const n = Number(v); return Number.isFinite(n) ? n : null; } function propertyToDb(p, userId) { return { user_id: userId, url_source: p.url || null, titre: p.title, type_bien: p.type || 'Appartement', prix: Number(p.price) || 0, surface: Number(p.surface) || 0, pieces: Number(p.rooms) || 0, etage: Number(p.floor) || 0, localisation: p.city || null, pays: 'FR', description: p.description || null, photo_principale: p.photoPath || p.photoUrl || null, statut: p.status || 'a-visiter', date_visite: p.visitDate || null, nom_agent: p.agentName || null, nom_agence: p.agentAgency || null, telephone_agent: p.agentPhone || null, notes: p.notes || null, frais_agence: numOrNull(p.fees), charges_mensuelles: numOrNull(p.charges), dpe: p.dpe || null, budget_travaux: numOrNull(p.travaux), favori: !!p.favori, visite_grille: p.visiteGrille || null, }; } const RECIPIENTS = { notaire: { label: 'Notaire', color: 'sage' }, diagnostics: { label: 'Diagnostics', color: 'blue' }, banque: { label: 'Banque', color: 'clay' }, assurance: { label: 'Assurance', color: 'sage' }, perso: { label: 'Vos ajouts', color: 'clay' }, }; const EMAIL_TEMPLATES = { compromis: { to: 'contact@agence.fr', subject: 'Compromis de vente — {{bien}}', body: "Bonjour,\n\nSuite à notre visite du bien situé {{bien}}, je souhaite avancer vers la signature du compromis de vente.\n\nPourriez-vous me transmettre le projet de compromis et m'indiquer les prochaines étapes ainsi que le montant du dépôt de garantie ?\n\nJe reste à votre disposition.\nBien cordialement,\n{{nom}}" }, reglement_copro: { to: 'syndic@cabinet.fr', subject: 'Demande de règlement de copropriété — {{bien}}', body: "Bonjour,\n\nDans le cadre de l'achat du bien situé {{bien}}, j'aurais besoin du règlement de copropriété à jour.\n\nPourriez-vous me le transmettre ?\n\nJe vous remercie.\nBien cordialement,\n{{nom}}" }, pv_ag: { to: 'syndic@cabinet.fr', subject: "Demande des PV d'assemblée générale — {{bien}}", body: "Bonjour,\n\nPour finaliser mon projet d'achat ({{bien}}), je souhaiterais consulter les procès-verbaux des trois dernières assemblées générales de copropriété.\n\nPourriez-vous me les faire parvenir ?\n\nMerci d'avance.\nBien cordialement,\n{{nom}}" }, pre_etat_date: { to: 'syndic@cabinet.fr', subject: 'Demande de pré-état daté — {{bien}}', body: "Bonjour,\n\nDans le cadre de la vente du bien situé {{bien}}, le notaire a besoin du pré-état daté.\n\nPourriez-vous l'établir et m'indiquer le délai et le coût ?\n\nJe vous remercie.\nBien cordialement,\n{{nom}}" }, dpe: { to: 'contact@agence.fr', subject: 'Diagnostics techniques (DDT) — {{bien}}', body: "Bonjour,\n\nConcernant le bien situé {{bien}}, pourriez-vous me transmettre le dossier de diagnostics techniques (DPE, état des risques, et le cas échéant amiante, plomb, électricité, gaz…) ?\n\nJe vous en remercie.\nBien cordialement,\n{{nom}}" }, avis_imposition: { to: 'conseiller@mabanque.fr', subject: 'Justificatifs pour mon dossier de prêt', body: "Bonjour,\n\nDans le cadre de mon projet d'achat immobilier ({{bien}}), je prépare mon dossier de financement.\n\nPourriez-vous m'indiquer la liste exacte des justificatifs à fournir pour étudier ma demande de prêt ?\n\nJe vous remercie.\nBien cordialement,\n{{nom}}" }, releves_bancaires: { to: 'conseiller@mabanque.fr', subject: 'Relevés bancaires pour dossier immobilier', body: "Bonjour,\n\nPour mon dossier de financement ({{bien}}), j'aurais besoin de mes trois derniers relevés bancaires.\n\nPourriez-vous me les transmettre ou m'indiquer comment les obtenir au format requis ?\n\nMerci d'avance.\nBien cordialement,\n{{nom}}" }, bulletins_salaire: { to: 'rh@employeur.fr', subject: 'Demande de bulletins de salaire', body: "Bonjour,\n\nDans le cadre d'un dossier de prêt immobilier ({{bien}}), j'aurais besoin de mes trois derniers bulletins de salaire.\n\nPourriez-vous me les transmettre ?\n\nMerci beaucoup.\nBien cordialement,\n{{nom}}" }, offre_pret: { to: 'conseiller@mabanque.fr', subject: 'Suivi de mon offre de prêt — {{bien}}', body: "Bonjour,\n\nConcernant le financement de mon achat ({{bien}}), pourriez-vous me préciser où en est l'édition de l'offre de prêt et le délai de réception ?\n\nJe vous remercie.\nBien cordialement,\n{{nom}}" }, assurance_hab: { to: 'contact@assureur.fr', subject: "Attestation d'assurance habitation — {{bien}}", body: "Bonjour,\n\nJe finalise l'achat du bien situé {{bien}} et le notaire me demande une attestation d'assurance habitation pour le jour de la signature.\n\nPourriez-vous me l'établir ?\n\nJe vous remercie.\nBien cordialement,\n{{nom}}" }, assurance_empr: { to: 'contact@assureur.fr', subject: "Devis d'assurance emprunteur — {{bien}}", body: "Bonjour,\n\nDans le cadre de mon prêt immobilier ({{bien}}), je souhaiterais obtenir un devis d'assurance emprunteur.\n\nPourriez-vous m'indiquer les garanties proposées et le tarif ?\n\nJe vous remercie.\nBien cordialement,\n{{nom}}" }, }; /* ─── Hook principal ──────────────────────────────────────── */ function useStore() { const sb = window.sb; /* userId en state React pour réagir à la résolution async de l'auth */ const [userId, setUserId] = React.useState(window.IMMOBASE_USER?.id || null); React.useEffect(() => { const { data: { subscription } } = sb.auth.onAuthStateChange((_event, session) => { const uid = session?.user?.id || null; if (session?.user) window.IMMOBASE_USER = session.user; setUserId(uid); }); return () => subscription.unsubscribe(); }, []); const [state, setState] = React.useState(emptyState); /* Nettoyage unique des anciennes clés globales non namespacées (pré-P0-09). */ React.useEffect(() => { try { IMMO_LEGACY_KEYS.forEach((k) => localStorage.removeItem(k)); } catch (e) {} }, []); /* Chargement du cache local NAMESPACÉ quand l'utilisateur est résolu. * Aucune donnée n'est lue avant de connaître le userId → pas de fuite * entre comptes sur le même navigateur (P0-09). */ React.useEffect(() => { if (!userId) return; try { const raw = localStorage.getItem(stateKeyFor(userId)); if (raw) { const parsed = JSON.parse(raw); if (!parsed.properties?.some((p) => p.id === 'p1')) { setState((s) => ({ ...emptyState(), ...parsed })); } } } catch (e) {} }, [userId]); /* Cache localStorage — écrit UNIQUEMENT sous la clé de l'utilisateur courant. */ React.useEffect(() => { if (!userId) return; try { localStorage.setItem(stateKeyFor(userId), JSON.stringify(state)); } catch (e) {} }, [state, userId]); /* Bucket privé (P0-08) : résout les chemins « userId/uuid.ext » en URLs * signées (24 h) pour l'affichage. Relancé à chaque chargement de biens. */ async function resolveSignedPhotos(props) { const paths = (props || []).filter((p) => p.photoPath).map((p) => p.photoPath); if (!paths.length || !sb) return; try { const { data, error } = await sb.storage.from('property-photos').createSignedUrls(paths, 60 * 60 * 24); if (error || !data) return; const map = {}; data.forEach((d) => { if (d.signedUrl && d.path) map[d.path] = d.signedUrl; }); setState((s) => ({ ...s, properties: s.properties.map((p) => (p.photoPath && map[p.photoPath]) ? { ...p, photoUrl: map[p.photoPath] } : p), })); } catch (e) { /* photo indisponible : la fiche reste utilisable */ } } /* Chargement initial depuis Supabase */ React.useEffect(() => { if (!userId || !sb) return; Promise.all([ sb.from('biens').select('*').eq('user_id', userId).order('created_at', { ascending: false }), sb.from('checklist_documents').select('local_id, est_coche').eq('user_id', userId), sb.from('user_settings').select('*').eq('user_id', userId).maybeSingle(), ]).then(([biensRes, docsRes, simRes]) => { const updates = {}; if (biensRes.error) console.error('[immobase] lecture biens', biensRes.error); if (docsRes.error) console.error('[immobase] lecture documents', docsRes.error); if (simRes.error && simRes.error.code !== 'PGRST116') console.error('[immobase] lecture user_settings', simRes.error); const checkedDocs = {}; if (!docsRes.error && docsRes.data) { docsRes.data.forEach(d => { if (d.local_id) checkedDocs[d.local_id] = d.est_coche; }); } updates.checkedDocs = checkedDocs; const profile = { ...EMPTY_PROFILE }; if (!simRes.error && simRes.data) { profile.type_bien = simRes.data.type_bien ?? null; profile.financement = simRes.data.financement ?? null; profile.statut_emploi = simRes.data.statut_emploi ?? null; updates.visiteCriteres = simRes.data.visite_criteres_perso || []; updates.customDocs = simRes.data.checklist_custom || []; updates.sim = { price: simRes.data.sim_price ?? 300000, apport: simRes.data.sim_apport ?? 60000, duree: simRes.data.sim_duree ?? 20, taux: Number(simRes.data.sim_taux ?? 3.4), }; } updates.profile = profile; // Biens locaux non encore synchronisés (id temporaire 'p…') — à préserver / resync let toSync = []; setState(s => { const merged = { ...s, ...updates }; if (!biensRes.error && Array.isArray(biensRes.data)) { const dbProps = biensRes.data.map(dbToProperty); const dbIds = new Set(dbProps.map(p => p.id)); const localOnly = (s.properties || []).filter( p => typeof p.id === 'string' && (p.id.startsWith('p') || p.demo) && !dbIds.has(p.id) ); toSync = localOnly.filter(p => !p.demo); // le bien d'exemple ne va JAMAIS en base merged.properties = [...localOnly, ...dbProps]; // ne JAMAIS écraser les biens locaux } merged.documents = computeDocuments(merged.profile, merged.checkedDocs, merged.customDocs); return merged; }); // Photos du bucket privé → URLs signées pour l'affichage (P0-08) if (!biensRes.error && Array.isArray(biensRes.data)) { resolveSignedPhotos(biensRes.data.map(dbToProperty)); } // Tente de pousser en base les biens restés en local (réussit une fois le schéma corrigé) if (userId && sb && toSync.length) { toSync.forEach(async (p) => { const { data, error } = await sb.from('biens').insert(propertyToDb(p, userId)).select().single(); if (!error && data) { setState(s => ({ ...s, properties: s.properties.map(x => x.id === p.id ? dbToProperty(data) : x) })); } }); } }); }, [userId]); const api = React.useMemo(() => ({ state, /* Ajoute le bien d'exemple (local uniquement, jamais en base — s9q10). */ addDemoProperty() { if (state.properties.some((x) => x.demo)) { toast("Le bien d'exemple est déjà dans votre liste"); return; } setState(s => ({ ...s, properties: [demoProperty(), ...s.properties] })); toast("Bien d'exemple ajouté — supprimez-le quand vous voulez"); }, async addProperty(p) { // Le bien d'exemple ne compte pas dans la limite du plan gratuit. const countable = state.properties.filter((x) => !x.demo).length; if (!window.isPro() && countable >= window.FREE_PLAN_MAX_PROPERTIES) { if (window.openLimitWall) window.openLimitWall(); // mur des biens (s5q13) else toast(`Limite de ${window.FREE_PLAN_MAX_PROPERTIES} biens atteinte — passez au plan Complet`); return; } if (userId && sb) { const { data, error } = await sb.from('biens').insert(propertyToDb(p, userId)).select().single(); if (!error && data) { window.track?.('property_added', { count_after: countable + 1 }); const np = dbToProperty(data); // conserve l'aperçu signé déjà obtenu au moment de l'upload if (!np.photoUrl && p.photoUrl) np.photoUrl = p.photoUrl; setState(s => ({ ...s, properties: [np, ...s.properties] })); return; } if (error) { console.error('[immobase] insertion bien', error); toast('⚠ Sauvegarde en ligne impossible — bien gardé sur cet appareil seulement'); } } setState(s => ({ ...s, properties: [{ ...p, id: 'p' + Date.now() }, ...s.properties] })); }, async updateProperty(id, patch) { const current = state.properties.find(p => p.id === id); const merged = { ...current, ...patch }; setState(s => ({ ...s, properties: s.properties.map(p => p.id === id ? merged : p) })); // id 'p…' = bien non synchronisé ; 'demo…' = bien d'exemple : jamais en base if (userId && sb && current && !String(id).startsWith('p') && !String(id).startsWith('demo')) { const { error } = await sb.from('biens').update(propertyToDb(merged, userId)).eq('id', id); if (error) console.error('[immobase] maj bien', error); } }, async removeProperty(id) { const prop = state.properties.find((p) => p.id === id); setState(s => ({ ...s, properties: s.properties.filter(p => p.id !== id) })); if (userId && sb && !String(id).startsWith('p') && !String(id).startsWith('demo')) { await sb.from('biens').delete().eq('id', id); // Suppression de la photo liée au bien dans le bucket privé (P0-08) if (prop?.photoPath) { try { await sb.storage.from('property-photos').remove([prop.photoPath]); } catch (e) {} } } }, async toggleDoc(id) { const newVal = !state.checkedDocs[id]; const newChecked = { ...state.checkedDocs, [id]: newVal }; setState(s => ({ ...s, checkedDocs: newChecked, documents: computeDocuments(s.profile, newChecked, s.customDocs), })); if (userId && sb) { const doc = ALL_DOCS.find(d => d.id === id) || (state.customDocs || []).find(d => d.id === id); await sb.from('checklist_documents').upsert({ user_id: userId, local_id: id, est_coche: newVal, nom_francais: doc?.name || id, destinataire: doc?.recipient || 'perso', pays: 'FR', }, { onConflict: 'user_id,local_id' }); } }, /* Lignes de checklist personnalisées (s10q08). */ async addCustomDoc(name) { const clean = (name || '').trim(); if (!clean) return; const exists = (state.customDocs || []).some((d) => d.name.toLowerCase() === clean.toLowerCase()); if (exists) { toast('Cette ligne existe déjà'); return; } const list = [...(state.customDocs || []), { id: 'custom_' + Date.now(), name: clean }]; setState(s => ({ ...s, customDocs: list, documents: computeDocuments(s.profile, s.checkedDocs, list) })); if (userId && sb) { const { error } = await sb.from('user_settings').upsert({ user_id: userId, checklist_custom: list, updated_at: new Date().toISOString(), }, { onConflict: 'user_id' }); if (error) console.error('[immobase] maj checklist perso', error); } }, async removeCustomDoc(id) { const list = (state.customDocs || []).filter((d) => d.id !== id); setState(s => ({ ...s, customDocs: list, documents: computeDocuments(s.profile, s.checkedDocs, list) })); if (userId && sb) { await sb.from('user_settings').upsert({ user_id: userId, checklist_custom: list, updated_at: new Date().toISOString(), }, { onConflict: 'user_id' }); await sb.from('checklist_documents').delete().eq('user_id', userId).eq('local_id', id); } }, async setProfile(patch) { const newProfile = { ...state.profile, ...patch }; setState(s => ({ ...s, profile: newProfile, documents: computeDocuments(newProfile, s.checkedDocs, s.customDocs), })); if (userId && sb) { await sb.from('user_settings').upsert({ user_id: userId, type_bien: newProfile.type_bien, financement: newProfile.financement, statut_emploi: newProfile.statut_emploi, updated_at: new Date().toISOString(), }, { onConflict: 'user_id' }); } }, async setSim(patch) { const newSim = { ...state.sim, ...patch }; setState(s => ({ ...s, sim: newSim })); if (userId && sb) { await sb.from('user_settings').upsert({ user_id: userId, sim_price: newSim.price, sim_apport: newSim.apport, sim_duree: newSim.duree, sim_taux: newSim.taux, updated_at: new Date().toISOString(), }, { onConflict: 'user_id' }); } }, async setVisiteCriteres(list) { setState(s => ({ ...s, visiteCriteres: list })); if (userId && sb) { const { error } = await sb.from('user_settings').upsert({ user_id: userId, visite_criteres_perso: list, updated_at: new Date().toISOString(), }, { onConflict: 'user_id' }); if (error) console.error('[immobase] maj critères perso', error); } }, }), [state, userId]); return api; } function computeLoan({ price, apport, duree, taux }) { const principal = Math.max(0, price - apport); const n = duree * 12; const r = taux / 100 / 12; let monthly; if (r === 0) monthly = principal / n; else monthly = (principal * r) / (1 - Math.pow(1 + r, -n)); return { principal, monthly, total: monthly * n, interest: monthly * n - principal, n }; } /* Accessibilité des modales (s11q10) : role=dialog, fermeture Échap, * focus initial + piège de focus (Tab reste dans la modale), restitution * du focus à la fermeture. À poser sur le conteneur .modal. */ function useModalA11y(onClose) { const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; const prevFocus = document.activeElement; el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); const focusables = () => Array.from(el.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' )).filter((n) => !n.disabled && n.offsetParent !== null); const first = focusables()[0]; if (first) first.focus(); function onKey(e) { if (e.key === 'Escape') { e.stopPropagation(); onClose && onClose(); return; } if (e.key !== 'Tab') return; const list = focusables(); if (!list.length) return; const firstEl = list[0], lastEl = list[list.length - 1]; if (e.shiftKey && document.activeElement === firstEl) { e.preventDefault(); lastEl.focus(); } else if (!e.shiftKey && document.activeElement === lastEl) { e.preventDefault(); firstEl.focus(); } } document.addEventListener('keydown', onKey, true); return () => { document.removeEventListener('keydown', onKey, true); if (prevFocus && prevFocus.focus) prevFocus.focus(); }; }, []); return ref; } const fmtEur = (v) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(Math.round(v)); const fmtEur2 = (v) => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(v)) + ' €'; const fmtDate = (iso) => { if (!iso) return null; try { return new Date(iso + 'T00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); } catch (e) { return iso; } }; Object.assign(window, { useStore, emptyState, computeLoan, ALL_DOCS, matchesProfile, computeDocuments, STATUS_LABELS, STATUS_ORDER, RECIPIENTS, EMAIL_TEMPLATES, fmtEur, fmtEur2, fmtDate, purgeImmobaseLocal, normalizeStatus, numOrNull, demoProperty, useModalA11y, });