// CrowdMix — App Client (mobile PWA). Données réelles via API backend.
const {useState,useEffect,useRef,useCallback}=React;
const {Cover,CMI,FlipList}=window;

const fmt=s=>`${Math.floor(Math.max(0,s)/60)}:${String(Math.max(0,Math.floor(s)%60)).padStart(2,'0')}`;
function fmtDur(ms){if(!ms)return '?:??';const s=Math.round(ms/1000);return Math.floor(s/60)+':'+String(s%60).padStart(2,'0');}

// ── styles (inchangés du design) ─────────────────────────────
const cl={
  app:{position:'absolute',inset:0,background:'var(--bg)',color:'var(--text)',fontFamily:'var(--font-ui)',display:'flex',flexDirection:'column',touchAction:'pan-y'},
  header:{display:'flex',alignItems:'center',justifyContent:'space-between',padding:'58px 20px 12px',position:'relative',zIndex:5,
    background:'linear-gradient(180deg,rgba(14,14,16,0.96),rgba(14,14,16,0.6))',backdropFilter:'blur(10px)'},
  loginBtn:{background:'var(--gold)',color:'#1A1208',border:'none',borderRadius:999,padding:'9px 16px',fontWeight:700,fontSize:13.5,cursor:'pointer',fontFamily:'var(--font-ui)',whiteSpace:'nowrap'},
  acctChip:{display:'flex',alignItems:'center',gap:8,background:'var(--surface-2)',border:'1px solid var(--line)',borderRadius:999,padding:'5px 10px 5px 5px',cursor:'pointer'},
  avatar:{width:28,height:28,borderRadius:999,background:'linear-gradient(135deg,var(--gold-hi),var(--gold-dim))',color:'#1A1208',display:'flex',alignItems:'center',justifyContent:'center',fontWeight:700,fontSize:12,flexShrink:0},
  acctName:{fontWeight:600,fontSize:13.5,color:'var(--text)',whiteSpace:'nowrap',maxWidth:90,overflow:'hidden',textOverflow:'ellipsis'},
  acctMenu:{position:'absolute',top:'calc(100% + 8px)',right:0,minWidth:218,background:'var(--surface)',border:'1px solid var(--line)',borderRadius:'var(--r-md)',boxShadow:'0 18px 50px -16px rgba(0,0,0,0.7)',padding:8,zIndex:30,animation:'cmFade .15s ease'},
  acctMenuHead:{display:'flex',alignItems:'center',gap:11,padding:'8px 10px 12px',borderBottom:'1px solid var(--line-soft)',marginBottom:6},
  acctLogout:{width:'100%',display:'flex',alignItems:'center',gap:10,background:'none',border:'none',color:'#FF8B80',fontWeight:600,fontSize:14,cursor:'pointer',padding:'10px',borderRadius:'var(--r-sm)',fontFamily:'var(--font-ui)'},
  scroll:{flex:1,overflowY:'auto',overflowX:'hidden',padding:'8px 18px 0',touchAction:'pan-y',display:'flex',flexDirection:'column'},
  np:{background:'var(--surface)',border:'1px solid var(--line-soft)',borderRadius:'var(--r-lg)',padding:16,marginBottom:18},
  npLabel:{display:'flex',alignItems:'center',gap:7,fontSize:10.5,letterSpacing:2,fontWeight:700,color:'var(--gold)',marginBottom:11,whiteSpace:'nowrap'},
  npDot:{width:8,height:8,borderRadius:9,background:'var(--red)',boxShadow:'0 0 10px var(--red)',flexShrink:0,animation:'cmPulse 1.4s ease-in-out infinite'},
  npTitle:{fontWeight:700,fontSize:16,letterSpacing:-.2,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'},
  npArtist:{fontSize:13,color:'var(--text-3)',marginBottom:9},
  progress:{height:4,background:'var(--surface-3)',borderRadius:9,overflow:'hidden'},
  progressFill:{height:'100%',background:'linear-gradient(90deg,var(--gold-dim),var(--gold-hi))',borderRadius:9,transition:'none'},
  npTimes:{display:'flex',justifyContent:'space-between',fontSize:10.5,color:'var(--text-4)',marginTop:5,fontFamily:'var(--font-mono)'},
  roundHead:{display:'flex',alignItems:'flex-start',justifyContent:'space-between',gap:14,marginBottom:14},
  sectionKick:{fontSize:11,letterSpacing:2,fontWeight:700,color:'var(--text-3)',whiteSpace:'nowrap'},
  sectionTitle:{fontFamily:'var(--font-display)',fontWeight:700,fontSize:25,letterSpacing:-.5,lineHeight:1.05,marginTop:3,maxWidth:235},
  countdown:{display:'flex',alignItems:'center',gap:8,padding:'10px 16px',borderRadius:999,border:'1px solid var(--line)',background:'var(--surface-2)',color:'var(--text)',fontSize:19},
  cand:{background:'var(--surface)',border:'1px solid var(--line-soft)',borderRadius:'var(--r-md)',padding:13,position:'relative'},
  candLeader:{background:'linear-gradient(135deg,rgba(200,151,58,0.12),var(--surface))',border:'1px solid var(--gold)',boxShadow:'0 8px 30px -12px rgba(200,151,58,0.45)'},
  candVoted:{borderColor:'var(--green-dim)'},
  rankNum:{fontFamily:'var(--font-display)',fontWeight:700,fontSize:21,lineHeight:1,width:18,textAlign:'center',flexShrink:0},
  candTitle:{fontWeight:700,fontSize:15,letterSpacing:-.2,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'},
  candArtist:{fontSize:12.5,color:'var(--text-3)',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'},
  playBadge:{position:'absolute',bottom:3,right:3,width:22,height:22,borderRadius:99,display:'flex',alignItems:'center',justifyContent:'center',background:'rgba(0,0,0,0.66)',border:'1px solid rgba(255,255,255,0.35)',color:'#fff'},
  fbTag:{fontSize:10.5,fontWeight:600,color:'var(--green)',background:'var(--green-wash)',padding:'2px 8px',borderRadius:999},
  byTag:{fontSize:10.5,fontWeight:600,color:'var(--text-3)',background:'var(--surface-3)',padding:'2px 8px',borderRadius:999},
  voteCount:{fontSize:19,fontWeight:700,lineHeight:1,color:'var(--text)'},
  voteBtn:{display:'flex',alignItems:'center',gap:6,minWidth:54,justifyContent:'center',padding:'9px 10px',borderRadius:12,border:'1px solid var(--line)',background:'var(--surface-3)',color:'var(--text)',cursor:'pointer'},
  voteBtnLeader:{borderColor:'var(--gold-dim)',color:'var(--gold-hi)'},
  voteNum:{fontSize:18,fontWeight:700,lineHeight:1},
  voteLabel:{fontSize:9.5,letterSpacing:1.5,fontWeight:700,color:'var(--text-3)',textTransform:'uppercase'},
  voteBtnDone:{background:'var(--green)',borderColor:'var(--green)',color:'#06210F'},
  voteBtnLock:{opacity:.4,cursor:'default'},
  actionStatus:{display:'flex',alignItems:'center',gap:9,background:'var(--green-wash)',border:'1px solid var(--green-dim)',borderRadius:'var(--r-sm)',padding:'10px 13px',fontSize:13,color:'var(--text-2)',marginBottom:14},
  searchCta:{width:'100%',marginTop:16,display:'flex',alignItems:'center',justifyContent:'center',gap:9,padding:'15px',borderRadius:'var(--r-md)',background:'var(--gold)',border:'none',color:'#1A1208',fontWeight:700,fontSize:15,cursor:'pointer'},
  searchField:{flex:1,display:'flex',alignItems:'center',gap:10,background:'var(--surface-2)',border:'1px solid var(--line)',borderRadius:'var(--r-sm)',padding:'12px 14px'},
  searchInput:{flex:1,background:'none',border:'none',outline:'none',color:'var(--text)',fontSize:15.5,fontFamily:'var(--font-ui)'},
  cancelBtn:{background:'none',border:'none',color:'var(--gold)',fontWeight:700,fontSize:15,cursor:'pointer'},
  searchHint:{marginTop:4},
  resultRow:{display:'flex',gap:12,alignItems:'center',padding:'9px 6px',borderRadius:'var(--r-sm)',cursor:'pointer'},
  qualifyArrow:{fontSize:12.5,fontWeight:700,color:'var(--gold)',whiteSpace:'nowrap'},
  previewBtn:{width:36,height:36,borderRadius:99,border:'1.5px solid var(--line)',borderColor:'var(--line)',background:'var(--surface-2)',color:'var(--text-3)',display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',flexShrink:0},
  previewBtnOn:{borderColor:'var(--gold)',color:'var(--gold)',background:'var(--gold-wash)'},
  previewSpinner:{width:14,height:14,borderRadius:99,border:'2px solid rgba(255,255,255,.12)',borderTopColor:'var(--gold)',animation:'cmSpin .8s linear infinite'},
  addBtn:{width:36,height:36,borderRadius:99,background:'var(--gold)',border:'none',color:'#1A1208',display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',flexShrink:0},
  addBtnSpinner:{width:15,height:15,borderRadius:99,border:'2px solid rgba(26,18,8,.25)',borderTopColor:'#1A1208',animation:'cmSpin .8s linear infinite'},
  overlay:{position:'absolute',inset:0,background:'rgba(0,0,0,0.6)',backdropFilter:'blur(4px)',display:'flex',alignItems:'flex-end',zIndex:80,animation:'cmFade .2s ease'},
  sheet:{width:'100%',background:'var(--surface)',borderTopLeftRadius:'var(--r-xl)',borderTopRightRadius:'var(--r-xl)',borderTop:'1px solid var(--line)',padding:'14px 20px 38px',animation:'cmUp .32s cubic-bezier(.2,.9,.2,1)'},
  grip:{width:38,height:5,borderRadius:9,background:'var(--surface-3)',margin:'0 auto 18px'},
  aiLoading:{display:'flex',alignItems:'center',gap:14,padding:'14px 0 8px'},
  spinner:{width:34,height:34,flexShrink:0,borderRadius:99,border:'3px solid var(--surface-3)',borderTopColor:'var(--gold)',animation:'cmSpin .8s linear infinite'},
  primaryBtn:{width:'100%',padding:16,borderRadius:'var(--r-md)',background:'linear-gradient(135deg,var(--gold-hi),var(--gold))',border:'none',color:'#1A1208',fontWeight:700,fontSize:15.5,cursor:'pointer',fontFamily:'var(--font-ui)'},
  ghostBtn:{width:'100%',padding:15,borderRadius:'var(--r-md)',background:'var(--surface-2)',border:'1px solid var(--line)',color:'var(--text-2)',fontWeight:600,fontSize:15,cursor:'pointer'},
  ssoBtn:{display:'flex',alignItems:'center',justifyContent:'center',gap:11,padding:'15px',borderRadius:'var(--r-md)',background:'var(--surface-3)',border:'1px solid var(--line)',color:'var(--text)',fontWeight:700,fontSize:15,cursor:'pointer',fontFamily:'var(--font-ui)'},
  resub:{display:'flex',alignItems:'center',gap:11,background:'var(--gold-wash)',border:'1px solid var(--gold-dim)',borderRadius:'var(--r-md)',padding:'12px 13px',marginBottom:16},
  resubBtn:{background:'var(--gold)',color:'#1A1208',border:'none',borderRadius:999,padding:'8px 13px',fontWeight:700,fontSize:12.5,cursor:'pointer',whiteSpace:'nowrap'},
  resubClose:{background:'none',border:'none',color:'var(--text-3)',cursor:'pointer',padding:2},
  winnerOverlay:{position:'absolute',inset:0,background:'rgba(8,8,10,0.86)',backdropFilter:'blur(8px)',display:'flex',alignItems:'center',justifyContent:'center',zIndex:90,animation:'cmFade .25s ease'},
  winnerCard:{textAlign:'center',padding:'30px 36px',animation:'cmPop .4s cubic-bezier(.2,.9,.2,1)'},
};

// ── App principale ────────────────────────────────────────────
function ClientApp(){
  const [sb,setSb]=useState(null);
  const [user,setUser]=useState(null);
  const [connected,setConnected]=useState(false);
  const [ready,setReady]=useState(false);
  const [screen,setScreen]=useState('main');
  const [q,setQ]=useState('');
  const [results,setResults]=useState([]);
  const [qualify,setQualify]=useState(null);
  const [sso,setSso]=useState(null);
  const [myAction,setMyAction]=useState(null);
  const [np,setNp]=useState(null);
  const [roundData,setRoundData]=useState(null);
  const [roundLoaded,setRoundLoaded]=useState(false);
  const [timer,setTimer]=useState(null);
  const [winner,setWinner]=useState(null);
  const [notif,setNotif]=useState(null);
  const [mySubs,setMySubs]=useState([]); // historique des propositions (mode proposer)
  const [preview,setPreview]=useState(null);
  const [previewLoading,setPreviewLoading]=useState(false);
  const [votingId,setVotingId]=useState(null); // id du candidat dont le vote est en cours (spinner bouton +)
  const [altBy,setAltBy]=useState(false);
  const [menuOpen,setMenuOpen]=useState(false);
  const [legalOpen,setLegalOpen]=useState(false);
  const [pendingPostLogin,setPendingPostLogin]=useState(null);
  const [engineOk,setEngineOk]=useState(null); // null (inconnu) | true | false — seul signal d'état public
  const [version,setVersion]=useState('');
  const initialVersionRef=useRef(null); // détection changement de version → reload
  const infoFailsRef=useRef(0); // échecs consécutifs /api/info (anti-flap serveur injoignable)

  const sbRef=useRef(null);
  const roundRef=useRef(null);
  const npRef=useRef(null);
  const engineOkRef=useRef(null); // détection transition engine_ok false→true (resync round)
  const audioRef=useRef(null);
  const previewUrlCache=useRef(new Map()); // "title|artist" → url Deezer | null | 'loading'
  const processedNotifRoundRef=useRef(null); // évite de redéclencher la notif pour le même round
  const myLastSubmissionRef=useRef(null);    // track soumis dans le round courant (pour la notif perdant)
  const pendingDefeatRef=useRef(null);       // { track, lostRoundId } — notif défaite EN ATTENTE du prochain round ouvert
  const pendingProposeRef=useRef(null);      // track en attente du prochain round (si round_not_open)
  const pendingProposeTimeoutRef=useRef(null);
  const refreshRoundInFlightRef=useRef(false); // dédoublonnage refreshRound
  const refreshRoundPendingRef=useRef(false);
  const scrollRef=useRef(null);                // conteneur scrollable de l'écran main
  sbRef.current=sb;
  roundRef.current=roundData;
  npRef.current=np;
  engineOkRef.current=engineOk;

  // ── Device ID persistant (anti-triche multi-compte) ─────────
  const deviceId=React.useMemo(()=>{
    let id=localStorage.getItem('crowdmix_device_id');
    if(!id){id=crypto.randomUUID();localStorage.setItem('crowdmix_device_id',id);}
    return id;
  },[]);

  // ── API helper ──────────────────────────────────────────────
  // Récupère un access_token frais. Si le token expire dans moins de 60s
  // (ou est déjà périmé), on force un refresh AVANT d'envoyer la requête.
  // Indispensable sur mobile : quand l'onglet passe en arrière-plan / l'écran
  // se verrouille, le timer d'auto-refresh de supabase-js est suspendu, et au
  // retour getSession() peut renvoyer un token mort → 401 → déconnexion sèche.
  const getFreshToken=useCallback(async(forceRefresh=false)=>{
    const sb=sbRef.current; if(!sb) return null;
    const {data}=await sb.auth.getSession();
    const session=data.session; if(!session) return null;
    const expSoon=!session.expires_at||(session.expires_at*1000-Date.now())<60000;
    if(forceRefresh||expSoon){
      const {data:rd}=await sb.auth.refreshSession();
      if(rd?.session?.access_token) return rd.session.access_token;
    }
    return session.access_token||null;
  },[]);

  const api=useCallback(async(path,{method='GET',body,auth=false,_retried=false}={})=>{
    // X-Device-Id sur toutes les requêtes : sert de heartbeat de présence côté serveur
    // (le polling /api/info 30s suffit à maintenir le device dans la fenêtre active 2 min).
    const headers={'Content-Type':'application/json','X-Device-Id':deviceId};
    if(auth&&sbRef.current){
      const t=await getFreshToken();
      if(t) headers.Authorization='Bearer '+t;
    }
    const res=await fetch(path,{method,headers,body:body?JSON.stringify(body):undefined});
    let data=null;
    try{data=await res.json();}catch(_){}
    // 401 sur requête authentifiée : NE PAS déconnecter d'emblée. Un 401 vient
    // le plus souvent d'un token expiré (cas normal), pas d'une révocation.
    // → on force un refresh et on rejoue la requête UNE fois. Si après refresh
    //   on a toujours 401, c'est une vraie révocation (signOut global depuis un
    //   autre appareil, compte supprimé…) → on purge alors la session locale.
    if(res.status===401&&auth&&sbRef.current&&!_retried){
      const t=await getFreshToken(true).catch(()=>null);
      if(t) return api(path,{method,body,auth,_retried:true});
      sbRef.current.auth.signOut({scope:'local'}).catch(()=>{});
    }else if(res.status===401&&auth&&sbRef.current&&_retried){
      sbRef.current.auth.signOut({scope:'local'}).catch(()=>{});
    }
    return{ok:res.ok,status:res.status,data};
  },[deviceId,getFreshToken]);

  // ── Boot ────────────────────────────────────────────────────
  useEffect(()=>{
    (async()=>{
      const r=await api('/api/realtime/config');
      if(r.ok){
        const client=window.supabase.createClient(r.data.supabaseUrl,r.data.anonKey);
        setSb(client); sbRef.current=client;
        const setFromSession=(session)=>{
          if(!session){setUser(null);setConnected(false);return;}
          const u=session.user,m=u.user_metadata||{};
          setUser({id:u.id,email:u.email,name:m.full_name||m.name||u.email,avatar:m.avatar_url||m.picture,provider:'google'});
          setConnected(true);
        };
        const {data}=await client.auth.getSession();
        setFromSession(data.session);
        client.auth.onAuthStateChange((_e,s)=>setFromSession(s));

        // Restaurer une action en attente après redirect OAuth
        try{
          const raw=localStorage.getItem('crowdmix_pending_action');
          if(raw){
            localStorage.removeItem('crowdmix_pending_action');
            const action=JSON.parse(raw);
            // Valide seulement si l'action date de moins de 10 minutes
            if(Date.now()-action.savedAt<10*60*1000) setPendingPostLogin(action);
          }
        }catch(_){}
      }
      setReady(true);
      refreshRound(); refreshInfo();
    })();
  },[]);

  // ── Realtime Supabase — canal global : détecte les nouveaux rounds (INSERT) immédiatement
  useEffect(()=>{
    if(!sb) return;
    const ch=sb.channel('cl-rounds-global')
      .on('postgres_changes',{event:'INSERT',schema:'public',table:'crowdmix_rounds'},()=>{
        if(audioRef.current){audioRef.current.pause();audioRef.current.src='';audioRef.current=null;setPreview(null);setPreviewLoading(false);}
        refreshRound();
      })
      // Resync à chaque (re)connexion du websocket : rattrape les événements perdus
      // pendant une coupure (le Realtime ne les rejoue pas). Tire aussi au boot (inoffensif).
      .subscribe((status)=>{if(status==='SUBSCRIBED')refreshRound();});
    return ()=>sb.removeChannel(ch);
  },[sb]);

  // ── Realtime Supabase — canal par round : votes et candidats intra-round
  useEffect(()=>{
    if(!sb||!roundData?.round?.id) return;
    const rid=roundData.round.id;
    const ch=sb.channel('cl-round-'+rid)
      .on('postgres_changes',{event:'UPDATE',schema:'public',table:'crowdmix_rounds',filter:`id=eq.${rid}`},refreshRound)
      .on('postgres_changes',{event:'*',schema:'public',table:'crowdmix_candidates',filter:`round_id=eq.${rid}`},refreshRound)
      .on('postgres_changes',{event:'*',schema:'public',table:'crowdmix_votes',filter:`round_id=eq.${rid}`},refreshRound)
      .subscribe();
    return ()=>sb.removeChannel(ch);
  },[sb,roundData?.round?.id]);

  // ── Polling : heartbeat 30s + rattrapage rapide 6s ──────────────────────────
  // (1) refreshInfo 30s : version + engine_ok + heartbeat devicePresence (X-Device-Id).
  // (2) Rattrapage 6s — quand l'UI n'est PAS sur un round ouvert (attente,
  //     résultat du round précédent, post-veille). Aligne la réactivité du mobile sur
  //     celle du display (poll 5s) : avant, le filet était à 30s → jusqu'à 30s de retard
  //     pour découvrir le nouveau round quand le websocket realtime avait raté l'INSERT.
  //     Pendant un round OUVERT : aucun trafic supplémentaire (realtime + horloge 1s).
  // (3) Auto-réparation « compteur figé à 00:00 » : un round OUVERT dont le closes_at est
  //     dépassé depuis trop longtemps signifie que l'event realtime de clôture (UPDATE
  //     status→closed / INSERT round suivant) a été RATÉ — typiquement pendant la fenêtre
  //     instable d'un redéploiement Railway. Le realtime ne rejoue jamais un event manqué,
  //     et le filet (2) ne tirait pas tant que status==='open' → l'horloge restait bloquée
  //     sur 00:00 clignotant jusqu'à un changement d'onglet (visibilitychange → resync).
  //     Seuil = 25s : > fenêtre de transition normale (CLOSE_BEFORE_END 15s + fondu) pour
  //     ne JAMAIS poller pendant le 00:00 légitime de fin de round ; ne tire que si l'event
  //     a réellement été manqué. Round non-expiré → aucun trafic ajouté.
  useEffect(()=>{
    const tInfo=setInterval(()=>refreshInfo(),30000);
    const tCatch=setInterval(()=>{
      const r=roundRef.current?.round;
      if(r?.status!=='open'){refreshRound();return;}
      if(r?.closes_at&&Date.now()-new Date(r.closes_at).getTime()>25000)refreshRound();
    },6000);
    return ()=>{clearInterval(tInfo);clearInterval(tCatch);};
  },[]);

  // ── Resync au réveil ─────────────────────────────────────────
  // Veille téléphone / onglet en arrière-plan : iOS gèle les timers et tue le websocket.
  // Le Realtime Supabase ne rejoue JAMAIS les événements manqués → un round ouvert pendant
  // la veille est invisible (son INSERT est antérieur à la reconnexion). Sans resync, l'UI
  // reste figée sur l'ancien round avec le compteur à 00:00. Trois déclencheurs :
  //   visibilitychange (retour au premier plan), pageshow persisted (restauration BFCache
  //   iOS, visibilitychange ne tire pas toujours), online (reprise réseau sans veille).
  // Tirs multiples quasi simultanés absorbés par le dédoublonnage in-flight de refreshRound ;
  // recalage du timer via le garde-fou "saut > 5s" existant.
  useEffect(()=>{
    const resync=()=>{refreshRound();refreshInfo();};
    const onVis=()=>{if(document.visibilityState==='visible')resync();};
    const onShow=(e)=>{if(e.persisted)resync();};
    document.addEventListener('visibilitychange',onVis);
    window.addEventListener('pageshow',onShow);
    window.addEventListener('online',resync);
    return ()=>{
      document.removeEventListener('visibilitychange',onVis);
      window.removeEventListener('pageshow',onShow);
      window.removeEventListener('online',resync);
    };
  },[]);

  // ── Horloge (timer round + progress NP) ────────────────────
  useEffect(()=>{
    const t=setInterval(()=>{
      const closesAt=roundRef.current?.round?.closes_at;
      setTimer(closesAt?Math.max(0,Math.round((new Date(closesAt)-Date.now())/1000)):0);
    },1000);
    return ()=>clearInterval(t);
  },[]);

  // ── Rejouer l'action post-login (action sauvegardée avant redirect OAuth) ──
  useEffect(()=>{
    if(!pendingPostLogin||!connected||!roundData) return;
    const {type,candidateId,roundId,track}=pendingPostLogin;
    setPendingPostLogin(null);
    if(type==='vote'){
      if(roundData.round?.id!==roundId||roundData.round?.status!=='open') return;
      vote(candidateId);
    } else if(type==='qualify'&&track){
      // Relancer la qualification directement — l'utilisateur est maintenant connecté
      openQualify(track);
    }
  },[pendingPostLogin,connected,roundData]);

  // ── Reset myAction + soumission mémorisée au changement de round ──────────
  useEffect(()=>{
    setMyAction(null);
    myLastSubmissionRef.current=null; // nouveau round → reset (le ref n'est utile que pendant le round courant)
  },[roundData?.round?.id]);

  // ── Arrêt preview si le bouton disparaît de l'écran ──────────
  // Couvre : écran d'attente (engineOk), nouveau round (roundData.round.id), clôture round (status→closed)
  // Fonctionne aussi si la preview était en cours de chargement (audioRef set mais pas encore playing)
  useEffect(()=>{
    if(!audioRef.current) return;
    audioRef.current.pause();audioRef.current.src='';audioRef.current=null;
    setPreview(null);setPreviewLoading(false);
  },[engineOk,roundData?.round?.id,roundData?.round?.status]);

  // ── Retry propose au prochain round (si round_not_open au moment de la soumission) ──
  useEffect(()=>{
    if(!pendingProposeRef.current) return;
    if(roundData?.round?.status!=='open') return;
    clearTimeout(pendingProposeTimeoutRef.current);
    const track=pendingProposeRef.current;
    pendingProposeRef.current=null;
    propose(track);
  },[roundData?.round?.id,roundData?.round?.status]);

  // ── Notif perdants : détecte la clôture d'un round où l'user a perdu ────────
  // IMPORTANT: utiliser roundData (état React, déclaré avant ce hook) dans les deps,
  // PAS les constantes dérivées (roundClosed, rd…) déclarées plus bas dans la fonction —
  // elles sont en TDZ quand Babel évalue le tableau de deps → undefined → effect figé.
  useEffect(()=>{
    const status=roundData?.round?.status;
    if(status!=='closed'&&status!=='unresolved') return;
    const roundId=roundData.round.id;
    if(processedNotifRoundRef.current===roundId) return;
    processedNotifRoundRef.current=roundId;
    const submitted=myLastSubmissionRef.current;
    if(!submitted) return;
    if(submitted.id===roundData.round.winner_track_id) return; // l'user a gagné
    // On NE montre PAS la bannière tout de suite : le round est clos, le suivant n'est pas encore
    // ouvert → un clic « Re-proposer » immédiat échouerait (« déjà voté »). On met la notif EN
    // ATTENTE ; elle sera affichée au DÉBUT du prochain round ouvert (effet ci-dessous).
    pendingDefeatRef.current={track:submitted,lostRoundId:roundId};
  },[roundData?.round?.status,roundData?.round?.id]);

  // ── Affiche la notif « Re-proposer » SEULEMENT quand un nouveau round est OUVERT ───────────
  // Évite le clic trop tôt (erreur « déjà voté »). Dès qu'un round ouvert d'id différent du round
  // perdu apparaît, on sort la bannière et on remonte le scroll pour la rendre visible.
  useEffect(()=>{
    const pd=pendingDefeatRef.current;
    if(!pd) return;
    if(roundData?.round?.status!=='open') return;
    if(roundData.round.id===pd.lostRoundId) return; // toujours le même round, pas encore le suivant
    pendingDefeatRef.current=null;
    setNotif(pd.track);
    scrollRef.current?.scrollTo({top:0,behavior:'smooth'});
  },[roundData?.round?.status,roundData?.round?.id]);

  // ── Preview URL — Deezer direct depuis le client ──
  // Appelé AU CLIC seulement (pas de prefetch). Cache client pour éviter
  // de re-fetcher le même morceau dans la même session.
  // iOS-safe : unlock audio synchrone (new Audio silent) avant le fetch async,
  // puis swap src sur le même élément une fois l'URL connue.
  function _previewCacheKey(title,artist){return`${(title||'').toLowerCase()}|${(artist||'').toLowerCase()}`;}
  // Données URI d'un WAV vide 1 frame (44 octets) — pour unlock iOS sans bruit
  const _SILENT='data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=';

  // ── Alternance artiste ↔ "proposé par" ─────────────────────
  useEffect(()=>{
    const t=setInterval(()=>setAltBy(v=>!v),3200);
    return ()=>clearInterval(t);
  },[]);

  const refreshRound=useCallback(async()=>{
    // Dédoublonnage : un seul appel en vol à la fois, le suivant est mis en attente
    if(refreshRoundInFlightRef.current){refreshRoundPendingRef.current=true;return;}
    refreshRoundInFlightRef.current=true;
    try{
      const r=await api('/api/rounds/current');
      if(r.ok){
        setRoundData(r.data.round);
        setRoundLoaded(true);
        const closesAt=r.data.round?.round?.closes_at;
        const newTimer=closesAt?Math.max(0,Math.round((new Date(closesAt)-Date.now())/1000)):null;
        // Timer monotoniquement non-croissant : refreshRound ne peut jamais faire remonter le timer.
        // Seul l'intervalle (source de vérité) décrémente. refreshRound ne remet à la hausse que
        // pour un nouveau round (saut > 5s, ex: 0→180) ou pour l'init (prev===null).
        setTimer(prev=>{
          if(newTimer===null) return prev;
          if(prev===null) return newTimer>0?newTimer:null;
          if(newTimer>prev+5) return newTimer; // nouveau round → reset autorisé
          if(newTimer<prev) return newTimer;   // décrémentation normale
          return prev;                         // remontée ou égalité → ignoré
        });
        if(r.data.playback) setNp({...r.data.playback,_stamp:Date.now()});
        if(r.data.engine_ok!==undefined) setEngineOk(r.data.engine_ok);
      }
    }finally{
      refreshRoundInFlightRef.current=false;
      if(refreshRoundPendingRef.current){refreshRoundPendingRef.current=false;refreshRound();}
    }
  },[api]);

  const refreshInfo=useCallback(async()=>{
    // Anti-flap (même pattern que le display) : serveur injoignable → écran générique
    // au 2e échec consécutif seulement (~60s au rythme du poll 30s), pour ne pas
    // flasher sur un hoquet réseau ou un redéploiement Railway.
    let r=null;
    try{r=await api('/api/info');}catch(_){}
    if(!r||!r.ok){
      infoFailsRef.current++;
      if(infoFailsRef.current>=2)setEngineOk(false);
      return;
    }
    infoFailsRef.current=0;
    const v=r.data.version||'';
    if(!initialVersionRef.current)initialVersionRef.current=v;
    else if(v&&v!==initialVersionRef.current){window.location.href=window.location.pathname+'?_r='+Date.now();return;}
    if(r.data.engine_ok!==undefined){
      // Moteur de retour (false→true) : resync immédiate du round — le nouveau round a pu
      // être inséré pendant que le websocket realtime était mort (INSERT jamais rejoué).
      if(r.data.engine_ok===true&&engineOkRef.current===false)refreshRound();
      setEngineOk(r.data.engine_ok);
    }
    setVersion(v);
  },[api]);

  // ── Historique des propositions ("Mes derniers titres") ─────
  // Chargé à chaque ouverture du mode proposer (1 requête). Le serveur filtre les
  // non-jouables (fenêtre de récence + refus IA) — relance 1 tap via resubmit().
  useEffect(()=>{
    if(screen!=='search'||!connected){setMySubs([]);return;}
    let cancelled=false;
    (async()=>{
      const r=await api('/api/me/submissions?limit=10',{auth:true});
      if(!cancelled&&r.ok) setMySubs((r.data.tracks||[]).map(adaptTrack));
    })();
    return ()=>{cancelled=true;};
  },[screen,connected]);

  // ── Recherche catalogue ───────────────────────────────────────
  // ⚠️ Drapeau `cancelled` OBLIGATOIRE : sans lui, une requête déjà partie revient
  // APRÈS l'effacement du champ et ré-écrit setResults() → champ vide mais liste
  // remplie de titres d'une saisie fugace (race condition). Idem hors-ordre en
  // frappe rapide. Le clearTimeout seul ne suffit pas (il n'annule pas le fetch en vol).
  useEffect(()=>{
    const query=q.trim();
    if(!query){setResults([]);return;}
    let cancelled=false;
    const id=setTimeout(async()=>{
      const r=await api('/api/search?q='+encodeURIComponent(query));
      if(!cancelled&&r.ok) setResults((r.data.tracks||[]).map(adaptTrack));
    },350);
    return ()=>{cancelled=true;clearTimeout(id);};
  },[q,api]);

  function adaptTrack(t){
    return{id:t.qobuz_track_id,qobuzId:t.qobuz_track_id,title:t.title,artist:t.artist,album_art:t.album_art,dur:fmtDur(t.duration_ms),duration_ms:t.duration_ms,preview_url:t.preview_url||null};
  }

  // ── Auth ────────────────────────────────────────────────────
  function doLogin(){
    // Sauvegarder l'action en attente avant le redirect OAuth (la page va se recharger)
    const pending=sso?.pendingData;
    if(pending) localStorage.setItem('crowdmix_pending_action',JSON.stringify({...pending,savedAt:Date.now()}));
    if(sbRef.current) sbRef.current.auth.signInWithOAuth({provider:'google',options:{redirectTo:window.location.origin}});
  }
  // scope:'local' — ne déconnecte QUE cet appareil. Le signOut() par défaut est GLOBAL :
  // il révoque toutes les sessions du compte (mobile, desktop, backoffice) et laisse les
  // autres appareils avec un token localement "valide" mais rejeté par le serveur.
  function doLogout(){if(sbRef.current){sbRef.current.auth.signOut({scope:'local'});setMenuOpen(false);}}

  // pendingData : données sérialisables décrivant l'action à rejouer après login
  function requireSSO(after,pendingData=null){if(connected){after();return;}setSso({after,pendingData});}

  // ── Preview audio (30s) ─────────────────────────────
  // Appelé au clic (user gesture). Fetch Deezer à la demande, cache local pour la session.
  async function handlePreview(id,title,artist){
    if(audioRef.current){audioRef.current.pause();audioRef.current.src='';audioRef.current=null;}
    if(preview===id){setPreview(null);setPreviewLoading(false);return;}
    setPreview(id);
    setPreviewLoading(true);
    // Unlock audio iOS : play() synchrone sur son silencieux avant tout await
    const audio=new Audio(_SILENT);
    audio.volume=0;
    audioRef.current=audio;
    audio.play().catch(()=>{});
    // Résolution URL : cache mémoire → JSONP Deezer (zéro serveur)
    const key=_previewCacheKey(title,artist);
    let url=previewUrlCache.current.get(key)||null;
    if(!url){
      url=await cmDeezerPreview(`${artist||''} ${title||''}`.trim());
      if(url) previewUrlCache.current.set(key,url);
    }
    // Vérification : preview annulée pendant le fetch ?
    if(!url||audioRef.current!==audio){
      if(audioRef.current===audio){audioRef.current=null;setPreview(null);setPreviewLoading(false);}
      return;
    }
    const cleanup=()=>{if(audioRef.current===audio){audioRef.current=null;setPreview(null);setPreviewLoading(false);}};
    audio.addEventListener('ended',cleanup);
    audio.addEventListener('playing',()=>{
      if(audioRef.current!==audio){audio.pause();audio.src='';return;}
      setPreviewLoading(false);
    });
    audio.src=url;
    audio.volume=0.6;
    audio.play().catch(cleanup);
  }

  // ── Qualification IA ────────────────────────────────────────
  // Si le morceau est accepté, on agit directement sans confirmation :
  //   • déjà dans les candidats → vote direct
  //   • action déjà utilisée ce round → state:'done' (message "Action déjà utilisée")
  //   • sinon → propose() directement (state:'submitting' dans la modale)
  async function openQualify(track){
    if(!connected){requireSSO(()=>openQualify(track),{type:'qualify',track});return;}
    // Déjà agi ce round (vote ou proposition) → on laisse parcourir mais pas ré-agir.
    // Message dédié avec le temps restant estimé, sans consommer de crédit IA.
    if(hasActed){
      if(audioRef.current){audioRef.current.pause();audioRef.current.src='';audioRef.current=null;setPreview(null);setPreviewLoading(false);}
      setQualify({track,state:'already_acted',res:null});
      return;
    }
    if(audioRef.current){audioRef.current.pause();audioRef.current.src='';audioRef.current=null;setPreview(null);setPreviewLoading(false);}
    setQualify({track,state:'loading',res:null});
    const r=await api('/api/ai/qualify',{method:'POST',auth:true,body:{
      title:track.title,artist:track.artist,album_art:track.album_art,
      qobuz_track_id:track.id,duration_ms:track.duration_ms
    }});
    if(r.status===503){setQualify(null);return;}
    if(r.status===401){setQualify(null);setSso({after:()=>openQualify(track),pendingData:{type:'qualify',track}});return;}
    if(!r.ok){
      setQualify(q=>q&&{...q,state:'done',res:{decision:'refuse',score:0,analysis:r.data?.message||'Erreur de qualification.'}});
      return;
    }
    if(r.data.decision!=='accepte'){
      setQualify(q=>q&&{...q,state:'done',res:{decision:r.data.decision,analysis:r.data.analysis}});
      return;
    }
    // Morceau accepté — action automatique sans étape de confirmation
    const freshCands=roundRef.current?.candidates||[];
    const alreadyCand=freshCands.find(c=>c.qobuz_track_id===track.id);
    if(alreadyCand){
      // Déjà proposé par quelqu'un → voter directement (vote() ferme la modale via setQualify(null))
      vote(alreadyCand.id);
      return;
    }
    if(myAction){
      // Action déjà utilisée ce round → afficher le message sans soumettre
      setQualify(q=>q&&{...q,state:'done',res:{decision:'accepte',analysis:''}});
      return;
    }
    // Proposer directement (la modale passe en state:'submitting' via propose())
    propose(track);
  }

  // ── Proposer un morceau ─────────────────────────────────────
  async function propose(track){
    const rid=roundRef.current?.round?.id;
    if(!rid)return;
    setQualify(q=>q&&{...q,state:'submitting'});
    const r=await api(`/api/rounds/${rid}/candidates`,{method:'POST',auth:true,body:{
      title:track.title,artist:track.artist,album_art:track.album_art,
      qobuz_track_id:track.id,duration_ms:track.duration_ms,device_id:deviceId,
      preview_url:track.preview_url||null
    }});
    if(r.status===503){setQualify(null);return;}
    if(!r.ok){
      if(r.data?.error==='device_already_voted'){
        setQualify(null);setScreen('main');
        setMyAction({type:'device_blocked'});
        return;
      }
      if(r.status===401){
        // Session révoquée → reconnexion en un clic, puis rejouer la qualification
        setQualify(null);
        setSso({after:()=>openQualify(track),pendingData:{type:'qualify',track}});
        return;
      }
      if(r.data?.error==='round_not_open'){
        // Round cloturé pile au moment de la soumission → attendre le prochain round et réessayer.
        // Le round suivant ouvre ~CLOSE_BEFORE_END_MS (10s) après la clôture du vote, plus les
        // latences moteur + realtime → le filet de sécurité doit largement couvrir ce délai.
        pendingProposeRef.current=track;
        setQualify(q=>q&&{...q,state:'waiting_round'});
        clearTimeout(pendingProposeTimeoutRef.current);
        pendingProposeTimeoutRef.current=setTimeout(()=>{
          if(pendingProposeRef.current){pendingProposeRef.current=null;setQualify(null);}
        },45000);
        return;
      }
      setQualify(q=>q&&{...q,state:'done',res:{decision:'refuse',score:0,analysis:r.data?.message||'Proposition refusée.'}});
      return;
    }
    // Succès : la fenêtre RESTE ouverte sur un message de confirmation — l'utilisateur
    // ferme lui-même pour revenir au round (le retour à 'main' est géré par onClose).
    setQualify(q=>q&&{...q,state:'confirmed'});
    setMyAction({type:'submit',id:track.id});
    // Mémoriser le track soumis pour la notif perdant (plus fiable que chercher dans candidates après coup)
    myLastSubmissionRef.current={id:track.id,title:track.title,artist:track.artist,album_art:track.album_art,duration_ms:track.duration_ms||null,preview_url:track.preview_url||null};
    refreshRound();
  }

  // ── Re-proposer après défaite (un seul clic) ────────────────
  // Le morceau a déjà été qualifié (accepté) lors de la soumission initiale → on saute
  // la qualification IA et on soumet directement. La modale s'ouvre en 'submitting' pour
  // que propose() puisse afficher ses états (waiting_round, erreurs…).
  function resubmit(track){
    if(!connected){requireSSO(()=>resubmit(track),{type:'qualify',track});return;}
    if(hasActed){setQualify({track,state:'already_acted',res:null});return;}
    setQualify({track,state:'submitting',res:null});
    propose(track);
  }

  // ── Voter ───────────────────────────────────────────────────
  async function vote(candidateId){
    if(myAction||votingId)return;
    const rid=roundRef.current?.round?.id;
    if(!rid)return;
    setVotingId(candidateId);
    try{
      const r=await api(`/api/rounds/${rid}/vote`,{method:'POST',auth:true,body:{candidate_id:candidateId,device_id:deviceId}});
      if(r.ok){setMyAction({type:'vote',id:candidateId});setQualify(null);}
      else if(r.status===401){setSso({after:()=>vote(candidateId),pendingData:{type:'vote',candidateId,roundId:rid}});}
      else if(r.data?.error==='device_already_voted'){setMyAction({type:'device_blocked'});}
      refreshRound();
    }finally{setVotingId(null);}
  }

  // ── Adaptation données backend → format design ──────────────
  // Nom du soumetteur abrégé : "Marie Sauveur" → "Marie S." (email ou nom simple : inchangé)
  const fmtBy=s=>{const p=String(s||'').trim().split(/\s+/);return p.length>1&&p[1]?`${p[0]} ${p[1][0].toUpperCase()}.`:(s||'');};
  const rd=roundData;
  const candidates=(rd?.candidates||[]).map(c=>({
    id:c.id,
    qobuzId:c.qobuz_track_id,
    title:c.title,
    artist:c.artist,
    album_art:c.album_art,
    duration_ms:c.duration_ms||null,
    votes:c.vote_count,
    isFallback:c.is_fallback,
    firstVotedAt:c.first_voted_at||null,
    by:fmtBy(c.submitter?.name||c.submitter?.email||''),
    mine:user&&c.submitted_by_user_id===user.id,
    preview_url:c.preview_url||null,
  })).sort((a,b)=>b.votes-a.votes||
    (!a.firstVotedAt&&!b.firstVotedAt?0:!a.firstVotedAt?1:!b.firstVotedAt?-1:new Date(a.firstVotedAt)-new Date(b.firstVotedAt))||
    a.id.localeCompare(b.id));

  const myVote=user&&rd?(rd.votes||[]).find(v=>v.user_id===user.id):null;
  const myCandDb=myVote?candidates.find(c=>c.id===myVote?.candidate_id):null;
  const hasActed=!!myAction||!!myVote;
  // Distingue « proposé » de « voté » pour le message post-action (proposer = vote auto sur son propre candidat).
  const iProposed=myAction?.type==='submit'||!!(myCandDb&&myCandDb.mine);
  const roundOpen=rd?.round?.status==='open';
  // Round final de clôture : SEUL ce round affiche le message « dernier morceau » et
  // bloque le vote. Pendant le round normal en cours (closing activé mais pas encore
  // is_final), le vote continue normalement — c'est lui qui désigne le dernier gagnant.
  const isFinalRound=rd?.round?.is_final===true;
  const roundClosed=rd?.round?.status==='closed'||rd?.round?.status==='unresolved';
  const winnerQobuzId=roundClosed?rd?.round?.winner_track_id:null;

  // Now playing adapté
  const npTrack=np?.track;
  const npElapsed=npTrack&&np._stamp?Math.min(
    (np.progress_ms+(np.is_playing?Date.now()-np._stamp:0))/1000,
    npTrack.duration_ms/1000
  ):0;
  const now=npTrack?{title:npTrack.name,artist:npTrack.artists,album_art:npTrack.album_art,id:'np',elapsed:npElapsed,total:npTrack.duration_ms/1000}:null;

  if(!ready) return <div style={{...cl.app,alignItems:'center',justifyContent:'center'}}><div style={cl.spinner}></div></div>;

  // Écran d'attente générique — harmonisé avec le teasing du display (message unique,
  // aucune raison exposée). Couvre : moteur bloqué (engine_ok=false), aucun round
  // (boot moteur, session pas démarrée, session finie), serveur injoignable (anti-flap
  // 2 échecs via refreshInfo). Disparaît dès qu'un round ouvert revient (realtime).
  const showWait=engineOk===false?(roundData?.round?.status!=='open'):(roundLoaded&&!roundData);
  if(showWait){
    return (
      <div style={{...cl.app,alignItems:'center',justifyContent:'center',textAlign:'center',padding:'0 32px',gap:24}}>
        {version&&<span style={{position:'absolute',top:'calc(env(safe-area-inset-top, 0px) + 12px)',right:14,fontSize:10,color:'var(--text-4)',fontFamily:'var(--font-mono)'}}>v{version}</span>}
        <img src="logo-pic.png" alt="Le pic" style={{height:44,width:'auto',opacity:.7}}/>
        <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:22,letterSpacing:-.3,lineHeight:1.15}}>
          C'est vous qui<br/><span style={{color:'var(--gold)'}}>choisissez</span> la musique !
        </div>
        <WaitDots/>
        {/* Mentions légales détachées du contenu : épinglées en bas de l'écran */}
        <button onClick={()=>setLegalOpen(true)} style={{position:'absolute',bottom:'calc(env(safe-area-inset-bottom, 0px) + 22px)',left:0,right:0,background:'none',border:'none',color:'var(--text-4)',fontSize:11,cursor:'pointer',fontFamily:'var(--font-ui)',textDecoration:'underline',textUnderlineOffset:2}}>Mentions légales & Confidentialité</button>
        {legalOpen&&<LegalModal onClose={()=>setLegalOpen(false)}/>}
      </div>
    );
  }

  return (
    <div style={cl.app}>
      <div style={cl.header}>
        <div style={{display:'flex',alignItems:'center',gap:9}}><Wordmark/>{version&&<span style={{fontSize:10,color:'var(--text-4)',fontFamily:'var(--font-mono)'}}>v{version}</span>}</div>
        <AccountChip connected={connected} user={user} open={menuOpen}
          onToggle={()=>setMenuOpen(o=>!o)}
          onLogin={()=>{setMenuOpen(false);doLogin();}}
          onLogout={doLogout}/>
      </div>

      {screen==='main'&&(
        <div style={cl.scroll} ref={scrollRef}>
{notif&&!isFinalRound&&<ResubBanner track={notif} onResub={()=>{setNotif(null);resubmit(notif);}} onClose={()=>setNotif(null)}/>}

          {now&&(
            <div style={cl.np}>
              <div style={cl.npLabel}><span style={cl.npDot}></span>EN LECTURE</div>
              <div style={{display:'flex',gap:13,alignItems:'center'}}>
                <Cover seed={'np-'+now.title} src={now.album_art} size={62} radius={12}/>
                <div style={{flex:1,minWidth:0}}>
                  <div style={cl.npTitle}>{now.title}</div>
                  <div style={cl.npArtist}>{now.artist}</div>
                  <div style={cl.progress}>
                    <div style={{...cl.progressFill,width:`${now.total>0?(now.elapsed/now.total)*100:0}%`}}></div>
                  </div>
                  <div style={cl.npTimes}><span>{fmt(now.elapsed)}</span><span>{fmt(now.total)}</span></div>
                </div>
              </div>
            </div>
          )}

          {roundOpen&&!isFinalRound&&(
            <div style={cl.roundHead}>
              <div>
                <div style={cl.sectionKick}>LE PROCHAIN MORCEAU</div>
                <div style={cl.sectionTitle}>SE DÉCIDE <span style={{color:'var(--gold)',position:'relative'}}>MAINTENANT !<Burst/></span></div>
              </div>
              {timer!==null&&<Countdown s={timer}/>}
            </div>
          )}

          {/* ── Round final : dernier morceau de la session (vote clos) ── */}
          {roundOpen&&isFinalRound&&(
            <div style={{textAlign:'center',padding:'26px 12px',marginBottom:12,background:'var(--surface)',border:'1px solid var(--line-soft)',borderRadius:'var(--r-lg)'}}>
              <div style={{fontSize:11,letterSpacing:2,fontWeight:700,color:'var(--gold)',marginBottom:8}}>FIN DE SESSION</div>
              <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:22,lineHeight:1.15}}>Dernier morceau<br/>de la session</div>
              <div style={{fontSize:13.5,color:'var(--text-3)',marginTop:10}}>Merci d'avoir participé ! À bientôt</div>
            </div>
          )}

          {!roundOpen&&!roundClosed&&(
            <div style={{display:'flex',justifyContent:'center',padding:'32px 0'}}><div style={cl.spinner}></div></div>
          )}

          {roundClosed&&candidates.length>0&&(
            <div style={{marginBottom:10}}>
              <div style={{...cl.sectionKick,marginBottom:6}}>RÉSULTAT DU ROUND</div>
              <div style={{display:'flex',flexDirection:'column',gap:10}}>
                {candidates.map((c,i)=>{
                  const isWinner=winnerQobuzId&&c.qobuzId===winnerQobuzId;
                  return(
                    <div key={c.id} style={{...cl.cand,...(isWinner?{...cl.candLeader,animation:'cmPop .4s cubic-bezier(.2,.9,.2,1), cmWinGlow 1.3s ease-in-out .4s infinite'}:{opacity:.35,filter:'grayscale(0.7)'})}} >
                      <div style={{display:'flex',gap:10,alignItems:'center'}}>
                        <div style={{...cl.rankNum,color:isWinner?'var(--gold)':'var(--text-3)'}} className="cm-mono">
                          {isWinner?<CMI.trophy style={{width:20,height:20}}/>:i+1}
                        </div>
                        <Cover seed={c.id+c.title} src={c.album_art} size={isWinner?56:50} radius={11}/>
                        <div style={{flex:1,minWidth:0}}>
                          <div style={{...cl.candTitle,fontSize:isWinner?16:15}}>{c.title}</div>
                          <div style={cl.candArtist}>{c.artist}</div>
                        </div>
                        <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:2,minWidth:32}}>
                          <span className="cm-mono" style={{fontSize:18,fontWeight:700,lineHeight:1,color:isWinner?'var(--gold)':'var(--text)'}}>{c.votes}</span>
                          <div style={cl.voteLabel}>{c.votes>1?'votes':'vote'}</div>
                        </div>
                      </div>
                    </div>
                  );
                })}
              </div>
              <div style={{display:'flex',justifyContent:'center',padding:'20px 0 8px'}}><div style={cl.spinner}></div></div>
            </div>
          )}

          {roundOpen&&hasActed&&myAction?.type==='device_blocked'&&<ActionStatus action={myAction} cands={candidates}/>}

          {roundOpen&&(
            <FlipList style={{display:'flex',flexDirection:'column',gap:10}} flipKey={candidates.map(c=>c.id).join(',')}>
              {candidates.map((c,i)=>(
                <div key={c.id} data-flip={c.id}>
                  <Candidate c={c} rank={i} leader={i===0&&c.votes>0}
                    voted={(myAction?.id===c.id)||(myVote?.candidate_id===c.id)}
                    voteLoading={votingId===c.id}
                    locked={hasActed||isFinalRound} altBy={altBy}
                    onVote={()=>{requireSSO(()=>vote(c.id),{type:'vote',candidateId:c.id,roundId:roundRef.current?.round?.id});}}
                    previewing={preview===c.id}
                    previewLoading={previewLoading&&preview===c.id}
                    onPreview={()=>handlePreview(c.id,c.title,c.artist)}/>
                </div>
              ))}
              {candidates.length===0&&!isFinalRound&&<div style={{color:'var(--text-3)',textAlign:'center',padding:'20px 0'}}>Sois le premier à proposer un morceau !</div>}
            </FlipList>
          )}

          {roundOpen&&!isFinalRound&&(
            <button style={cl.searchCta} onClick={()=>setScreen('search')}>
              <CMI.plus style={{width:18,height:18}}/> Ajouter un morceau
            </button>
          )}
          {/* marginTop:auto → le lien reste collé en bas quand le contenu est court
              (ex: round terminé + spinner) au lieu de « remonter » ; défile normalement
              quand le contenu déborde. */}
          <div style={{textAlign:'center',padding:'20px 0 8px',marginTop:'auto'}}>
            <button onClick={()=>setLegalOpen(true)} style={{background:'none',border:'none',color:'var(--text-4)',fontSize:11,cursor:'pointer',fontFamily:'var(--font-ui)',textDecoration:'underline',textUnderlineOffset:2}}>Mentions légales & Confidentialité</button>
          </div>
        </div>
      )}

      {screen==='search'&&(
        <SearchScreen q={q} results={results} onQuery={setQ}
          onBack={()=>{handlePreview(null,null,null);setScreen('main');}} onPick={openQualify}
          mySubs={mySubs} onResub={(t)=>{handlePreview(null,null,null);resubmit(t);}}
          preview={preview} previewLoading={previewLoading} onPreview={(id,title,artist)=>handlePreview(id,title,artist)}/>
      )}

      {qualify&&<QualifyModal data={qualify} cands={candidates} myAction={myAction||myVote}
        actedProposed={iProposed} secsLeft={timer}
        onClose={()=>{const back=qualify.state==='confirmed';setQualify(null);if(back)setScreen('main');}}
        onPropose={propose} onVote={(id)=>vote(id)}/>}
      {sso&&<SSOSheet onClose={()=>setSso(null)} onConnect={doLogin}/>}
      {winner&&<WinnerOverlay c={winner}/>}
      {legalOpen&&<LegalModal onClose={()=>setLegalOpen(false)}/>}
    </div>
  );
}

// ── Composants visuels (inchangés du design) ──────────────────
// Ligne de statut unique « Restez à l'écoute… » (points animés en JS — pas d'animation
// CSS ajoutée en cours de vie, conforme règle v2.6.6). Même message que le display.
function WaitDots(){
  const [dots,setDots]=useState(0);
  useEffect(()=>{
    const t=setInterval(()=>setDots(d=>(d+1)%4),540);
    return()=>clearInterval(t);
  },[]);
  return (
    <div style={{fontSize:14.5,color:'var(--text-3)',lineHeight:1.5}}>
      Restez à l'écoute<span style={{display:'inline-block',width:'2ch',textAlign:'left'}}>{'.'.repeat(dots)}</span>
    </div>
  );
}

function Wordmark(){
  return (<div style={{display:'flex',alignItems:'center',gap:11}}>
    <img src="logo-pic.png" alt="Le pic" style={{height:30,width:'auto',display:'block'}}/>
    <span style={{paddingLeft:11,borderLeft:'2px solid var(--line)',fontFamily:'var(--font-display)',fontWeight:700,fontSize:19,letterSpacing:-.3,color:'var(--text)'}}>Crowd<span style={{color:'var(--gold)'}}>Mix</span></span>
  </div>);
}

function Burst(){
  return (<svg width="46" height="42" viewBox="0 0 46 42" style={{position:'absolute',right:-40,top:-4,overflow:'visible'}}>
    {[...Array(4)].map((_,i)=>{
      const a=(-52+i*30)*Math.PI/180,r1=7,r2=20;
      return <line key={i} x1={8+Math.cos(a)*r1} y1={18+Math.sin(a)*r1} x2={8+Math.cos(a)*r2} y2={18+Math.sin(a)*r2}
        stroke="var(--gold)" strokeWidth="2.4" strokeLinecap="round"/>;
    })}
  </svg>);
}

function AccountChip({connected,user,open,onToggle,onLogin,onLogout}){
  if(!connected) return <button style={cl.loginBtn} onClick={onLogin}>Se connecter</button>;
  const initials=(user?.name||'?').split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase();
  return (
    <div style={{position:'relative'}}>
      <button style={cl.acctChip} onClick={onToggle}>
        {user?.avatar?<img src={user.avatar} style={{...cl.avatar,background:'none'}} alt=""/>:<span style={cl.avatar}>{initials}</span>}
        <span style={cl.acctName}>{user?.name}</span>
        <CMI.chevron style={{width:14,height:14,color:'var(--text-3)',transform:open?'rotate(180deg)':'none',transition:'transform .2s'}}/>
      </button>
      {open&&(
        <div style={cl.acctMenu}>
          <div style={cl.acctMenuHead}>
            {user?.avatar?<img src={user.avatar} style={{...cl.avatar,width:34,height:34}} alt=""/>:<span style={{...cl.avatar,width:34,height:34,fontSize:13}}>{initials}</span>}
            <div style={{minWidth:0}}>
              <div style={{fontWeight:700,fontSize:14,color:'var(--text)',whiteSpace:'nowrap'}}>{user?.name||user?.email}</div>
              <div style={{fontSize:11.5,color:'var(--text-3)'}}>Connecté via Google</div>
            </div>
          </div>
          <button style={cl.acctLogout} onClick={onLogout}>
            <CMI.logout style={{width:16,height:16}}/> Se déconnecter
          </button>
        </div>
      )}
    </div>
  );
}

function Countdown({s}){
  const closing=s<=30;
  const zero=s===0;
  // ── Fix fantôme "00:01"/"00:00" (iOS Safari) ──
  // Ajouter `animation:cmBlink` en cours de vie promeut l'élément dans sa propre
  // couche GPU. Dans un conteneur overflow scrollable fraîchement remonté (retour
  // de search après un propose), WebKit n'invalide pas la tuile du scroll layer
  // contenant l'ancien rendu ("00:01") → pixels fantômes visibles ~2s à côté du
  // "00:00" clignotant. Le clignotement est donc piloté en JS via `opacity` inline :
  // repaint par invalidation normale, aucune promotion de couche, jamais.
  const [blinkOn,setBlinkOn]=React.useState(true);
  React.useEffect(()=>{
    if(!zero){setBlinkOn(true);return;}
    const t=setInterval(()=>setBlinkOn(v=>!v),300); // 300ms = demi-cycle de cmBlink .6s (aligné sur le display)
    return()=>clearInterval(t);
  },[zero]);
  const col=closing?'#FF8B80':'var(--text)';
  const iconCol=closing?'var(--red)':'var(--gold)';
  const blink={opacity:zero&&!blinkOn?0.15:1};
  return (
    <div style={{display:'flex',alignItems:'center',gap:10}}>
      <CMI.clock style={{width:26,height:26,color:iconCol,...blink}}/>
      <span className="cm-mono" style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:36,letterSpacing:.5,lineHeight:1,color:col,...blink}}>{fmt(s)}</span>
    </div>
  );
}

function Candidate({c,rank,leader,voted,voteLoading,locked,altBy,onVote,previewing,previewLoading,onPreview}){
  const showBy=!c.isFallback&&altBy&&c.by;
  const flash=window.useVoteFlash(c.votes);
  // Flash doré : anneau fin + halo + teinte dorée INTÉRIEURE (inset → joue sur la couleur
  // de fond sans masquer le dégradé du leader). Montée douce, redescente lente.
  const flashStyle=flash
    ?{boxShadow:'0 0 0 1.5px var(--gold-hi), 0 0 24px 2px rgba(224,180,90,.85), inset 0 0 32px rgba(224,180,90,.42)',transition:'box-shadow .18s ease-out'}
    :{transition:'box-shadow 1.1s ease-out'};
  return (
    <div style={{...cl.cand,...(leader?cl.candLeader:{}),...(voted?cl.candVoted:{}),...flashStyle}}>
      <div style={{display:'flex',gap:10,alignItems:'center'}}>
        <div style={{...cl.rankNum,color:leader?'var(--gold)':'var(--text-3)'}} className="cm-mono">{rank+1}</div>
        <Cover seed={c.id+c.title} src={c.album_art} size={leader?56:50} radius={11}/>
        <div style={{flex:1,minWidth:0}}>
          <div style={{...cl.candTitle,fontSize:leader?16:15}}>{c.title}</div>
          {showBy
            ?<div style={{...cl.candArtist,color:'var(--gold-hi)',fontWeight:600}}>{c.mine?'Votre proposition':`proposé par ${c.by}`}</div>
            :<div style={cl.candArtist}>{c.artist}</div>}
        </div>
        <div style={{display:'flex',alignItems:'center',gap:8}}>
          <button style={{...cl.previewBtn,...(previewing&&!previewLoading?cl.previewBtnOn:{})}}
            onClick={(e)=>{e.stopPropagation();onPreview();}}>
            {previewLoading?<div style={cl.previewSpinner}/>:previewing?<CMI.pause style={{width:13,height:13}}/>:<CMI.play style={{width:13,height:13}}/>}
          </button>
          <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:2,minWidth:32}}>
            <span className="cm-mono" style={{fontSize:18,fontWeight:700,lineHeight:1,color:leader?'var(--gold)':'var(--text)'}}>{c.votes}</span>
            <div style={cl.voteLabel}>{c.votes>1?'votes':'vote'}</div>
          </div>
          <button style={{...cl.addBtn,...(voted?{background:'var(--green)'}:{}),...(leader&&!voted?{background:'var(--gold-hi)'}:{}),...((locked||voteLoading)&&!voted?{opacity:voteLoading?1:.35,cursor:'default'}:{})}}
            disabled={locked||voteLoading} onClick={onVote}>
            {voteLoading?<div style={cl.addBtnSpinner}/>:voted?<CMI.check style={{width:15,height:15}}/>:<CMI.plus style={{width:15,height:15}}/>}
          </button>
        </div>
      </div>
    </div>
  );
}

function ActionStatus({action,cands}){
  if(action?.type==='device_blocked'){
    return (<div style={{...cl.actionStatus,background:'rgba(228,86,74,0.1)',borderColor:'rgba(228,86,74,0.35)',color:'#FF8B80'}}>
      <CMI.close style={{width:16,height:16,flexShrink:0}}/>
      <span>Un vote a déjà été enregistré depuis cet appareil pour ce round.</span>
    </div>);
  }
  const c=cands.find(x=>x.id===action?.id||x.qobuzId===action?.id);
  if(!c) return null;
  return (<div style={cl.actionStatus}>
    <CMI.check style={{width:16,height:16,color:'var(--green)'}}/>
    <span>{action.type==='submit'?'Vous avez proposé':'Vous avez voté pour'} <b style={{color:'var(--text)'}}>{c.title}</b></span>
  </div>);
}

function ResubBanner({track,onResub,onClose}){
  return (<div style={cl.resub}>
    <CMI.bell style={{width:18,height:18,color:'var(--gold)',flexShrink:0}}/>
    <div style={{flex:1}}>
      <div style={{fontWeight:700,fontSize:13.5,color:'var(--text)'}}>« {track.title} » n'a pas gagné</div>
      <div style={{fontSize:12,color:'var(--text-3)'}}>Tentez votre chance au nouveau round.</div>
    </div>
    <button style={cl.resubBtn} onClick={onResub}>Re-proposer</button>
    <button style={cl.resubClose} onClick={onClose}><CMI.close style={{width:14,height:14}}/></button>
  </div>);
}

function SearchScreen({q,results,onQuery,onBack,onPick,mySubs,onResub,preview,previewLoading,onPreview}){
  const ref=useRef(null);
  useEffect(()=>{ref.current&&ref.current.focus();},[]);
  const showHistory=!q.trim()&&(mySubs||[]).length>0;
  return (
    <div style={cl.scroll}>
      <div style={{display:'flex',gap:10,alignItems:'center',marginBottom:14}}>
        <button style={{...cl.previewBtn,flexShrink:0}} onClick={onBack}>
          <CMI.chevron style={{width:18,height:18,transform:'rotate(90deg)'}}/>
        </button>
        <div style={cl.searchField}>
          <CMI.search style={{width:18,height:18,color:'var(--text-3)'}}/>
          <input ref={ref} value={q} onChange={e=>onQuery(e.target.value)}
            placeholder="Titre, artiste…" style={cl.searchInput}/>
          {q&&(
            <button onClick={()=>{onQuery('');ref.current&&ref.current.focus();}}
              aria-label="Effacer"
              style={{flexShrink:0,width:24,height:24,borderRadius:99,border:'none',background:'var(--surface-3)',color:'var(--text-2)',display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',padding:0}}>
              <CMI.close style={{width:13,height:13}}/>
            </button>
          )}
        </div>
      </div>
      {showHistory&&(
        <div style={{marginBottom:14}}>
          <div style={{...cl.sectionKick,marginBottom:8}}>MES DERNIERS TITRES</div>
          <div style={{display:'flex',flexDirection:'column',gap:4}}>
            {mySubs.map(t=>{
              const isPreviewing=preview===t.id;
              const isLoading=isPreviewing&&previewLoading;
              return (
              <div key={t.id} style={{...cl.resultRow,cursor:'default'}}>
                <Cover seed={t.id+t.title} src={t.album_art} size={48} radius={9}/>
                <div style={{flex:1,minWidth:0}}>
                  <div style={cl.candTitle}>{t.title}</div>
                  <div style={cl.candArtist}>{t.artist}</div>
                </div>
                <button style={{...cl.previewBtn,...(isPreviewing&&!isLoading?cl.previewBtnOn:{})}}
                  onClick={(e)=>{e.stopPropagation();onPreview(t.id,t.title,t.artist);}}>
                  {isLoading?<div style={cl.previewSpinner}/>:isPreviewing?<CMI.pause style={{width:14,height:14}}/>:<CMI.play style={{width:14,height:14}}/>}
                </button>
                <button style={cl.addBtn} onClick={(e)=>{e.stopPropagation();onResub(t);}}>
                  <CMI.plus style={{width:18,height:18}}/>
                </button>
              </div>
            );
            })}
          </div>
        </div>
      )}
      <div style={{display:'flex',flexDirection:'column',gap:4}}>
        {results.map(t=>{
          const isPreviewing=preview===t.id;
          const isLoading=isPreviewing&&previewLoading;
          return (
          <div key={t.id} style={{...cl.resultRow,cursor:'default'}}>
            <Cover seed={t.id+t.title} src={t.album_art} size={48} radius={9}/>
            <div style={{flex:1,minWidth:0}}>
              <div style={cl.candTitle}>{t.title}</div>
              <div style={cl.candArtist}>{t.artist}</div>
            </div>
            <button style={{...cl.previewBtn,...(isPreviewing&&!isLoading?cl.previewBtnOn:{})}}
              onClick={(e)=>{e.stopPropagation();onPreview(t.id,t.title,t.artist);}}>
              {isLoading?<div style={cl.previewSpinner}/>:isPreviewing?<CMI.pause style={{width:14,height:14}}/>:<CMI.play style={{width:14,height:14}}/>}
            </button>
            <button style={cl.addBtn} onClick={(e)=>{e.stopPropagation();onPick(t);}}>
              <CMI.plus style={{width:18,height:18}}/>
            </button>
          </div>
        );
        })}
      </div>
    </div>
  );
}

function QualifyModal({data,cands,myAction,actedProposed,secsLeft,onClose,onPropose,onVote}){
  const {track,state,res}=data;
  const alreadyCand=cands.find(c=>c.qobuzId===track.id||c.id===track.id);
  const dCol={accepte:'var(--green)',refuse:'var(--red)'}[res?.decision]||'var(--red)';
  const dLabel={accepte:'Accepté',refuse:'Refusé'}[res?.decision]||'Refusé';
  return (
    <div style={cl.overlay} onClick={onClose}>
      <div style={cl.sheet} onClick={e=>e.stopPropagation()}>
        <div style={cl.grip}></div>
        <div style={{display:'flex',gap:14,alignItems:'center',marginBottom:18}}>
          <Cover seed={track.id+track.title} src={track.album_art} size={64} radius={13}/>
          <div style={{flex:1,minWidth:0}}>
            <div style={{...cl.candTitle,fontSize:17}}>{track.title}</div>
            <div style={cl.candArtist}>{track.artist} · {track.dur}</div>
          </div>
        </div>
        {state==='loading'&&(
          <div style={cl.aiLoading}>
            <div style={cl.spinner}></div>
            <div style={{fontWeight:700,color:'var(--text)'}}>Vérification en cours…</div>
          </div>
        )}
        {state==='submitting'&&(
          <div style={cl.aiLoading}>
            <div style={cl.spinner}></div>
            <div style={{fontWeight:700,color:'var(--text)'}}>Proposition en cours…</div>
          </div>
        )}
        {state==='waiting_round'&&(
          <div style={cl.aiLoading}>
            <div style={cl.spinner}></div>
            <div style={{fontWeight:700,color:'var(--text)'}}>Votre morceau sera proposé au prochain round…</div>
          </div>
        )}
        {/* ── Déjà agi ce round : on laisse parcourir mais on bloque une 2ᵉ action ── */}
        {state==='already_acted'&&(
          <div style={{display:'flex',flexDirection:'column',gap:14}}>
            <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:10,padding:'18px 16px',borderRadius:'var(--r-md)',background:'rgba(201,162,77,0.10)',border:'1px solid rgba(201,162,77,0.40)',textAlign:'center'}}>
              <div style={{width:44,height:44,borderRadius:99,background:'var(--gold)',display:'flex',alignItems:'center',justifyContent:'center'}}>
                <CMI.check style={{width:24,height:24,color:'#1A1208'}}/>
              </div>
              <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:19,color:'var(--text)'}}>{actedProposed?'Vous avez déjà proposé un morceau':'Vous avez déjà voté'}</div>
              <div style={{fontSize:13.5,color:'var(--text-2)',lineHeight:1.5}}>Une seule action par round. {secsLeft!=null&&secsLeft>0?<>Prochain round dans environ <b style={{color:'var(--text)'}}>{fmt(secsLeft)}</b>.</>:'Le prochain round arrive très bientôt.'} En attendant, vous pouvez continuer à parcourir le catalogue et écouter des extraits.</div>
            </div>
            <button style={cl.primaryBtn} onClick={onClose}>Continuer à parcourir</button>
          </div>
        )}
        {/* ── Confirmation : le morceau a été ajouté au round (fermeture manuelle) ── */}
        {state==='confirmed'&&(
          <div style={{display:'flex',flexDirection:'column',gap:14}}>
            <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:10,padding:'18px 14px',borderRadius:'var(--r-md)',background:'rgba(91,224,139,0.10)',border:'1px solid rgba(91,224,139,0.35)',textAlign:'center'}}>
              <div style={{width:44,height:44,borderRadius:99,background:'var(--green)',display:'flex',alignItems:'center',justifyContent:'center'}}>
                <CMI.check style={{width:24,height:24,color:'#06210F'}}/>
              </div>
              <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:19,color:'var(--text)'}}>Morceau ajouté au round !</div>
              <div style={{fontSize:13.5,color:'var(--text-2)',lineHeight:1.5}}>Votre proposition est en lice et compte déjà pour un vote. Que le meilleur gagne !</div>
            </div>
            <button style={cl.primaryBtn} onClick={onClose}>Fermer</button>
          </div>
        )}
        {state==='done'&&res&&(<>
          {res.decision==='accepte'?(
            <div style={{display:'flex',flexDirection:'column',gap:10}}>
              <div style={{display:'flex',alignItems:'center',gap:10,padding:'11px 14px',borderRadius:'var(--r-sm)',background:'rgba(91,224,139,0.10)',border:'1px solid rgba(91,224,139,0.30)'}}>
                <CMI.check style={{width:18,height:18,color:'var(--green)',flexShrink:0}}/>
                <span style={{fontSize:14,fontWeight:600,color:'var(--green)'}}>Ce morceau est disponible</span>
              </div>
              <button style={cl.primaryBtn} onClick={()=>alreadyCand?onVote(alreadyCand.id):onPropose(track)}>
                {alreadyCand?'Voter pour ce morceau':(myAction?'Action déjà utilisée ce round':'Proposer ce morceau')}
              </button>
              <button style={cl.ghostBtn} onClick={onClose}>← Retour</button>
            </div>
          ):(<>
            {/* ── Refus : bannière pleine largeur bien visible + motif mis en avant ── */}
            <div style={{marginBottom:18}}>
              <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:10,padding:'18px 16px',borderRadius:'var(--r-md)',background:'rgba(228,86,74,0.12)',border:'1px solid rgba(228,86,74,0.45)',textAlign:'center'}}>
                <div style={{width:44,height:44,borderRadius:99,background:'var(--red)',display:'flex',alignItems:'center',justifyContent:'center'}}>
                  <CMI.close style={{width:22,height:22,color:'#fff'}}/>
                </div>
                <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:19,color:'#FF8B80'}}>Morceau refusé</div>
                {res.analysis&&<div style={{fontSize:14,color:'var(--text)',lineHeight:1.55,fontWeight:500}}>{res.analysis}</div>}
              </div>
            </div>
            <button style={cl.primaryBtn} onClick={onClose}>Chercher un autre titre</button>
          </>)}
        </>)}
      </div>
    </div>
  );
}

function SSOSheet({onClose,onConnect}){
  return (
    <div style={cl.overlay} onClick={onClose}>
      <div style={{...cl.sheet,padding:'20px 24px 44px'}} onClick={e=>e.stopPropagation()}>
        <div style={cl.grip}></div>
        <div style={{textAlign:'center',marginBottom:8,padding:'10px 0 4px'}}>
          <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:24,color:'var(--text)',marginBottom:10}}>Identifiez-vous</div>
          <div style={{fontSize:14.5,color:'var(--text-3)',lineHeight:1.5}}>Connectez-vous en un clic pour voter<br/>et proposer des morceaux</div>
        </div>
        <div style={{display:'flex',flexDirection:'column',gap:11,marginTop:28}}>
          <button style={{...cl.ssoBtn,padding:'16px',fontSize:15.5}} onClick={onConnect}>
            <CMI.google style={{width:22,height:22}}/> Continuer avec Google
          </button>
        </div>
      </div>
    </div>
  );
}

function WinnerOverlay({c}){
  return (<div style={cl.winnerOverlay}>
    <div style={cl.winnerCard}>
      <CMI.trophy style={{width:30,height:30,color:'var(--gold)'}}/>
      <div style={{fontSize:12,letterSpacing:2,color:'var(--gold)',fontWeight:700,marginTop:10}}>GAGNANT DU ROUND</div>
      <Cover seed={c.id+c.title} src={c.album_art} size={92} radius={16} style={{margin:'14px auto'}}/>
      <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:19,color:'var(--text)'}}>{c.title}</div>
      <div style={cl.candArtist}>{c.artist}</div>
      <div style={{marginTop:10,color:'var(--gold)',fontWeight:700}} className="cm-mono">{c.votes} {c.votes>1?'votes':'vote'}</div>
    </div>
  </div>);
}

function LegalModal({onClose}){
  const s={h2:{fontFamily:'var(--font-display)',fontWeight:700,fontSize:16,color:'var(--text)',margin:'20px 0 6px'},p:{fontSize:13,color:'var(--text-3)',lineHeight:1.6,margin:'0 0 8px'},a:{color:'var(--gold)'}};
  return (
    <div style={cl.overlay} onClick={onClose}>
      <div style={{...cl.sheet,maxHeight:'80vh',overflowY:'auto',paddingBottom:48}} onClick={e=>e.stopPropagation()}>
        <div style={cl.grip}/>
        <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:19,color:'var(--text)',marginBottom:4}}>Mentions légales</div>
        <p style={s.p}><strong style={{color:'var(--text-2)'}}>Éditeur</strong><br/>TURING 22 SARL au capital de 506 500,00 €<br/>22 rue Alan Turing – 63000 Clermont-Ferrand<br/>RCS Clermont-Ferrand n° 484 579 883<br/>Directeur de publication : M. Tristan COLOMBET</p>
        <p style={s.p}><strong style={{color:'var(--text-2)'}}>Hébergeur</strong><br/>Railway Corp – 548 Market St PMB 72287<br/>San Francisco, CA 94104, États-Unis</p>

        <div style={{height:1,background:'var(--line-soft)',margin:'16px 0'}}/>

        <div style={{fontFamily:'var(--font-display)',fontWeight:700,fontSize:19,color:'var(--text)',marginBottom:4}}>Politique de confidentialité</div>
        <p style={s.p}><strong style={{color:'var(--text-2)'}}>Responsable de traitement</strong><br/>TURING 22 SARL – contact : <a href="mailto:contact@pic.life" style={s.a}>contact@pic.life</a></p>
        <p style={s.p}><strong style={{color:'var(--text-2)'}}>Données collectées</strong><br/>Lors de la connexion Google : adresse e-mail, nom, photo de profil. Un identifiant d'appareil local est généré pour limiter la fraude. Votre historique de votes est conservé pour la durée du service.</p>
        <p style={s.p}><strong style={{color:'var(--text-2)'}}>Base légale</strong><br/>Votre consentement (art. 6.1.a RGPD), exprimé lors de la connexion.</p>
        <p style={s.p}><strong style={{color:'var(--text-2)'}}>Sous-traitants</strong><br/>Google LLC (authentification), Supabase Inc (base de données), Railway Corp (hébergement) — transferts USA encadrés par clauses contractuelles types.</p>
        <p style={s.p}><strong style={{color:'var(--text-2)'}}>Conservation</strong><br/>Données conservées pendant la durée du service + 12 mois, ou supprimées sur demande.</p>
        <p style={s.p}><strong style={{color:'var(--text-2)'}}>Vos droits</strong><br/>Accès, rectification, suppression, portabilité, limitation. Demande à <a href="mailto:contact@pic.life" style={s.a}>contact@pic.life</a>. Réclamation possible auprès de la <a href="https://www.cnil.fr" target="_blank" style={s.a}>CNIL</a>.</p>

        <button onClick={onClose} style={{...cl.ghostBtn,marginTop:8}}>Fermer</button>
      </div>
    </div>
  );
}

window.ClientApp=ClientApp;
