const BASE = {screen:'onboarding', pendingRegistration:false, region:'kr', detailId:'m1', tempDetail:null, rulesOpen:false, ruleCheck:false, photoConsent:false, joined:{}, completed:[], reconfirmOpen:false, reconfirmedMap:{}, waitlists:{}, chatEnded:false, shared:null, conn:{}, survey:null, interest:12, interested:false, votes:{}, qtab:0, toast:null, chatMsgs:[], chatMsgsByMeetup:{}, chatInput:'', dismissedCancellations:[], composeOpen:false, draftTag:1, draftTitle:'', draftBody:'', extraPosts:[], policyOpen:false, regionEdit:null, regionDraft:'', legalOpen:null, reportOpen:false, hostCompleteIdx:null, applicantsIdx:null, editingMeetupIdx:null, hostLimitNoticeOpen:false, reviewPopup:null, eventInterest:{}, eventLikes:{}, eventApplied:{}, eventReason:'', eventGender:null, companionScore:58, qnaDetailPost:null, postComments:{}, commentDraft:'', regionVals:{cur:'영국', move:'9월 · 서울', interest:'미국'}, stayType:'교환학기', profile:{nickname:'유학생22', school:'', bio:''}, newMeetup:{title:'', when:'', date:'', time:'', cap:'6', place:'', purpose:'', purposes:[], mood:'', desc:''}, exploreIdx:0, exploreFilter:'kr', consented:false, consents:{terms:false, privacy:false, collect:false, age:false, safety:false, marketing:false, location:false}, qnaSearch:'', storyComposeOpen:false, storyDraftTopic:'새로 왔어요', storyDraftBody:'', storyDraftRelated:'', myStoryPosts:[], storyPage:1, reviewComposeOpen:false, reviewDraftMeetupId:'', reviewDraftBody:'', myReviewPosts:[], reviewPage:1, localPage:1, localComposeOpen:false, localDraftBody:'', signupEmail:'', signupPassword:'', myHostedMeetups:[], acctEmail:'me@example.com', acctPhone:'', pwCurrent:'', pwNew:'', pwConfirm:'', acctEdit:null, profileTab:0, locGranted:false, locPromptDismissed:false, locPromptOpen:false, regName:'', schoolVerifyChoice:'none', schoolEmail:'', schoolEmailStep:0, schoolCode:'', schoolBadges:[], schoolDocsStatus:null, schoolBadgeType:'재학 인증', authMethod:'email', confirmedAuthMethod:'', schoolPublic:true, moveDate:'', moveCountry:'대한민국', regGender:'n', phoneCode:'', phoneCodeSent:false, phoneVerified:false, attendeeStatus:{}, exploreTag:'오늘의 추천', viewAsUser:'A', avoidUserConfirm:null, withdrawConfirmOpen:false, authChoiceOpen:false, authStatus:'anonymous', onboardingCompleted:false, forceActivityPicker:false};
const SCREENS = {onboarding:WScrOnboarding, signup:WScrSignup, register:WScrRegister, emailverify:WScrEmailVerify, resetpassword:WScrResetPassword, consent:WScrConsent, setup:WScrSetup, schoolemail:WScrSchoolEmail, schooldocs:WScrSchoolDocs, home:WScrHome, homecur:WScrHomeCur, homeabroad:WScrHomeAbroad, homesnapshot:WScrHomeSnapshot, hometimeline:WScrHomeTimeline, explore:WScrExplore, region:WScrRegion, empty:WScrEmpty, browseregions:WScrBrowseRegions, interestregion:WScrInterestRegion, events:WScrEvents, eventslist:WScrEventsList, eventshighlights:WScrEventsHighlights, eventdetail:WScrEventDetail, eventchat:WScrEventChat, detail:WScrDetail, chat:WScrChat, host:WScrHost, postmeetup:WScrPost, mymeet:WScrMyMeet, mymeetupcoming:WScrMyMeetUpcoming, mymeetpast:WScrMyMeetPast, mymeethosted:WScrMyMeetHosted, qna:WScrQnA, postdetail:WScrPostDetail, seoulall:WScrSeoulAll, savedmeetups:WScrSavedMeetups, profile:WScrProfile, createmeetup:WScrCreateMeetup, attendees:WScrAttendees, privacyavoid:WScrPrivacyAvoid, admin:WScrAdmin};
const HYP = [
 {id:'H1', tone:{bg:'var(--c-primary-bg)', fg:'var(--c-primary)'}, name:'통합 가설 · 안심 진입 & 관계 지속',
  stmt:'모임의 진짜 장벽은 가입이 아니라 "혼자 낯선 모임에 가는 불안감"이며, 한번 실제로 만난 사람은 카드 탐색보다 훨씬 강한 관계 후보가 된다. 발견→신청→참석 구간에서 불안을 낮추는 신호를 배치하고, 모임 후 상호동의 연결과 지역 연속성(런던→서울)으로 관계를 이어주면 신청 전환율·실제 참석률·30일 내 재참석률이 함께 오른다.',
  metrics:['상세 조회→신청 전환율','신청 대비 실제 참석률 (노쇼 역수)','첫 가입→첫 참석 소요 시간','모임 후 친구 연결률','30일 내 두 번째 모임 참석률','귀국 후 첫 모임 참석률'],
  flows:[
   {name:'30초 가입 — 인증 없이 시작', scr:'가입→홈', steps:[
     {s:'onboarding', note:'닉네임만 필수. "미인증" 라벨 대신 인증을 나중에 쌓는 방식임을 카피로 안내해요.'},
     {s:'home', note:'가입 직후 홈 — 프로필 스트립에 완료한 인증만 긍정형 배지로 표시돼요 (사실 확인형 라벨링).'}]},
   {name:'모임 발견 — 불안을 낮추는 카드', scr:'홈', steps:[
     {s:'home', note:'카드마다 목적·분위기 태그와 "혼자 참여 환영" 배지가 노출 — 신청 장벽을 낮추는 핵심 정보예요.'},
     {s:'detail', note:'카드를 누르면 상세로 — 같은 정보가 더 풍부하게 이어져요.'}]},
   {name:'상세에서 신뢰 확인', scr:'상세→호스트', steps:[
     {s:'detail', note:'목적·분위기·언어·규모 태그, 혼자 참가 8명, 스폰서 협찬 배지(투명성), 24시간 전 재확인 예고까지 한 화면에.'},
     {s:'host', note:'호스트는 별점 대신 태그형 후기 — "혼자 온 참가자를 잘 챙겼어요" 같은 불안 해소형 신호가 핵심이에요.'}]},
   {name:'신청 → 수칙 동의 → 대기명단', scr:'상세', steps:[
     {s:'detail', note:'정원 마감 상태 — CTA가 "대기 명단 신청하기"로 바뀌어요.'},
     {s:'detail', patch:{rulesOpen:true}, note:'이용수칙 6개 조항 동의 없이는 신청 불가 — 안전장치가 신청 흐름 안에 들어있어요.'},
     {s:'detail', patch:{joined:{m1:'waitlisted'}}, note:'대기 등록 완료 — 순번과 자동 전달 규칙을 명확히 보여줘요.'}]},
   {name:'노쇼 방지 — 24시간 전 재확인', scr:'내 모임', steps:[
     {s:'mymeet', patch:{joined:{m2:'confirmed'}}, note:'행사 24시간 전 재확인 요청 — 결제 없이 노쇼를 줄이는 MVP 핵심 장치예요. "참석 재확인"을 눌러보세요.'},
     {s:'mymeet', patch:{joined:{m2:'confirmed'}, reconfirmOpen:true}, note:'미응답 시 대기자에게 자리가 넘어간다는 규칙을 시트에서 그대로 안내해요.'}]},
   {name:'참석 전 어색함 풀기 — 채팅방', scr:'채팅방', steps:[
     {s:'chat', patch:{joined:{m1:'confirmed'}}, note:'수칙 요약 핀 고정 + 호스트 환영 메시지 + 아이스브레이커 질문 — 참석 전에 이미 인사가 오가요. 신고 버튼은 입력창 옆 상시 노출.'}]},
   {name:'지역 3분화 — 거주·귀국·관심', scr:'지역 설정', steps:[
     {s:'region', note:'현재 런던 / 9월 서울 귀국 / 관심 지역을 분리 — 귀국 시점에 맞춘 웰컴모임 추천의 데이터 기반이에요.'}]},
   {name:'사람 평가가 아닌 활동 선택', scr:'탐색', steps:[
     {s:'explore', note:'"인사하기/다음에" 대신 같이 할 활동 4종을 골라요. 프로필도 사진보다 정보가 먼저 — 데이팅앱 오해를 구조로 차단해요.'}]},
   {name:'모임 후 "오늘 만난 사람"', scr:'모임 후', steps:[
     {s:'postmeetup', note:'연결 요청은 상호 동의제 — 일방적 연결 없음. 안전·만족도 설문(30초)이 같은 화면에 붙어요.'}]},
   {name:'채팅방 종료 전 연락처 공유', scr:'채팅방', steps:[
     {s:'chat', patch:{chatEnded:true}, note:'채팅방이 보관되기 전, 연락처 공유 여부를 명시적으로 선택 — 관계가 앱 밖으로 자연스럽게 이어져요.'}]},
   {name:'귀국 연결 — 서울 웰컴모임 추천', scr:'모임 후→상세', steps:[
     {s:'postmeetup', patch:{conn:{A:'requested'}, survey:'ok'}, note:'하단 추천 카드 — "오늘 만난 멤버 2명이 참가 예정" + 9월 귀국 일정 기반. 런던의 관계가 서울로 이어지는 지점이에요.'},
     {s:'detail', patch:{detailId:'m6'}, note:'추천된 서울 웰컴모임 상세 — 런던 멤버 재회가 셀링 포인트로 노출돼요.'}]},
   {name:'빈 지역 → 수요 수집', scr:'빈 지역', steps:[
     {s:'empty', note:'모임이 없는 지역은 빈 화면 대신 수요 수집 — 관심 등록·투표·"5명이 모이면 개설"·가까운 도시 추천. 해외 확장 리스크 완화 장치예요.'}]},
  ]},
];
let __navFromPopstate = false; // popstate 처리 중에는 go()가 새 history 항목을 만들지 않음
function WebApp() {
  const {useState, useEffect, useRef} = React;
  const [S, setS] = useState(()=>{
    // Supabase 인증 콜백 방어 — 이메일 인증 링크를 눌러 돌아오면 주소 해시에 access_token 등이 실려있는데,
    // 이 해시를 우리 라우팅 로직이 건드리면(history.replaceState 등) Supabase 클라이언트가 세션을 읽기 전에 유실된다.
    // 이런 해시가 보이면 아래 라우팅 재작성 로직을 전부 건너뛰고 해시를 그대로 둔다 — authGetSession()이 세션을 다 읽고 나면 이후 useEffect에서 정리한다.
    // Supabase 버전·프로젝트 설정에 따라 콜백이 해시(#access_token=...)로 오거나, PKCE 방식이면 쿼리스트링(?code=...)으로 옴 — 둘 다 감지해야 함.
    const isAuthCallback = /access_token=|refresh_token=|type=(signup|recovery|email_change|magiclink)/.test(location.hash) || /[?&](code|type)=/.test(location.search);
    if (isAuthCallback) {
      const restored0 = loadSession(BASE);
      // 비밀번호 재설정 메일 링크(type=recovery)는 이메일 인증 화면이 아니라 새 비밀번호 입력 화면으로 보낸다.
      if (/type=recovery/.test(location.hash) || /type=recovery/.test(location.search)) return {...(restored0 || BASE), screen:'resetpassword', pendingRegistration:false};
      return {...(restored0 || BASE), screen:'emailverify', pendingRegistration:true};
    }
    const restored = loadSession(BASE);
    let base = restored || {...BASE};
    // 공개 모드에서는 어떤 저장된 값도 인증 상태로 신뢰하지 않음 — 데모 세션 잔존·수동 조작·손상된 값 모두 여기서 무력화
    if (!DEMO_MODE && base.authStatus==='authenticated') {
      base = {...base, authStatus:'anonymous', pendingRegistration:false, screen:'onboarding'};
    }
    // 손상된 세션 방어 — screen 값이 알 수 없는 문자열이면(수동 조작·구버전 잔존 등) 인증 여부에 따라 안전한 화면으로 정리
    if (typeof base.screen!=='string' || !(SCREENS_ROUTABLE.has(base.screen) || base.screen==='detail' || base.screen==='chat' || base.screen==='attendees' || base.screen==='eventdetail' || base.screen==='eventchat' || base.screen==='postdetail' || AUTH_FLOW_SCREENS.has(base.screen))) {
      base = {...base, screen: base.authStatus==='authenticated' ? 'home' : 'onboarding'};
    }
    if (base.screen!=='onboarding' && base.authStatus!=='authenticated' && !base.pendingRegistration) {
      base = {...base, screen:'onboarding', authMethod:null, regName:'', pendingRegistration:false};
    }
    const alreadyAuthed = base.authStatus==='authenticated';
    // 카카오/이메일 회원가입 버튼이 넘기는 ?authMethod=/?login= 쿼리 — 이미 로그인된 세션이 복원됐다면 오래된·복사된 URL이 재로그인 화면을 강제로 열지 못하게 무시하고 쿼리만 정리한다.
    const q = new URLSearchParams(location.search);
    const wantsLogin = q.get('login');
    if (q.has('authMethod') || wantsLogin) {
      const url = new URL(location.href);
      url.searchParams.delete('authMethod');
      url.searchParams.delete('login');
      history.replaceState(null, '', url.pathname + (url.search||'') + url.hash);
    }
    if (wantsLogin && !alreadyAuthed) { history.replaceState({screen:'signup'}, '', '#/signup'); return {...BASE, screen:'signup'}; }
    // 새로고침·직접 URL 진입: 저장된 화면보다 현재 주소창 해시를 우선 신뢰 — 단, 이미 인증된 세션이 복원됐는데 해시가 오래된 가입 흐름 화면(북마크·복사된 링크 등)을 가리키면 무시하고 홈으로 보낸다.
    const resolved = pathToScreen(location.hash);
    if (resolved) {
      if (alreadyAuthed && AUTH_FLOW_SCREENS.has(resolved.screen) && !base.pendingRegistration) {
        history.replaceState({screen:'home'}, '', '#/home');
        return {...base, screen:'home'};
      }
      const target = {...base, screen:resolved.screen, ...(resolved.patch||{})};
      if (isAuthRoutable(resolved.screen, target)) {
        // 필터·탭 쿼리는 각 화면 컴포넌트가 마운트 시 직접 읽어 복원하므로, 여기서는 경로만 맞추고 기존 쿼리 문자열은 보존한다.
        const qIdx = location.hash.indexOf('?');
        const incomingQuery = qIdx===-1 ? '' : location.hash.slice(qIdx);
        history.replaceState({screen:resolved.screen}, '', screenPath(resolved.screen, target)+incomingQuery);
        return target;
      }
    }
    const fallbackPath = screenPath(base.screen, base);
    if (location.hash !== fallbackPath) history.replaceState({screen:base.screen}, '', fallbackPath);
    return base;
  });
  const skipUrlSync = useRef(true); // 초기 마운트 직후 1회는 위 useState에서 이미 URL을 맞췄으므로 건너뜀
  const set = p => setS(s=>{ const next={...s, ...p}; saveSession(next); return next; }); // 로그인/로그아웃 등 인증 전환도 렌더 이펙트를 기다리지 않고 즉시 저장 — 새로고침 경합 제거
  useEffect(()=>{
    // 내가 신청한 모임이 주최자에 의해 삭제됐는지 주기적으로 확인 — 삭제되면 알림 띄우고 참가예정 목록에서도 제거
    const checkCancellations = () => {
      setS(s=>{
        const joinedIds = Object.keys(s.joined||{});
        if (joinedIds.length===0) return s;
        fetchMeetupCancellations().then(list=>{
          const dismissed = s.dismissedCancellations||[];
          const newlyCanceled = list.filter(c=>joinedIds.includes(c.meetup_id) && !dismissed.includes(c.id));
          if (newlyCanceled.length===0) return;
          const first = newlyCanceled[0];
          setS(s2=>{
            const nextJoined = {...s2.joined};
            newlyCanceled.forEach(c=>{ delete nextJoined[c.meetup_id]; });
            const next = {...s2, joined:nextJoined, dismissedCancellations:[...(s2.dismissedCancellations||[]), ...newlyCanceled.map(c=>c.id)], toast:`주최자가 "${first.title}" 모임을 삭제했어요. 사유: ${first.reason||'별도 사유 없음'}`};
            saveSession(next);
            return next;
          });
        });
        return s;
      });
    };
    checkCancellations();
    const t = setInterval(checkCancellations, 15000);
    return ()=>clearInterval(t);
  }, []);
  useEffect(()=>{
    const wasAuthCallback = /access_token=|refresh_token=|type=(signup|recovery|email_change|magiclink)/.test(location.hash) || /[?&](code|type)=/.test(location.search);
    const wasRecoveryCallback = /type=recovery/.test(location.hash) || /type=recovery/.test(location.search);
    // 새로고침·재방문 시 진짜 Supabase 세션 기준으로 로그인 상태를 재확인 — 로컬에 남은 값과 실제 서버 세션이 어긋나면 서버 쪽을 신뢰
    authGetSession().then(session=>{
      if (wasAuthCallback) {
        // Supabase 클라이언트가 해시의 토큰을 다 읽었으니, 이제 지저분한 토큰이 남은 해시를 깨끗한 주소로 정리
        try { history.replaceState({}, '', location.pathname + (wasRecoveryCallback ? '#/resetpassword' : '#/emailverify')); } catch(e) {}
      }
      if (session && session.user && session.user.email) {
        setS(s=>{
          if (s.authStatus==='authenticated' && s.acctEmail===session.user.email && s.emailVerified===!!session.user.email_confirmed_at) return s; // 이미 다 일치하면 그대로
          const next = {...s, authStatus:'authenticated', acctEmail:session.user.email, emailVerified:!!session.user.email_confirmed_at, onboardingCompleted:true};
          saveSession(next);
          return next;
        });
        // 이벤트 신청 현황을 서버 기준으로 채워둠 — "내 모임"·이벤트 상세 화면이 이 값을 그대로 읽음
        fetchMyEventApplications(session.user.email).then(map=>{
          if (Object.keys(map).length===0) return;
          setS(s2=>{ const next={...s2, eventApplied:{...s2.eventApplied, ...map}}; saveSession(next); return next; });
        });
      } else {
        setS(s=>{
          if (s.authStatus!=='authenticated') return s;
          // 로컬엔 로그인된 것처럼 남아있는데 실제 서버 세션이 없으면 로그아웃 상태로 정정
          const next = {...BASE, screen:'onboarding'};
          saveSession(next);
          return next;
        });
      }
    });
  }, []);
  const go = (screen, p={}) => {
    if (!__navFromPopstate) {
      const merged = {...S, screen, ...p};
      const path = screenPath(screen, merged);
      if (path !== location.hash) { try { history.pushState({screen}, '', path); } catch(e) {} }
      skipUrlSync.current = true;
    }
    set({screen, ...p});
  };
  useEffect(()=>{ injectWebCss(); }, []);
  useEffect(()=>{ saveSession(S); }, [S]);
  useEffect(()=>{ if(S.toast){ const t=setTimeout(()=>set({toast:null}), 3200); return ()=>clearTimeout(t);} }, [S.toast]);
  // 카카오 OAuth 복귀: 주소에 세션이 실려 오면 로그인 처리(신규면 약관 동의 → 프로필 설정으로)
  useEffect(()=>{
    const sb = getSupabaseClient(); if (!sb) return;
    if (S.authStatus==='authenticated') return;
    sb.auth.getSession().then(({data})=>{
      const em = data && data.session && data.session.user && (data.session.user.email||'').toLowerCase();
      if (!em) return;
      fetchMyProfile(em).then(prof=>{
        if (prof && prof.nickname) {
          set(switchAccount(BASE, S, em, {screen:'home', authStatus:'authenticated', acctEmail:em, onboardingCompleted:true, pendingRegistration:false, authMethod:null, toast:'카카오 계정으로 로그인했어요'}));
        } else {
          set(switchAccount(BASE, S, em, {screen:'consent', authStatus:'authenticated', acctEmail:em, signupEmail:em, authMethod:'kakao', pendingRegistration:true, toast:'카카오 인증이 완료됐어요 — 약관 동의 후 프로필을 만들어주세요'}));
        }
      });
    });
  }, [S.authStatus]);
    // 24단계: 로그인 시 차단 목록 로드(모임·글 필터의 기준)
  useEffect(()=>{
    if (S.authStatus!=='authenticated' || !S.acctEmail) { window.__mmBlockSet = new Set(); return; }
    loadMyBlocks(S.acctEmail);
  }, [S.authStatus, S.acctEmail]);
    // 23단계: 로그인 상태면 활성 정지 여부를 조회해 배너로 안내(서버가 실제 차단, 화면은 알림)
  useEffect(()=>{
    if (S.authStatus!=='authenticated' || !S.acctEmail) { if (S.sanctionActive) set({sanctionActive:null}); return; }
    let alive = true;
    checkActiveSanction(S.acctEmail).then(sn=>{ if (alive) set({sanctionActive: sn||null}); });
    return ()=>{ alive=false; };
  }, [S.authStatus, S.acctEmail, S.screen]);
  // 19단계 프로필 서버 저장 — 로그인하면 서버 프로필을 내려받아 로컬을 덮어씀(서버가 기준)
  useEffect(()=>{
    if (S.authStatus!=='authenticated' || !S.acctEmail) return;
    let alive = true;
    fetchMyProfile(S.acctEmail).then(row=>{
      if (!alive) return;
      if (!row) {
        // 서버 미등록 계정 — 지금 닉네임으로 즉시 등록 시도(중복·사칭은 여기서 걸러짐). 실패하면 바꾸라고 안내
        // 데모 기본 닉네임(유학생22)은 자동 등록 제외 — 첫 로그인 계정이 기본값을 선점해버리는 것 방지
        if ((S.profile.nickname||'').trim().length>=2 && (S.profile.nickname||'').trim()!=='유학생22' && !S.profileNickConflict) {
          saveMyProfile(S.acctEmail, S.profile).then(res=>{
            if (!alive || !res) return;
            if (res.dup || res.reserved) set({profileNickConflict:true, toast: res.dup ? '지금 쓰는 닉네임은 이미 다른 회원이 등록했어요 — 내 정보에서 닉네임을 바꿔 저장해주세요' : '지금 닉네임은 사용할 수 없어요 — 내 정보에서 바꿔 저장해주세요'});
            else if (res.ok) set({profileNickConflict:false});
          });
        }
        return;
      }
      const p = {...S.profile, nickname:row.nickname||S.profile.nickname, photoUrl:row.photo_url||null, photoPublic:!!row.photo_public, school:row.school!=null?row.school:S.profile.school, bio:row.bio!=null?row.bio:S.profile.bio};
      const patch = {};
      if (JSON.stringify(p)!==JSON.stringify(S.profile)) patch.profile = p;
      // 마케팅 수신 동의도 서버값 기준으로 화면 상태 동기화(내 정보 토글 표시용)
      if ('marketing_ok' in row && !!(S.consents&&S.consents.marketing) !== !!row.marketing_ok) patch.consents = {...S.consents, marketing: !!row.marketing_ok};
      if (Object.keys(patch).length) set(patch);
    });
    return ()=>{ alive=false; };
  }, [S.authStatus, S.acctEmail]);
  // 12단계 참가 서버화 — 로그인하면 서버의 내 참가 내역을 읽어 화면 미러(S.joined)에 반영.
  // 서버 모임(uuid) 항목은 서버 기록이 기준이 되고, 데모·로컬 전용 id 항목은 그대로 유지된다.
  useEffect(()=>{
    if (S.authStatus!=='authenticated' || !S.acctEmail) return;
    // 로그인 직후 + 참가 상태가 보이는 화면에 들어올 때마다 갱신 — 호스트가 승인·거절하면(13단계) 재로그인 없이 반영됨
    if (!['home','mymeet','mymeetupcoming','mymeetpast','detail','browse','browseregions'].includes(S.screen)) return;
    let alive = true;
    fetchMyParticipations(S.acctEmail).then(map=>{
      if (!alive || !map) return;
      const joined = {...S.joined};
      Object.keys(joined).forEach(k=>{ if (isServerMeetupId(k)) delete joined[k]; });
      Object.assign(joined, map);
      // 내용이 같으면 set을 생략해 불필요한 재렌더(및 이 효과의 재실행)를 막는다
      if (JSON.stringify(joined) !== JSON.stringify(S.joined)) set({joined});
    });
    return ()=>{ alive = false; };
  }, [S.authStatus, S.acctEmail, S.screen]);
  // 상세 화면에 들어올 때마다 그 모임의 확정 인원을 서버에서 새로 받아 표시 갱신(12단계 보강)
  useEffect(()=>{
    if (S.screen!=='detail' || !isServerMeetupId(S.detailId)) return;
    let alive = true;
    fetchMeetupCounts([S.detailId]).then(c=>{
      if (!alive || !(S.detailId in c)) return;
      const n = c[S.detailId];
      if (S.tempDetail && S.tempDetail.id===S.detailId && S.tempDetail.capJoined!==n) set({tempDetail:{...S.tempDetail, capJoined:n}});
      const cached = resolveMeetup(S.detailId, S);
      if (cached && cached.capJoined!==n) cached.capJoined = n;
    });
    return ()=>{ alive = false; };
  }, [S.screen, S.detailId]);
  // props로 S/set/go를 못 받는 컴포넌트(MeetCard 등)가 "비회원 행동 차단"을 알릴 때 쓰는 전역 신호
  useEffect(()=>{
    const onGuestAction = () => set({authChoiceOpen:true});
    document.addEventListener('mm:guestaction', onGuestAction);
    return ()=>document.removeEventListener('mm:guestaction', onGuestAction);
  }, []);
  // 계정 전환·로그아웃처럼 go()를 거치지 않고 화면이 바뀐 경우에도 주소창을 동기화(단, 새 기록 항목은 만들지 않음)
  useEffect(()=>{
    if (skipUrlSync.current) { skipUrlSync.current = false; return; }
    const path = screenPath(S.screen, S);
    if (path !== location.hash) { try { history.replaceState({screen:S.screen}, '', path); } catch(e) {} }
  }, [S.screen, S.detailId, S.qnaDetailPost]);
  // 브라우저 뒤로가기·앞으로가기 (해시 변경은 hashchange/popstate 둘 다 발생할 수 있어 popstate만 구독하고 hashchange는 무시)
  useEffect(()=>{
    const onPop = () => {
      __navFromPopstate = true;
      skipUrlSync.current = true;
      const resolved = pathToScreen(location.hash);
      if (!resolved) { set({screen:'onboarding'}); }
      else if (!isAuthRoutable(resolved.screen, S)) { set({screen:'onboarding'}); try{history.replaceState({screen:'onboarding'},'','#/');}catch(e){} }
      else { set({screen:resolved.screen, ...(resolved.patch||{})}); }
      __navFromPopstate = false;
    };
    window.addEventListener('popstate', onPop);
    return ()=>window.removeEventListener('popstate', onPop);
  }, [S]);
  const modalOpen = S.rulesOpen||S.legalOpen||S.reportOpen||S.reconfirmOpen||S.hostCompleteIdx!=null||S.reviewPopup||S.locPromptOpen||S.cancelConfirmId||S.avoidUserConfirm||S.applicantsIdx!=null||S.hostLimitNoticeOpen;
  useEffect(()=>{ document.body.style.overflow = modalOpen ? 'hidden' : ''; return ()=>{document.body.style.overflow='';}; }, [modalOpen]);
  const Scr = SCREENS[S.screen] || WScrHome;
  return <div style={{position:'relative', height:'100%', overflow:'hidden'}}>
    <DemoModeBanner/>
    <Scr S={S} set={set} go={go}/>
    <LocationPrompt S={S} set={set}/>
    {S.rulesOpen&&<WRulesModal S={S} set={set} go={go}/>}
    {S.legalOpen&&<LegalModal S={S} set={set}/>}
    {S.reportOpen&&<ReportModal S={S} set={set}/>}
    {S.adminRemove&&<AdminRemoveModal S={S} set={set}/>}
    {S.inquiryOpen&&<MeetupInquiryModal S={S} set={set}/>}
    {S.hostCompleteIdx!=null&&<HostCompleteModal S={S} set={set}/>}
    {S.applicantsIdx!=null&&<ApplicantsModal S={S} set={set}/>}
    {S.hostLimitNoticeOpen&&<HostLimitModal S={S} set={set} go={go}/>}
    {S.reviewPopup&&<ReviewPopup S={S} set={set}/>}
    {S.reconfirmOpen&&<ReconfirmSheet S={S} set={set} go={go}/>}
    {S.cancelConfirmId&&<CancelConfirmSheet S={S} set={set}/>}
    {S.avoidUserConfirm&&<AvoidUserConfirmSheet S={S} set={set}/>}
    {S.withdrawConfirmOpen&&<WithdrawConfirmSheet S={S} set={set} go={go}/>}
    <AuthChoiceSheet S={S} set={set} go={go}/>
    <FirstVisitPopup S={S} set={set}/>
    {S.toast&&<div className="toast" style={{left:'auto', right:16, bottom:16, width:340}}>{S.toast}</div>}
    <DevPanel S={S} set={set}/>
  </div>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<WebApp/>);
