const WDS = window.WantedDesignSystemKR_853048;
// 라우팅으로 history.pushState/replaceState가 주소를 바꾸기 전, 최초 로드 시점의 실제 문서 위치를 고정 — 상대 경로 자산이 가짜 라우트 경로 기준으로 깨지는 것을 방지
const __APP_BASE_URL = document.baseURI;
function asset(path) { return new URL(path, __APP_BASE_URL).href; }
// window.MM_DEMO_MODE를 명시적으로 true로 설정한 HTML에서만 데모 데이터/저장소가 활성화된다. 기본값은 공개(생산) 모드.
const DEMO_MODE = typeof window!=='undefined' && window.MM_DEMO_MODE === true;
const MM_NS = DEMO_MODE ? 'demo' : 'prod';
function nsKey(key) { return MM_NS+':'+key; }
function DemoModeBanner() {
  if (!DEMO_MODE) return null;
  return <div style={{background:'var(--c-yellow-bg)', color:'var(--c-text)', fontSize:12, fontWeight:700, textAlign:'center', padding:'6px 10px', lineHeight:1.4}}><span className="demo-banner-txt-full">데모 모드 · 화면의 모임과 사용자는 기능 확인을 위한 예시입니다.</span><span className="demo-banner-txt-short">데모 모드 · 예시 데이터입니다.</span></div>;
}
const {Icon: WIcon, ButtonButton} = WDS;
function Ic({n, s=20, c, style}) { return <WIcon name={n} size={s} style={{color:c||'currentColor', flexShrink:0, ...style}}/>; }
// DS button wrapped for clicks + full width — 실제 시맨틱 <button>으로 렌더링(키보드 Tab/Enter/Space, disabled 접근성 확보)
function DSBtn({label, onClick, variant='solid', color='primary', size='lg', disable=false, fit=false, style, ariaLabel, type='button'}) {
  return <button type={type} className={'dsbtn'+(fit?' fit':'')} style={style} onClick={onClick} disabled={!!disable} aria-label={ariaLabel}>
    <ButtonButton label={label} variant={variant} color={color} size={size} disable={disable}/>
  </button>;
}
function Chip({icon, label, tone='', onClick, on}) {
  const cls = 'chip '+tone+(onClick?' chipbtn':'')+(on?' on':'');
  if (onClick) return <button type="button" className={cls} onClick={onClick} aria-pressed={!!on} style={{justifyContent:'flex-start', fontFamily:'inherit'}}>{icon&&<Ic n={icon} s={14}/>}{label}</button>;
  return <span className={cls} style={{justifyContent:'flex-start'}}>{icon&&<Ic n={icon} s={14}/>}{label}</span>;
}
function Ph({h=110, icon='IconNormalImageNameImage', label='이미지 영역', w, r=0, style}) {
  return <div className="ph" style={{height:h, width:w, borderRadius:r, ...style}}><Ic n={icon} s={26}/><small>{label}</small></div>;
}
function Av({name, size=40, tone, style, photo}) {
  if (photo) return <img src={photo} alt="" loading="lazy" style={{width:size, height:size, borderRadius:'50%', objectFit:'cover', flexShrink:0, ...style}}/>;
  const bg = tone==='p'?'var(--c-primary-bg)':tone==='v'?'var(--c-violet-bg)':tone==='o'?'var(--c-orange-bg)':'var(--c-fill)';
  const fg = tone==='p'?'var(--c-primary)':tone==='v'?'var(--c-violet)':tone==='o'?'var(--c-orange)':'var(--c-sub)';
  return <div className="av" style={{width:size, height:size, fontSize:size*.38, background:bg, color:fg, ...style}}>{name[0]}</div>;
}
function TopBar({title, back, onBack, right, children}) {
  return <div className="topbar">
    {back&&<button className="iconbtn" onClick={onBack} aria-label="뒤로"><Ic n="IconNormalChevronLeftNameChevronLeftTightFalseThick" s={22}/></button>}
    {title!==undefined?<h1>{title}</h1>:children}
    {right}
  </div>;
}
function NavBar({S, go}) {
  const items = [
    {id:'home', label:'홈', ic:'IconNormalHomeNameHomeFillFalse', icOn:'IconNormalHomeNameHomeFillFillTrue'},
    {id:'mymeet', label:'모임', ic:'IconNormalPersonsNamePersonsFillFalse', icOn:'IconNormalPersonsNamePersonsFillFillTrue'},
    {id:'explore', label:'둘러보기', ic:'IconNormalSearchNameSearchThickFalse', icOn:'IconNormalSearchNameSearchThickThickTrue'},
    {id:'chatlist', label:'채팅', ic:'IconNormalBubbleNameBubbleFillFalse', icOn:'IconNormalBubbleNameBubbleFillFillTrue'},
    {id:'my', label:'MY', ic:'IconNavigationMyPageNameNavigationMypage', icOn:'IconNavigationMyPageNameNavigationMypage'},
  ];
  const myScreens = {profile:1, region:1, my:1};
  return <div className="navbar">
    {items.map(it=>{
      const on = S.screen===it.id || (it.id==='my'&&myScreens[S.screen]);
      return <button key={it.id} className={'navitem'+(on?' on':'')} onClick={()=>go(it.id==='my'?'profile':it.id)}>
        <Ic n={on?it.icOn:it.ic} s={22}/><span>{it.label}</span>
      </button>;
    })}
  </div>;
}
function Sect({title, more, onMore, icon}) {
  return <div className="sect pad">
    <h2 style={{display:'flex',alignItems:'center',gap:6}}>{icon&&<Ic n={icon} s={17} c="var(--c-orange)"/>}{title}</h2>
    {more&&<button className="more" onClick={onMore}>{more} <Ic n="IconNormalChevronRightNameChevronRightTightFalseThick" s={11}/></button>}
  </div>;
}
// 모임 카드 (홈 캐러셀)
const MEETUP_ICON_RULES = [
  [/펍|바\b/, 'IconNormalFireNameFireFillFalse'],
  [/커피|카페/, 'IconNormalCoffeeNameCoffeeFillFalse'],
  [/브런치|식사|맛집|저녁/, 'IconNormalStarNameStarFillFalse'],
  [/러닝|운동/, 'IconNormalTrophyNameTrophyFillFalse'],
  [/여행/, 'IconNormalCompassNameCompassFillFalse'],
  [/전시|공연|문화생활/, 'IconNormalTicketNameTicketFillFalse'],
  [/보드게임|게임/, 'IconNormalDiamondNameDiamondFillFalse'],
  [/언어|회화|영어/, 'IconNormalBubbleNameBubbleFillFalse'],
  [/스터디/, 'IconNormalBookNameBookFillFalse'],
  [/네트워킹|커리어|동문/, 'IconNormalPersonsNamePersonsFillFillTrue'],
  [/소개팅/, 'IconNormalFaceSmileNameFaceSmileFillFalse'],
  [/친구|조용한/, 'IconNormalHeartNameHeartFillFalse'],
  [/현지생활|적응/, 'IconNormalGlobe2NameGlobeFillFalse'],
];
// 카테고리 전용 선 아이콘 — 기존 디자인 시스템에 없는 활동(달리는 사람, 포크·나이프 등)만
// Tabler Icons(MIT 라이선스) 공식 경로 데이터를 가져와 같은 선(스트로크) 스타일로 렌더링.
// 매칭되는 활동이 없으면 기존 디자인 시스템 선 아이콘으로 폴백 — 시각적으로 한 세트처럼 보임.
const ACT_SVG_PATHS = {
  run: ['M13 4m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0','M4 17l5 1l.75 -1.5','M15 21l0 -4l-4 -3l1 -6','M7 12l0 -3l5 -1l3 3l3 1'],
  food: ['M19 3v12h-5c-.023 -3.681 .184 -7.406 5 -12zm0 12v6h-1v-3m-10 -14v17m-3 -17v3a3 3 0 1 0 6 0v-3'],
  dice: ['M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z'],
  plane: ['M14.639 10.258l4.83 -1.294a2 2 0 1 1 1.035 3.863l-14.489 3.883l-4.45 -5.02l2.897 -.776l2.45 1.414l2.897 -.776l-3.743 -6.244l2.898 -.777l5.675 5.727z','M3 21h18'],
  beer: ['M9 21h6a1 1 0 0 0 1 -1v-3.625c0 -1.397 .29 -2.775 .845 -4.025l.31 -.7c.556 -1.25 .845 -2.253 .845 -3.65v-4a1 1 0 0 0 -1 -1h-10a1 1 0 0 0 -1 1v4c0 1.397 .29 2.4 .845 3.65l.31 .7a9.931 9.931 0 0 1 .845 4.025v3.625a1 1 0 0 0 1 1z','M6 8h12'],
  theater: ['M13.192 9h6.616a2 2 0 0 1 1.992 2.183l-.567 6.182a4 4 0 0 1 -3.983 3.635h-1.5a4 4 0 0 1 -3.983 -3.635l-.567 -6.182a2 2 0 0 1 1.992 -2.183z','M15 13h.01','M18 13h.01','M15 16.5c1 .667 2 .667 3 0','M8.632 15.982a4.037 4.037 0 0 1 -.382 .018h-1.5a4 4 0 0 1 -3.983 -3.635l-.567 -6.182a2 2 0 0 1 1.992 -2.183h6.616a2 2 0 0 1 2 2','M6 8h.01','M9 8h.01','M6 12c.764 -.51 1.528 -.63 2.291 -.36'],
};
const ACT_SVG_DOTS = { dice: [[8.5,8.5],[15.5,8.5],[15.5,15.5],[8.5,15.5],[12,12]] };
const ACT_ICON_RULES = [
  [/러닝|운동/, 'run'],
  [/브런치|식사|맛집|저녁/, 'food'],
  [/보드게임|게임/, 'dice'],
  [/여행/, 'plane'],
  [/펍|바\b/, 'beer'],
  [/전시|공연|문화생활/, 'theater'],
];
function ActIcon({label, size=20, color, fallbackIcon, fallbackColor}){
  const c = color || fallbackColor || 'currentColor';
  const hit = ACT_ICON_RULES.find(([re])=>re.test(label||''));
  if (!hit) return <Ic n={fallbackIcon||'IconNormalSparkleNameSparkle'} s={size} c={c}/>;
  const key = hit[1];
  return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0, display:'block'}} aria-hidden="true">
    {ACT_SVG_PATHS[key].map((d,i)=><path key={i} d={d}/>)}
    {(ACT_SVG_DOTS[key]||[]).map(([cx,cy],i)=><circle key={'c'+i} cx={cx} cy={cy} r="1.1" fill={c} stroke="none"/>)}
  </svg>;
}
const TONE_BG = {primary:'var(--c-primary-bg)', violet:'var(--c-violet-bg)', green:'var(--c-green-bg)', orange:'var(--c-orange-bg)'};
const TONE_FG = {primary:'var(--c-primary)', violet:'var(--c-violet)', green:'var(--c-green)', orange:'var(--c-orange)'};
function meetupVisual(m) {
  const label = (m.cardTags&&m.cardTags[0]&&m.cardTags[0][0]) || '';
  const tone = (m.cardTags&&m.cardTags[0]&&m.cardTags[0][1]) || 'primary';
  const hit = MEETUP_ICON_RULES.find(([re])=>re.test(label));
  return {icon: hit?hit[1]:'IconNormalSparkleNameSparkle', bg: TONE_BG[tone]||'var(--c-fill)', fg: TONE_FG[tone]||'var(--c-sub)'};
}
// 실사진 대신 활동 기반 그래픽 씨스템 — 주최자가 직접 사진을 올리면(m.hostPhoto) 그것을 우선하고, 없으면 활동별 기본 그래픽으로 폴백한다.
function MiniThumb({m, size=44, radius=10}) {
  const v = meetupVisual(m);
  const {useState} = React;
  const [broken, setBroken] = useState(false);
  if (m.hostPhoto && !broken) return <img src={m.hostPhoto} alt={m.title} loading="lazy" onError={()=>setBroken(true)} style={{width:size, height:size, borderRadius:radius, objectFit:'cover', flexShrink:0}}/>;
  const label = (m.cardTags&&m.cardTags[0]&&m.cardTags[0][0]) || m.purpose || '';
  return <div style={{width:size, height:size, borderRadius:radius, background:v.bg, display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0}}><ActIcon label={label} size={Math.round(size*0.45)} color={v.fg} fallbackIcon={v.icon}/></div>;
}
function feeLine(m) {
  if (!m.fee) return null;
  if (/무료/.test(m.fee)) return '참가비 없음';
  const range = m.fee.match(/예상\s*1인\s*[\d.]+~[\d.]+만원/);
  return range ? range[0] : m.fee.split(' (')[0];
}
function statusChip(m) {
  if (m.expired) return {label:'종료된 모임', tone:''};
  const capMax = m.capMax || (m.size&&(m.size.match(/\d+/)||[])[0]) || null;
  let capJoined = m.capJoined!=null ? m.capJoined : null;
  if (capJoined!=null) capJoined += companionApprovedCount(m.id);
  if (capMax!=null && capJoined!=null) {
    const left = capMax - capJoined;
    if (left<=0) return m.waitlistOk ? {label:'대기 신청 가능', tone:'violet'} : {label:'마감', tone:''};
    if (left<=2) return {label:'마감임박', tone:'orange'};
  }
  if (m.approvalMode==='approval') return {label:'승인 후 참가', tone:'violet'};
  return {label:'모집 중', tone:'green'};
}
// 찜(즐겨찾기) — 계정별로 완전히 분리 저장. 기존 공용 키(nsKey('mm_saved_meetups'))는 어느 계정 것인지 알 수 없으므로
// 신규 계정별 키의 기본값으로 쓰지 않고 그대로 방치한다(자동 배포·복사 없음 — 읽지도 쓰지도 않음).
function savedMeetupsKey() { const acct = (typeof getActiveAccountId==='function') ? getActiveAccountId() : 'guest'; return nsKey('mm_saved_meetups:'+acct); }
function isSaved(id) { try { return JSON.parse(localStorage.getItem(savedMeetupsKey())||'[]').includes(id); } catch(e) { return false; } }
function toggleSaved(id) {
  let arr; try { arr = JSON.parse(localStorage.getItem(savedMeetupsKey())||'[]'); } catch(e) { arr = []; }
  arr = arr.includes(id) ? arr.filter(x=>x!==id) : [...arr, id];
  localStorage.setItem(savedMeetupsKey(), JSON.stringify(arr));
  const nowSaved = arr.includes(id);
  try { document.dispatchEvent(new CustomEvent('mm:savedchanged', {detail:{id, saved:nowSaved}})); } catch(e) {}
  return nowSaved;
}
const COMPANION_KEY = nsKey('mm_companions_v1');
// 찜한 모임 id 목록 → 실제 모임 객체 배열(존재하지 않는 id는 제외 — 공개모드에서 MEETUPS가 비어있으면 자동으로 빈 배열)
function getSavedMeetupIds() { try { return JSON.parse(localStorage.getItem(savedMeetupsKey())||'[]'); } catch(e) { return []; } }
function getSavedMeetups(S) { return getSavedMeetupIds().map(id=>resolveMeetup(id,S)).filter(Boolean); }
// 하트 토글(mm:savedchanged) · 계정 전환(mm:accountchanged) 시 실시간으로 갱신되는 찜 목록 훅
function useSavedMeetups(S) {
  const {useState, useEffect} = React;
  const [ids, setIds] = useState(getSavedMeetupIds);
  useEffect(()=>{
    const refresh = () => setIds(getSavedMeetupIds());
    document.addEventListener('mm:savedchanged', refresh);
    document.addEventListener('mm:accountchanged', refresh);
    return ()=>{ document.removeEventListener('mm:savedchanged', refresh); document.removeEventListener('mm:accountchanged', refresh); };
  }, []);
  return ids.map(id=>resolveMeetup(id,S)).filter(Boolean);
}
function getCompanion(id) { try { const all = JSON.parse(localStorage.getItem(COMPANION_KEY)||'{}'); return all[id]||null; } catch(e) { return null; } }
function setCompanion(id, data) {
  let all; try { all = JSON.parse(localStorage.getItem(COMPANION_KEY)||'{}'); } catch(e) { all = {}; }
  if (data==null) delete all[id]; else all[id] = data;
  localStorage.setItem(COMPANION_KEY, JSON.stringify(all));
  return data;
}
function companionApprovedCount(id) { const c = getCompanion(id); return (c&&c.status==='approved') ? (c.count||1) : 0; }
function effectiveJoined(m) { return (m.capJoined||0) + companionApprovedCount(m.id); }
function seatLabel(m) {
  if (m.expired) return '종료';
  const capMax = m.capMax || (m.size&&(m.size.match(/\d+/)||[])[0]) || null;
  let capJoined = m.capJoined!=null ? m.capJoined : null;
  if (capJoined!=null) capJoined += companionApprovedCount(m.id);
  if (capMax==null || capJoined==null) return m.soloLine;
  const left = capMax - capJoined;
  if (left<=0) return m.waitlistOk ? '대기 신청 가능' : '마감';
  if (left<=2) return '마감임박 · '+left+'자리 남음';
  return left+'자리 남음';
}
// ------- 지인 마주침 방지 (프로토타입 데모 정체성 4종으로 상호 회피 로직을 검증) -------
const TEST_IDENTITIES = {
  A:{label:'사용자 A (나)', school:'University College London', enrollFrom:'2023-09', enrollTo:'2024-06'},
  B:{label:'사용자 B', school:'University College London', enrollFrom:'2023-09', enrollTo:'2024-06'},
  C:{label:'사용자 C', school:'University College London', enrollFrom:'2017-09', enrollTo:'2018-06'},
  D:{label:'사용자 D', school:'서울대학교', enrollFrom:'2022-03', enrollTo:'2023-02'},
};
const HOST_IDENTITY = {'지은':'B', '윤영진':'D'};
const PRIVACY_KEY = nsKey('mm_privacy_v1');
const AVOID_USERS_KEY = nsKey('mm_avoid_users_v1');
function getPrivacy(id) { try { const all=JSON.parse(localStorage.getItem(PRIVACY_KEY)||'{}'); return all[id]||{on:false, scope:'similar'}; } catch(e) { return {on:false, scope:'similar'}; } }
function setPrivacy(id, patch) { let all; try { all=JSON.parse(localStorage.getItem(PRIVACY_KEY)||'{}'); } catch(e) { all={}; } all[id]={...getPrivacy(id), ...patch}; localStorage.setItem(PRIVACY_KEY, JSON.stringify(all)); }
function getAvoidList(id) { try { const all=JSON.parse(localStorage.getItem(AVOID_USERS_KEY)||'{}'); return all[id]||[]; } catch(e) { return []; } }
function toggleAvoidUser(id, other) {
  let all; try { all=JSON.parse(localStorage.getItem(AVOID_USERS_KEY)||'{}'); } catch(e) { all={}; }
  const cur = all[id]||[];
  all[id] = cur.includes(other) ? cur.filter(x=>x!==other) : [...cur, other];
  localStorage.setItem(AVOID_USERS_KEY, JSON.stringify(all));
}
function monthsVal(ym) { const [y,m] = (ym||'0-0').split('-').map(Number); return y*12+(m||0); }
function enrollOverlap(p1, p2) {
  if (!p1.enrollFrom||!p1.enrollTo||!p2.enrollFrom||!p2.enrollTo) return false;
  return monthsVal(p1.enrollFrom) <= monthsVal(p2.enrollTo) && monthsVal(p2.enrollFrom) <= monthsVal(p1.enrollTo);
}
function isSchoolAvoiding(a, b) {
  const pa=TEST_IDENTITIES[a], pb=TEST_IDENTITIES[b];
  if (!pa||!pb||!pa.school||!pb.school||pa.school!==pb.school) return false;
  const sa=getPrivacy(a), sb=getPrivacy(b);
  const matches = scope => scope==='all' ? true : scope==='similar' ? enrollOverlap(pa,pb) : false;
  return (sa.on&&matches(sa.scope)) || (sb.on&&matches(sb.scope));
}
function isAvoidingUser(a, b) { return getAvoidList(a).includes(b) || getAvoidList(b).includes(a); }
function isAvoiding(a, b) { if (!a||!b||a===b) return false; return isSchoolAvoiding(a,b) || isAvoidingUser(a,b); }
function meetupParticipantIdentities(m) { const hostId = m.host&&HOST_IDENTITY[m.host.name]; return hostId ? [hostId] : []; }
function meetupVisibleTo(m, viewer) { if (!viewer) return true; return !meetupParticipantIdentities(m).some(id=>isAvoiding(viewer, id)); }
function visibleMeetups(list, viewer) { return (list||[]).filter(m=>meetupVisibleTo(m, viewer)); }
function PrivacyAvoidBody({S, set}) {
  const me = S.viewAsUser || 'A';
  const priv = getPrivacy(me);
  const prof = TEST_IDENTITIES[me];
  const [, force] = React.useState(0);
  const rerender = () => force(n=>n+1);
  const avoidIds = getAvoidList(me);
  const others = Object.keys(TEST_IDENTITIES).filter(id=>id!==me);
  return <div>
    <p style={{fontSize:13.5, color:'var(--c-sub)', lineHeight:1.65, margin:'0 0 4px'}}>새로운 사람을 만나고 싶을 때 기존 지인과 같은 모임에 참여할 가능성을 줄일 수 있어요.</p>
    <p style={{fontSize:12.5, color:'var(--c-faint)', lineHeight:1.6, margin:'0 0 20px'}}>학교 정보가 없거나 비회원 동반자로 참여하는 경우에는 모든 우연한 만남을 방지할 수 없어요.</p>
    <div className="card" style={{marginBottom:14}}>
      <div className="row" style={{justifyContent:'space-between', gap:12}}>
        <div style={{flex:1}}><b style={{fontSize:14.5}}>같은 학교에서 비슷한 시기에 다닌 사람 피하기</b><div className="meta" style={{marginTop:4, lineHeight:1.6}}>인증된 학교와 재학 기간이 겹치는 사용자가 참가한 모임을 서로에게 표시하지 않아요.</div></div>
        <label style={{position:'relative', width:44, height:26, flexShrink:0, cursor:'pointer'}}>
          <input type="checkbox" checked={!!priv.on} onChange={e=>{setPrivacy(me,{on:e.target.checked}); rerender(); set({toast:e.target.checked?'지인 마주침 방지 설정을 저장했어요.':'지인 마주침 방지를 껐어요.'});}} style={{position:'absolute', opacity:0, width:44, height:26, margin:0, cursor:'pointer'}}/>
          <span style={{position:'absolute', inset:0, background:priv.on?'var(--c-primary)':'var(--c-line)', borderRadius:14, transition:'background .15s'}}></span>
          <span style={{position:'absolute', top:3, left:priv.on?21:3, width:20, height:20, borderRadius:'50%', background:'#fff', transition:'left .15s', boxShadow:'0 1px 3px rgba(0,0,0,.3)'}}></span>
        </label>
      </div>
      <div style={{fontSize:12, color:'var(--c-primary)', fontWeight:700, marginTop:10}}>한 사람이라도 설정하면 양쪽 모두에게 적용돼요.</div>
      {priv.on&&<div style={{marginTop:12, paddingTop:12, borderTop:'1px solid var(--c-line)', display:'flex', flexDirection:'column', gap:8}}>
        {[['similar','비슷한 시기에 다닌 사람만 (권장)'],['all','같은 학교 인증 사용자 모두']].map(([v,l])=>
          <label key={v} className="row" style={{gap:8, cursor:'pointer', fontSize:13.5, minHeight:44}}>
            <input type="radio" name="avoidscope" checked={priv.scope===v} onChange={()=>{setPrivacy(me,{scope:v}); rerender();}} style={{width:18, height:18, accentColor:'var(--c-primary)'}}/>{l}
          </label>)}
      </div>}
      <div className="meta" style={{marginTop:12, lineHeight:1.6}}>상대방에게 누가 설정했는지, 어떤 조건으로 적용됐는지 공개되지 않아요.</div>
    </div>
    <div className="card" style={{marginBottom:14}}>
      <b style={{fontSize:14.5}}>특정 사용자와 같은 모임 피하기</b>
      <div className="meta" style={{marginTop:4, marginBottom:10, lineHeight:1.6}}>학교 전체가 아니라 특정 지인만 피하고 싶다면 상대 프로필의 더보기 메뉴에서 설정할 수 있어요. 여기서는 현재 설정된 목록만 확인·해제할 수 있어요.</div>
      {avoidIds.length===0?<div className="meta">아직 설정한 사용자가 없어요.</div>:<div style={{display:'flex', flexDirection:'column', gap:8}}>
        {avoidIds.map(id=><div key={id} className="row" style={{justifyContent:'space-between', gap:8}}>
          <span style={{fontSize:13.5}}>{TEST_IDENTITIES[id]?.label||id}</span>
          <button style={{background:'none', border:'1px solid var(--c-line)', borderRadius:8, padding:'8px 12px', minHeight:44, fontFamily:'inherit', fontSize:12.5, cursor:'pointer'}} onClick={()=>{toggleAvoidUser(me,id); rerender(); set({toast:'같은 모임 피하기를 해제했어요.'});}}>해제</button>
        </div>)}
      </div>}
    </div>
    {DEMO_MODE&&<div className="card" style={{marginBottom:4}}>
      <b style={{fontSize:13.5}}>테스트용 관점 전환 (프로토타입 전용)</b>
      <div className="meta" style={{margin:'4px 0 10px', lineHeight:1.6}}>실제 서비스에는 없는 검증 도구예요. 지금 어떤 테스트 사용자로 화면을 보고 있는지 바꿔볼 수 있어요.</div>
      <div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
        {Object.keys(TEST_IDENTITIES).map(id=><button key={id} onClick={()=>set({viewAsUser:id})} style={{background:me===id?'var(--c-primary)':'none', color:me===id?'#fff':'var(--c-text)', border:'1px solid '+(me===id?'var(--c-primary)':'var(--c-line)'), borderRadius:20, padding:'8px 14px', minHeight:44, fontFamily:'inherit', fontSize:13, fontWeight:700, cursor:'pointer'}}>{TEST_IDENTITIES[id].label}</button>)}
      </div>
    </div>}
    <div className="meta" style={{marginTop:10, lineHeight:1.6}}>비회원 동반자는 학교·프로필 정보를 확인할 수 없어 지인 마주침 방지 설정의 적용 대상이 아니에요.</div>
  </div>;
}function AvoidUserButton({hostName, S, set}) {
  if (!DEMO_MODE) return null; // 지인 마주침 방지는 테스트용 고정 정체성(TEST_IDENTITIES)에만 연결된 프로토타입 기능 — 실제 계정과는 연결돼 있지 않아 공개 모드에서는 숨김(6단계, 준비 중)
  const id = HOST_IDENTITY[hostName];
  const [, force] = React.useState(0);
  if (!id || id===(S.viewAsUser||'A')) return null;
  const me = S.viewAsUser||'A';
  const avoiding = getAvoidList(me).includes(id) || getAvoidList(id).includes(me);
  return <button style={{background:'none', border:'1px solid var(--c-line)', borderRadius:8, padding:'10px 14px', minHeight:44, fontFamily:'inherit', fontSize:13, cursor:'pointer', color:avoiding?'var(--c-primary)':'var(--c-text)'}} onClick={()=>{ if(avoiding){ toggleAvoidUser(me,id); set({toast:'이 사용자와 같은 모임 피하기를 해제했어요.'}); } else { set({avoidUserConfirm:id}); } force(n=>n+1); }}>{avoiding?'같은 모임 피하기 해제':'이 사용자와 같은 모임 피하기'}</button>;
}
function AvoidUserConfirmSheet({S, set}) {
  const id = S.avoidUserConfirm; if (!id) return null;
  const me = S.viewAsUser||'A';
  return <div className="overlay" onClick={()=>set({avoidUserConfirm:null})}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <h2 style={{fontSize:17, margin:'0 0 10px'}}>이 사용자와 같은 모임을 피할까요?</h2>
      <p style={{fontSize:13.5, color:'var(--c-sub)', lineHeight:1.65, margin:'0 0 16px'}}>앞으로 두 사람 중 한 명이 먼저 참가 확정된 모임은 다른 사람에게 표시되지 않아요. 상대방에게 설정 사실이나 설정한 사람이 공개되지 않습니다.</p>
      <div style={{display:'flex', gap:8}}>
        <button style={{flex:1, background:'none', border:'1px solid var(--c-line)', borderRadius:10, padding:'12px', minHeight:44, fontFamily:'inherit', fontSize:13.5, cursor:'pointer'}} onClick={()=>set({avoidUserConfirm:null})}>취소</button>
        <button style={{flex:1, background:'var(--c-primary)', color:'#fff', border:'none', borderRadius:10, padding:'12px', minHeight:44, fontFamily:'inherit', fontSize:13.5, fontWeight:700, cursor:'pointer'}} onClick={()=>{toggleAvoidUser(me,id); set({avoidUserConfirm:null, toast:'같은 모임 피하기를 설정했어요.'});}}>같은 모임 피하기</button>
      </div>
    </div>
  </div>;
}
function WithdrawConfirmSheet({S, set, go}) {
  const {useState} = React;
  const [busy, setBusy] = useState(false);
  if (!S.withdrawConfirmOpen) return null;
  const doWithdraw = async () => {
    setBusy(true);
    const email = S.acctEmail || S.signupEmail;
    await deleteMyData(email);
    await authSignOut();
    try { localStorage.removeItem(accountSessionKey(email)); } catch(e) {}
    setBusy(false);
    set({...logoutAccount(BASE), withdrawConfirmOpen:false, toast:'탈퇴 처리됐어요 — 그동안 이용해주셔서 감사해요'});
    go('onboarding');
  };
  return <div className="overlay" onClick={()=>!busy&&set({withdrawConfirmOpen:false})}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <h2 style={{fontSize:17, margin:'0 0 10px'}}>정말 탈퇴하시겠어요?</h2>
      <p style={{fontSize:13.5, color:'var(--c-sub)', lineHeight:1.65, margin:'0 0 16px'}}>탈퇴하면 이 계정으로 개설한 모임, 작성한 게시물·댓글이 서버에서 실제로 삭제되고 복구할 수 없어요.</p>
      <div style={{display:'flex', gap:8}}>
        <button disabled={busy} style={{flex:1, background:'none', border:'1px solid var(--c-line)', borderRadius:10, padding:'12px', minHeight:44, fontFamily:'inherit', fontSize:13.5, cursor:'pointer'}} onClick={()=>set({withdrawConfirmOpen:false})}>취소</button>
        <button disabled={busy} style={{flex:1, background:'var(--c-red)', color:'#fff', border:'none', borderRadius:10, padding:'12px', minHeight:44, fontFamily:'inherit', fontSize:13.5, fontWeight:700, cursor:'pointer'}} onClick={doWithdraw}>{busy?'처리 중...':'탈퇴하기'}</button>
      </div>
    </div>
  </div>;
}
// ------- 첫 방문 활동 선택 팝업 -------
const POPUP_ACTIVITIES = [
  ['러닝·운동','IconNormalTrophyNameTrophyFillFalse',/러닝|운동/],
  ['식사·맛집','IconNormalStarNameStarFillFalse',/브런치|식사|맛집|저녁/],
  ['카페','IconNormalCoffeeNameCoffeeFillFalse',/커피|카페/],
  ['보드게임','IconNormalDiamondNameDiamondFillFalse',/보드게임|게임/],
  ['전시·공연','IconNormalTicketNameTicketFillFalse',/전시|공연|문화생활/],
  ['여행','IconNormalCompassNameCompassFillFalse',/여행/],
  ['언어 교환','IconNormalBubbleNameBubbleFillFalse',/언어|회화|영어/],
  ['스터디','IconNormalBookNameBookFillFalse',/스터디/],
  ['펍·바','IconNormalFireNameFireFillFalse',/펍|바\b/],
  ['네트워킹','IconNormalPersonsNamePersonsFillFillTrue',/네트워킹|커리어|동문/],
  ['소개팅','IconNormalFaceSmileNameFaceSmileFillFalse',/소개팅/],
];
const FIRSTVISIT_KEY = nsKey('mm_firstvisit_seen_v1'); // deprecated — replaced by per-user welcome_pending/completed keys below
function welcomeUserId(S) { const e=((S&&S.signupEmail)||'').trim().toLowerCase(); return e||null; }
function welcomePendingKey(uid) { return nsKey('mm_welcome_pending_v1:'+uid); }
function welcomeCompletedKey(uid) { return nsKey('mm_welcome_completed_v1:'+uid); }
// 필수 온보딩(가입·인증·약관·프로필)을 실제로 마친 시점에만 호출 — 로그인·미리보기 경로에서는 절대 호출하지 않음
function markWelcomePending(S) { const uid=welcomeUserId(S); if(!uid) return; try { localStorage.setItem(welcomePendingKey(uid), '1'); } catch(e) {} }
function isWelcomePending(S) { const uid=welcomeUserId(S); if(!uid) return false; try { return localStorage.getItem(welcomePendingKey(uid))==='1'; } catch(e) { return false; } }
function isWelcomeCompleted(S) { const uid=welcomeUserId(S); if(!uid) return false; try { return localStorage.getItem(welcomeCompletedKey(uid))==='1'; } catch(e) { return false; } }
function completeWelcomePrompt(S) { const uid=welcomeUserId(S); if(!uid) return; try { localStorage.setItem(welcomeCompletedKey(uid), '1'); localStorage.removeItem(welcomePendingKey(uid)); } catch(e) {} }
// 표시 이름 정규화 — 우선순위: displayName > nickname > name, 앞뒤/중복 공백 정리, 줄바꿈 제거, 이미 '님'으로 끝나면 중복 방지
function displayNameOf(S) {
  const p = (S && S.profile) || {};
  const raw = p.displayName || p.nickname || p.name || '';
  if (typeof raw !== 'string') return '';
  return raw.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function honorificName(S) {
  const name = displayNameOf(S);
  if (!name) return '';
  return name.replace(/\s*님\s*$/, '').trim();
}
function suggestActivityIcon(text) {
  const hit = POPUP_ACTIVITIES.find(([,,re])=>re.test(text));
  return hit ? hit[1] : 'IconNormalSparkleNameSparkle';
}
function FirstVisitPopup({S, set}) {
  const {useState, useRef, useEffect} = React;
  // 다른 필수 모달이 열려 있으면 절대 겹쳐 띄우지 않음
  const otherModalOpen = !!(S.rulesOpen||S.legalOpen||S.reportOpen||S.reconfirmOpen||S.hostCompleteIdx!=null||S.reviewPopup||S.locPromptOpen||S.cancelConfirmId||S.avoidUserConfirm||S.applicantsIdx!=null||S.hostLimitNoticeOpen);
  // 인증된 사용자 + 온보딩 완료 + 로그인된 홈 화면 + (신규 가입 직후 pending이거나, 설정에서 명시적으로 다시 연 경우)에만 렌더링
  const eligible = !otherModalOpen && S.authStatus==='authenticated' && S.onboardingCompleted===true && S.screen==='home' && (S.forceActivityPicker===true || (isWelcomePending(S) && !isWelcomeCompleted(S)));
  const [open, setOpen] = useState(false);
  useEffect(()=>{ setOpen(eligible); }, [eligible]);
  const [sel, setSel] = useState([]);
  const [custom, setCustom] = useState('');
  const dialogRef = useRef(null);
  const closeBtnRef = useRef(null);
  const previouslyFocused = useRef(null);
  useEffect(()=>{
    if (!open) return;
    previouslyFocused.current = document.activeElement;
    closeBtnRef.current && closeBtnRef.current.focus();
    const onKey = e => { if (e.key==='Escape') dismiss(); };
    document.addEventListener('keydown', onKey);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return ()=>{ document.removeEventListener('keydown', onKey); document.body.style.overflow = prevOverflow; previouslyFocused.current && previouslyFocused.current.focus && previouslyFocused.current.focus(); };
  }, [open]);
  if (!open) return null;
  const dismiss = () => { completeWelcomePrompt(S); if (S.forceActivityPicker) set({forceActivityPicker:false}); setOpen(false); };
  const toggleSel = label => setSel(s=>s.includes(label)?s.filter(x=>x!==label):[...s, label]);
  const customOn = sel.includes('직접 입력');
  const canSubmit = sel.filter(x=>x!=='직접 입력').length>0 || (customOn && custom.trim());
  const submit = () => {
    dismiss();
    const first = sel.find(x=>x!=='직접 입력');
    set({screen:'browseregions', browseregionsType:'member', pendingActivityFilter: first||null, pendingCustomActivity: customOn?custom.trim():null});
  };
  return <div className="overlay" role="presentation" onClick={dismiss}>
    <div className="sheet" role="dialog" aria-modal="true" aria-labelledby="fvp-title" aria-describedby="fvp-desc" ref={dialogRef} onClick={e=>e.stopPropagation()} style={{maxWidth:480}}>
      <div className="row" style={{justifyContent:'space-between', alignItems:'flex-start', marginBottom:6}}>
        <h2 id="fvp-title" style={{fontSize:19, fontWeight:800, margin:0}}>오늘은 어떤 걸 함께하고 싶나요?</h2>
        <button ref={closeBtnRef} aria-label="환영 팝업 닫기" onClick={dismiss} style={{background:'none', border:'none', cursor:'pointer', padding:8, minWidth:44, minHeight:44, display:'flex', alignItems:'center', justifyContent:'center', color:'var(--c-faint)', flexShrink:0}}><Ic n="IconNormalCloseNameCloseThickFalse" s={18}/></button>
      </div>
      <p id="fvp-desc" style={{fontSize:13.5, color:'var(--c-sub)', lineHeight:1.6, margin:'0 0 16px'}}>모두모여는 유학생·워홀러·귀국자가 하고 싶은 활동을 중심으로 새로운 사람을 만나는 곳이에요.</p>
      <div style={{display:'grid', gridTemplateColumns:'repeat(3,1fr)', gap:8, marginBottom:6}}>
        {POPUP_ACTIVITIES.map(([label,icon])=>{ const on=sel.includes(label); return <button key={label} type="button" aria-pressed={on} onClick={()=>toggleSel(label)} style={{display:'flex', flexDirection:'column', alignItems:'center', gap:6, padding:'12px 6px', minHeight:44, borderRadius:12, border:'1.5px solid '+(on?'var(--c-primary)':'var(--c-line)'), background:on?'var(--c-primary-bg)':'none', cursor:'pointer', position:'relative'}}>
          {on&&<span style={{position:'absolute', top:4, right:4}}><Ic n="IconNormalCircleCheck2NameCircleCheckFillFillTrue" s={14} c="var(--c-primary)"/></span>}
          <ActIcon label={label} size={20} color={on?'var(--c-primary)':'var(--c-sub)'} fallbackIcon={icon}/>
          <span style={{fontSize:11.5, fontWeight:600, color:on?'var(--c-primary)':'var(--c-text)', textAlign:'center', lineHeight:1.3}}>{label}</span>
        </button>; })}
        <button type="button" aria-pressed={customOn} onClick={()=>toggleSel('직접 입력')} style={{display:'flex', flexDirection:'column', alignItems:'center', gap:6, padding:'12px 6px', minHeight:44, borderRadius:12, border:'1.5px solid '+(customOn?'var(--c-primary)':'var(--c-line)'), background:customOn?'var(--c-primary-bg)':'none', cursor:'pointer', position:'relative'}}>
          {customOn&&<span style={{position:'absolute', top:4, right:4}}><Ic n="IconNormalCircleCheck2NameCircleCheckFillFillTrue" s={14} c="var(--c-primary)"/></span>}
          <Ic n="IconNormalWriteNameWrite" s={20} c={customOn?'var(--c-primary)':'var(--c-sub)'}/>
          <span style={{fontSize:11.5, fontWeight:600, color:customOn?'var(--c-primary)':'var(--c-text)'}}>직접 입력</span>
        </button>
      </div>
      <div className="meta" style={{marginBottom:14}}>여러 개를 선택할 수 있어요.</div>
      {customOn&&<div style={{marginBottom:14}}>
        <div style={{fontSize:12.5, fontWeight:700, marginBottom:4}}>어떤 활동을 하고 싶나요?</div>
        <input value={custom} onChange={e=>setCustom(e.target.value.slice(0,60))} placeholder="예: 볼링, 도자기 만들기, 저녁 식사 후 산책" aria-label="하고 싶은 활동 직접 입력" style={{width:'100%', border:'1px solid var(--c-line)', borderRadius:8, padding:'10px 12px', fontFamily:'inherit', fontSize:13.5, outline:'none'}}/>
        <div className="meta" style={{marginTop:5}}>선택지에 없는 활동도 자유롭게 입력할 수 있어요.</div>
        {custom.trim()&&<div className="row" style={{gap:6, marginTop:8}}><Ic n={suggestActivityIcon(custom)} s={16} c="var(--c-primary)"/><span style={{fontSize:12, color:'var(--c-primary)'}}>입력한 활동에 어울리는 아이콘을 추천했어요.</span></div>}
      </div>}
      <div style={{display:'flex', flexDirection:'column', gap:8}}>
        <DSBtn label="선택한 활동 둘러보기" size="md" disable={!canSubmit} onClick={submit}/>
        <button onClick={dismiss} style={{background:'none', border:'none', color:'var(--c-faint)', fontFamily:'inherit', fontSize:13, fontWeight:600, cursor:'pointer', padding:'8px 0', minHeight:44}}>그냥 둘러볼게요</button>
      </div>
    </div>
  </div>;
}
function MeetupBlockedNotice({go}) {
  return <div className="scr"><div className="pad" style={{flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', textAlign:'center', gap:12}}>
    <Ic n="IconNormalEyeSlashNameEyeSlashFillFalse" s={32} c="var(--c-faint)"/>
    <b style={{fontSize:15}}>현재 이 모임의 상세 정보를 확인하거나 참가하기 어려워요.</b>
    <button style={{background:'var(--c-primary)', color:'#fff', border:'none', borderRadius:10, padding:'12px 20px', minHeight:44, fontFamily:'inherit', fontSize:13.5, fontWeight:700, cursor:'pointer'}} onClick={()=>go('home')}>홈으로 돌아가기</button>
  </div></div>;
}
function MeetupNotFoundNotice({go}) {
  return <div className="scr"><div className="pad" style={{flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', textAlign:'center', gap:10}}>
    <Ic n="IconNormalTelescopeNameTelescope" s={32} c="var(--c-faint)"/>
    <b style={{fontSize:15}}>모임을 찾을 수 없어요.</b>
    <p className="meta" style={{margin:0}}>삭제되었거나 현재 공개되지 않은 모임일 수 있어요.</p>
    <div className="row" style={{gap:8, marginTop:8}}>
      <button style={{background:'var(--c-primary)', color:'#fff', border:'none', borderRadius:10, padding:'12px 20px', minHeight:44, fontFamily:'inherit', fontSize:13.5, fontWeight:700, cursor:'pointer'}} onClick={()=>go('explore')}>모임 찾기로 돌아가기</button>
      <button style={{background:'none', border:'1px solid var(--c-line)', borderRadius:10, padding:'12px 20px', minHeight:44, fontFamily:'inherit', fontSize:13.5, fontWeight:700, cursor:'pointer'}} onClick={()=>go('home')}>홈으로 이동</button>
    </div>
  </div></div>;
}
function ChatNotFoundNotice({go, reason}) {
  return <div className="scr"><div className="pad" style={{flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', textAlign:'center', gap:10}}>
    <Ic n="IconNormalTelescopeNameTelescope" s={32} c="var(--c-faint)"/>
    <b style={{fontSize:15}}>대화방을 찾을 수 없어요.</b>
    <p className="meta" style={{margin:0}}>{reason||'종료되었거나 접근할 수 없는 대화일 수 있어요.'}</p>
    <div className="row" style={{gap:8, marginTop:8}}>
      <button style={{background:'var(--c-primary)', color:'#fff', border:'none', borderRadius:10, padding:'12px 20px', minHeight:44, fontFamily:'inherit', fontSize:13.5, fontWeight:700, cursor:'pointer'}} onClick={()=>go('mymeet')}>내 모임으로 이동</button>
      <button style={{background:'none', border:'1px solid var(--c-line)', borderRadius:10, padding:'12px 20px', minHeight:44, fontFamily:'inherit', fontSize:13.5, fontWeight:700, cursor:'pointer'}} onClick={()=>go('home')}>홈으로 이동</button>
    </div>
  </div></div>;
}
function MeetCard({m, onClick}) {
  const {useState, useEffect, useRef} = React;
  const [saved, setSaved] = useState(()=>isSaved(m.id));
  const pendingRef = useRef(false);
  useEffect(()=>{
    const onSavedChanged = e => { if (e.detail && e.detail.id===m.id) setSaved(e.detail.saved); };
    const onAccountChanged = () => setSaved(isSaved(m.id));
    document.addEventListener('mm:savedchanged', onSavedChanged);
    document.addEventListener('mm:accountchanged', onAccountChanged);
    return ()=>{ document.removeEventListener('mm:savedchanged', onSavedChanged); document.removeEventListener('mm:accountchanged', onAccountChanged); };
  }, [m.id]);
  const toggleSave = e => {
    e.stopPropagation();
    if (typeof getActiveAccountId === 'function' && getActiveAccountId()==='guest') {
      try { document.dispatchEvent(new CustomEvent('mm:guestaction')); } catch(err) {}
      return;
    }
    if (pendingRef.current) return; // 빠른 연속 클릭 시 한 번만 반영
    pendingRef.current = true;
    setSaved(toggleSaved(m.id));
    requestAnimationFrame(()=>{ pendingRef.current = false; });
  };
  const official = !!m.official;
  const hostName = (m.host&&m.host.name) || null;
  const hasReviewData = m.host && m.host.reviews!=null;
  const reviewCount = (m.host&&m.host.reviews) || 0;
  const sc = statusChip(m);
  const fee = feeLine(m);
  return <div className="mcard" role="button" tabIndex={0} onClick={onClick} onKeyDown={e=>{ if((e.key==='Enter'||e.key===' ') && e.target===e.currentTarget){ e.preventDefault(); onClick&&onClick(e); } }} style={m.expired?{opacity:.72}:undefined}>
    <div className="mcard-media" style={{background:meetupVisual(m).bg, display:'flex', alignItems:'center', justifyContent:'center'}}>
      {(m.photoUrl||m.hostPhoto)?<img src={m.photoUrl||m.hostPhoto} alt={m.title} loading="lazy" style={{width:'100%',height:'100%',objectFit:'cover'}} onError={e=>{e.currentTarget.style.display='none';}}/>:<ActIcon label={(m.cardTags&&m.cardTags[0]&&m.cardTags[0][0])||m.purpose||''} size={40} color={meetupVisual(m).fg} fallbackIcon={meetupVisual(m).icon}/>}
      <button type="button" className="mcard-save" aria-pressed={saved} aria-label={saved?'모임 찜 취소':'모임 찜하기'} onClick={toggleSave}>
        <Ic n={saved?'IconNormalHeartNameHeartFillFillTrue':'IconNormalHeartNameHeartFillFalse'} s={16} c="#fff"/>
      </button>
      {official&&<span className="mcard-badge primary">공식 이벤트</span>}
    </div>
    <div className="mcard-body">
      <div style={{display:'flex',gap:4,flexWrap:'wrap',marginBottom:6}}>
        <Chip label={sc.label} tone={sc.tone}/>
        {m.dateBucket&&dateBucketLabel(m.dateBucket)&&<Chip label={dateBucketLabel(m.dateBucket)} tone="line"/>}
        {/* 카테고리가 비어 있어도(구 데이터·비정상 입력) 크래시하지 않게 방어 */}
        {m.cardTags&&m.cardTags[0]&&m.cardTags[0][0]&&<Chip label={m.cardTags[0][0]} tone="primary"/>}
      </div>
      <div className="mcard-title">{m.title}</div>
      {/* 카드에서 바로 어느 나라·도시인지 보이게(사용자 요청 2026-07-30). '확정자만 공개' 모임은 region에 나라만 담겨 자동으로 나라 단위 표시 */}
      {m.region&&<div className="row" style={{gap:4, marginBottom:2}}><Ic n="IconNormalLocation2NameLocationFillFalse" s={13} c="var(--c-faint)"/><span className="meta">{m.region}</span></div>}
      <div className="meta">{m.when} · {m.size}</div>
      {fee&&<div className="meta" style={{marginTop:2}}>{fee}</div>}
      {hostName&&<div className="row" style={{gap:5,marginTop:6}}><Av name={hostName} size={18} photo={m.host&&m.host.photo}/><span style={{fontSize:11.5,color:'var(--c-sub)'}}>{hostName}</span>{isAdminEmail(m.hostEmail)&&<AdminBadge small/>}{hasReviewData&&(reviewCount>0?<span className="meta">· 후기 {reviewCount}</span>:<span className="meta">· 첫 모임</span>)}</div>}
      <div className="row" style={{gap:5,marginTop:7,justifyContent:'space-between'}}>
        {!m.expired&&m.soloWelcome?<span className="row" style={{gap:5}}><Ic n="IconNormalHeartNameHeartFillFalse" s={14} c="var(--c-violet)"/><span style={{fontSize:12,fontWeight:700,color:'var(--c-violet)'}}>혼자 참여 환영</span></span>:<span/>}
        <span style={{fontSize:11.5,fontWeight:700,color:m.expired?'var(--c-faint)':'var(--c-coral)'}}>{seatLabel(m)}</span>
      </div>
    </div>
  </div>;
}
// 약관 · 정책 · 가이드 모달
const LEGAL_DOCS = {
  terms:{title:'이용약관', body:[
    ['제1조 (목적)','이 약관은 모두모여(이하 "회사")가 제공하는 유학생·교환학생 대상 모임 매칭 서비스(이하 "서비스")의 이용과 관련하여 회사와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.'],
    ['제2조 (정의)','"회원"이란 이 약관에 동의하고 서비스에 가입한 자를 말하며, "모임"이란 회원이 개설하거나 참가 신청하는 오프라인·온라인 만남을 말합니다.'],
    ['제3조 (약관의 효력과 변경)','회사는 관련 법령을 위배하지 않는 범위에서 이 약관을 개정할 수 있으며, 개정 시 적용일자 7일 전(불리한 변경은 30일 전)부터 서비스 내 공지합니다.'],
    ['제4조 (회원가입 및 인증)','회원가입은 이메일 또는 소셜 계정으로 신청하며, 학교 이메일·재학 서류 인증은 선택사항으로 인증 배지로 표시됩니다. 인증은 사실 확인이며 신원을 보증하지 않습니다.'],
    ['제4조의2 (이용 연령)','본 서비스는 만 19세 이상만 이용할 수 있습니다. 만 19세 미만은 회원가입 및 서비스 이용이 불가하며, 가입 시 만 19세 이상임을 확인하는 절차를 거칩니다. 회사는 미성년자로 확인된 계정에 대해 이용계약을 해지할 수 있습니다.'],
    ['제5조 (모임 개설과 참가)','회원은 모임을 자유롭게 개설할 수 있으며, 참가 신청 시 모임별 이용수칙에 동의해야 합니다. 정원 초과 시 대기 등록되며 순번에 따라 자동 전환됩니다.'],
    ['제6조 (회원의 의무)','회원은 타 회원을 존중해야 하며, 허위 정보 등록, 상업적 목적의 홍보, 불법 정보 유포, 반복적 노쇼 등의 행위를 해서는 안 됩니다.'],
    ['제6조의2 (연락처 교환)','SNS·전화번호 등 개인 연락처 교환은 서비스 내 채팅이 아닌, 오프라인 모임에서 직접 만나 서로 동의한 이후에 하는 것을 권장합니다. 채팅방 등 서비스 내에서 연락처를 강요하거나 반복적으로 요구하는 행위는 제재 대상입니다.'],
    ['제7조 (이용 제한)','회사는 이 약관 또는 커뮤니티 가이드를 위반한 회원에 대해 경고, 일부 기능 제한, 이용계약 해지 등의 조치를 취할 수 있습니다.'],
    ['제8조 (책임의 한계)','회사는 회원 간 오프라인 모임을 주선하는 매칭 플랫폼을 제공할 뿐이며, 실제 모임 장소·시간에서 발생하는 회원의 안전, 부주의, 실수, 사고, 금전 거래, 분쟁 등에 대해 직접 관여하거나 책임지지 않습니다. 회사는 안전 가이드라인 제공, 신고 접수·처리 등 합리적인 조치를 취하되, 오프라인에서 발생한 결과 자체를 보증하지 않으며, 관련 법령이 정하는 범위를 넘어 책임지지 않습니다.'],
  ]},
  privacy:{title:'개인정보처리방침', body:[
    ['1. 수집하는 개인정보 항목','이메일, 닉네임, 학교 인증 정보(선택), 위치 기반 지역 정보(선택), 모임 참가·개설 이력을 수집합니다.'],
    ['2. 수집 및 이용 목적','회원 식별 및 인증, 모임 매칭 및 추천, 안전한 모임 운영을 위한 신뢰 정보 제공, 고객 문의 대응을 위해 이용합니다.'],
    ['3. 보유 및 이용 기간','회원 탈퇴 시 지체 없이 파기하며, 관계 법령에 따라 보관이 필요한 정보는 해당 기간 동안만 별도 보관합니다.'],
    ['4. 제3자 제공','회원의 동의 없이 개인정보를 제3자에게 제공하지 않으며, 법령에 근거가 있는 경우에 한해 예외로 합니다.'],
    ['5. 위치정보의 처리','현재 거주지 자동 인식은 회원의 별도 동의 하에 이루어지며, 언제든 설정에서 위치 기반 인증을 해제할 수 있습니다.'],
    ['6. 이용자의 권리','회원은 언제든 자신의 개인정보 열람, 정정, 삭제, 처리정지를 요청할 수 있습니다.'],
    ['7. 개인정보 보호책임자','문의: support@modumoyeo.com'],
  ]},
  guide:{title:'커뮤니티 가이드', body:[
    ['서로 존중하기','나이, 국적, 학교, 체류 신분과 상관없이 모든 회원을 동등하게 존중해주세요.'],
    ['안전 최우선','과도한 스킨십, 위협적인 언행, 원치 않는 연락처 요구는 즉시 신고 대상입니다.'],
    ['연락처 교환은 만난 후에','SNS·전화번호 교환은 채팅방 안에서가 아니라, 오프라인에서 직접 만나보고 서로 괜찮을 때 하는 걸 권장해요. 강요는 안 돼요.'],
    ['정직한 정보 등록','프로필, 모임 소개, 참가 목적은 사실대로 작성해주세요. 허위 정보는 제재 사유입니다.'],
    ['노쇼 방지','부득이하게 불참하게 되면 최소 24시간 전에 취소해주세요. 반복적인 노쇼는 참가 제한으로 이어질 수 있습니다.'],
    ['비용은 명확하게','모임 비용은 사전에 공지된 방식(개인 결제 등)을 따르며, 강요성 지출 요구는 금지됩니다.'],
    ['상업적 홍보 금지','다단계, 영업, 광고성 모임 개설은 금지되며 발견 시 삭제 및 이용 제한 조치됩니다.'],
    ['신고는 언제나 환영','불편하거나 위험한 상황은 주저하지 말고 신고해주세요. 신고자의 정보는 비공개로 보호됩니다.'],
  ]},
  safety:{title:'안전수칙', body:[
    ['모임 전','신뢰 배지(학교 인증 등)와 후기를 확인하세요. 처음 만나는 자리는 공개된 장소에서 진행돼요.'],
    ['모임 중','과도한 스킨십, 강압적 권유, 원치 않는 연락처 요구는 즉시 거절하고 신고해주세요.'],
    ['비용','별도 안내가 없다면 개인 비용은 각자 결제하는 게 기본이며, 강요성 지출 요구는 금지돼요.'],
    ['사진 촬영','참가자 동의 없이 사진을 SNS에 게시하지 마세요. 홍보 활용은 별도 선택 동의가 있을 때만 가능해요.'],
    ['불편한 상황','채팅·프로필·후기 어디서든 즉시 신고할 수 있고, 신고자 정보는 비공개로 보호돼요.'],
    ['연락처 교환 타이밍','SNS·전화번호는 모임에서 직접 만나 서로 괜찮다고 느낀 뒤 교환하는 걸 권장해요. 채팅방에서의 일방적·강압적 요구는 신고 대상이에요.'],
    ['학교 인증 배지 안내','학교 인증 배지는 학적 사실 확인일 뿐, 신원이나 안전을 보증하지 않아요.'],
  ]},
  faq:{title:'자주 묻는 질문', body:[
    ['모두모여는 소개팅 앱인가요?','아니에요. 지역·학교·관심사로 친구, 동문, 활동 파트너를 만나는 커뮤니티예요.'],
    ['학교 인증을 꼭 해야 하나요?','아니요, 선택이에요. 가입 후 프로필에서 언제든 진행할 수 있어요.'],
    ['해외에서도 이용할 수 있나요?','네, 해외 도시 모임도 지원해요. 다만 해외 모임은 지도 대신 텍스트 주소로 안내돼요.'],
    ['나이 제한이 있나요?','모두모여는 만 19세 이상 이용 가능한 서비스예요. 가입 시 만 19세 이상임을 확인해요.'],
    ['연락처는 자동으로 공개되나요?','아니요. 상대방과 서로 동의한 경우에만, 한 명씩 원하는 연락수단으로 공유돼요.'],
  ]},
};
function LegalModal({S, set}) {
  const doc = LEGAL_DOCS[S.legalOpen];
  if (!doc) return null;
  return <div className="overlay" onClick={()=>set({legalOpen:null})}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <div className="row" style={{justifyContent:'space-between', marginBottom:14}}>
        <h2 style={{fontSize:18, margin:0}}>{doc.title}</h2>
        <button style={{background:'none', border:'none', cursor:'pointer', padding:4}} onClick={()=>set({legalOpen:null})}><Ic n="IconNormalCloseNameCloseThickFalse" s={18} c="var(--c-faint)"/></button>
      </div>
      <div style={{fontSize:13, color:'var(--c-sub)', lineHeight:1.75}}>
        {doc.body.map((s,i)=><div key={i} style={{marginBottom:14}}><b style={{display:'block', color:'var(--c-text)', marginBottom:3, fontSize:13.5}}>{s[0]}</b>{s[1]}</div>)}
      </div>
    </div>
  </div>;
}
// 스크롤 등장 애니메이션 래퍼 — 화면에 들어오면 .in 클래스를 붙여 CSS 트랜지션으로 등장.
// fx: 'up'(기본, 아래→위) | 'pop'(살짝 커지며) | 'left' | 'right'. 접근성: prefers-reduced-motion은 CSS에서 처리.
function Reveal({children, delay=0, fx='up'}) {
  const ref = React.useRef(null);
  const [vis, setVis] = React.useState(false);
  React.useEffect(()=>{
    const el = ref.current; if (!el) return;
    if (!('IntersectionObserver' in window)) { setVis(true); return; }
    const io = new IntersectionObserver(([e])=>{
      if (e.isIntersecting) setVis(true);
      // 화면 '아래'로 다시 벗어난 요소만 리셋 → 위로 올렸다가 다시 내리면 애니메이션 재생.
      // 위로 지나간 요소(top<0)는 유지해야 스크롤 올릴 때 콘텐츠가 사라져 보이지 않음.
      else if (e.boundingClientRect.top > 0) setVis(false);
    }, {threshold:.15, rootMargin:'0px 0px -36px 0px'});
    io.observe(el);
    return ()=>io.disconnect();
  },[]);
  return <div ref={ref} className={'revcard'+(fx!=='up'?' fx-'+fx:'')+(vis?' in':'')} style={{transitionDelay:delay+'ms'}}>{children}</div>;
}
function ReportModal({S, set}) {
  const {useState} = React;
  const [target, setTarget] = useState(S.reportPrefill||'');
  const targetEmail = S.reportTargetEmail || null; // 게시글/댓글처럼 실제 계정과 연결된 신고일 때만 채워짐
  const reporterEmail = (S.acctEmail||S.signupEmail||'').trim().toLowerCase();
  const isSelfReport = !!(targetEmail && reporterEmail && targetEmail.trim().toLowerCase()===reporterEmail);
  const isListing = S.reportContext==='listing';
  // 특정 모임·글에서 연 신고는 대상이 그걸로 고정돼야 함 — 지우거나 다른 이름으로 바꿔치기 못 하게
  const lockedTarget = !!targetEmail || !!(S.reportPrefill||'').trim();
  const [reason, setReason] = useState(isListing?'허위 프로필':'성희롱');
  const [desc, setDesc] = useState('');
  const [img, setImg] = useState(null);
  const [sending, setSending] = useState(false);
  const [done, setDone] = useState(false); // 접수 완료 확인 화면(토스트는 순식간에 사라져 사용자가 못 봄 — 실제 피드백)
  const [submitErr, setSubmitErr] = useState('');
  const reasons = isListing?['허위 프로필','과도한 홍보·영업','사기 또는 금전 요구','안전 문제 우려','미성년자로 의심됨','기타']:['부적절한 언행','과도한 신체 접촉','성희롱','사기 또는 금전 요구','광고 및 홍보','노쇼','허위 프로필','사진 무단 게시','미성년자로 의심됨','기타'];
  const [imgFile, setImgFile] = useState(null); // 업로드용 원본 파일(미리보기는 img에 base64)
  const onFile = e => { const f=e.target.files&&e.target.files[0]; if(!f) return; setImgFile(f); const r=new FileReader(); r.onload=()=>setImg(r.result); r.readAsDataURL(f); };
  const close = () => set({reportOpen:false, reportPrefill:'', reportContext:null, reportTargetEmail:null});
  const submit = () => {
    if (isSelfReport) { set({toast:'본인이 작성한 글·계정은 신고할 수 없어요'}); return; } // 방어적 재확인
    if (sending) return;
    setSending(true); setSubmitErr('');
    // 스크린샷이 있으면 먼저 비공개 버킷에 업로드(26단계) — 실패하면 신고를 접수하지 않고 알림(첨부가 조용히 사라지는 것 방지)
    const uploadStep = imgFile ? uploadReportScreenshot(imgFile) : Promise.resolve({});
    uploadStep.then(up=>{
      if (up && up.error) { setSending(false); setSubmitErr('스크린샷 업로드에 실패했어요 — '+up.error+' 이미지를 제거하거나 다시 시도해주세요.'); return; }
    // 서버 응답을 확인한 뒤에만 닫는다 — 예전엔 실패해도 "접수됐어요"라고 떠서 신고가 조용히 사라질 수 있었음
    createReport({report_type:isListing?'모임 신고':'사용자 신고', target, target_email:targetEmail, reason, description:desc, reporter_email:S.acctEmail||S.signupEmail||'익명', screenshot_path:(up&&up.path)||null}).then(res=>{
      if (res && res.error) { console.error('신고 저장 실패', res.error); setSending(false); setSubmitErr('접수에 실패했어요 — 잠시 후 다시 시도해주세요. 계속 안 되면 support@modumoyeo.com 으로 알려주세요.'); return; }
      setSending(false); setDone(true);
    });
    });
  };
  if (done) return <div className="overlay" onClick={close}>
    <div className="sheet" onClick={e=>e.stopPropagation()} role="dialog" aria-modal="true">
      <div style={{textAlign:'center', padding:'8px 0 4px'}}><Ic n="IconNormalCircleCheck2NameCircleCheckFillFillTrue" s={44} c="var(--c-green)"/></div>
      <h2 style={{fontSize:18, margin:'10px 0 8px', textAlign:'center'}}>신고가 접수됐어요</h2>
      <p style={{fontSize:13.5, color:'var(--c-sub)', lineHeight:1.65, margin:'0 0 18px', textAlign:'center'}}>운영진이 내용을 확인한 뒤 필요한 조치를 진행할게요.<br/>접수한 신고는 내 정보의 신고 내역에서 볼 수 있어요.</p>
      <DSBtn label="확인" size="md" onClick={close}/>
    </div>
  </div>;
  if (isSelfReport) return <div className="overlay" onClick={close}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <h2 style={{fontSize:18, margin:'0 0 10px'}}>신고할 수 없어요</h2>
      <p style={{fontSize:13.5, color:'var(--c-sub)', lineHeight:1.6, margin:'0 0 16px'}}>본인이 작성한 글이나 본인 계정은 신고할 수 없어요.</p>
      <DSBtn label="확인" size="md" onClick={close}/>
    </div>
  </div>;
  return <div className="overlay" onClick={close}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <div className="row" style={{justifyContent:'space-between', marginBottom:14}}>
        <h2 style={{fontSize:18, margin:0}}>신고하기</h2>
        <button style={{background:'none', border:'none', cursor:'pointer', padding:4}} onClick={close}><Ic n="IconNormalCloseNameCloseThickFalse" s={18} c="var(--c-faint)"/></button>
      </div>
      <p style={{fontSize:12.5, color:'var(--c-sub)', margin:'0 0 16px', lineHeight:1.6}}>신고 내용은 운영진에게 바로 접수돼요. 스크린샷을 함께 첨부하면 더 빠르고 정확하게 확인할 수 있어요.</p>
      <div className="meta" style={{marginBottom:6}}>신고 대상 (닉네임 또는 모임명)</div>
      <input value={target} onChange={e=>{ if(!lockedTarget) setTarget(e.target.value); }} readOnly={lockedTarget} placeholder="예: 닉네임D · 런던 펍모임" style={{width:'100%', border:'1px solid var(--c-line)', borderRadius:8, padding:'9px 12px', fontFamily:'inherit', fontSize:14, outline:'none', marginBottom:lockedTarget?4:14, background:lockedTarget?'var(--c-fill-a)':undefined, color:lockedTarget?'var(--c-sub)':undefined}}/>
      {targetEmail?<div className="meta" style={{marginBottom:14, fontSize:11.5}}>이 글/댓글 작성 계정으로 자동 확인됐어요 (수정 불가)</div>
      :lockedTarget?<div className="meta" style={{marginBottom:14, fontSize:11.5}}>{isListing?'신고 대상이 이 모임으로 자동 지정됐어요 (수정 불가)':'신고 대상이 자동으로 지정됐어요 (수정 불가)'}</div>:null}
      <div className="meta" style={{marginBottom:6}}>신고 유형</div>
      <div style={{display:'flex', gap:6, flexWrap:'wrap', marginBottom:14}}>{reasons.map(r=><Chip key={r} label={r} tone={reason===r?'primary':'line'} onClick={()=>setReason(r)}/>)}</div>
      <div className="meta" style={{marginBottom:6}}>무슨 일이 있었나요?</div>
      <textarea value={desc} onChange={e=>setDesc(e.target.value)} rows={4} placeholder="상황을 최대한 자세히 적어주세요" style={{width:'100%', border:'1px solid var(--c-line)', borderRadius:8, padding:'9px 12px', fontFamily:'inherit', fontSize:14, outline:'none', resize:'none', marginBottom:14}}/>
      <div className="meta" style={{marginBottom:6}}>캡처(스크린샷) 첨부</div>
      {!img?<label style={{display:'flex', alignItems:'center', justifyContent:'center', gap:8, border:'1px dashed var(--c-line)', borderRadius:10, padding:'22px 12px', cursor:'pointer', color:'var(--c-faint)', fontSize:13, marginBottom:18}}>
        <Ic n="IconNormalImageNameImage" s={18}/>대화·프로필 캡처 이미지를 올려주세요
        <input type="file" accept="image/*" onChange={onFile} style={{display:'none'}}/>
      </label>:<div style={{position:'relative', marginBottom:18}}>
        <img src={img} alt="첨부 캡처" style={{width:'100%', maxHeight:220, objectFit:'contain', borderRadius:10, border:'1px solid var(--c-line)'}}/>
        <button style={{position:'absolute', top:8, right:8, background:'rgba(23,23,27,.7)', border:'none', borderRadius:20, padding:'11px 14px', minHeight:44, color:'#fff', fontSize:11.5, cursor:'pointer'}} onClick={()=>{setImg(null); setImgFile(null);}}>제거</button>
      </div>}
      {submitErr&&<div role="alert" style={{background:'var(--c-red-bg,#fdeaea)', color:'var(--c-red)', border:'1px solid var(--c-red)', borderRadius:8, padding:'10px 12px', marginBottom:12, fontSize:13, fontWeight:600}}>{submitErr}</div>}
      <DSBtn label={sending?'접수 중…':'신고 접수하기'} size="md" disable={!target.trim()||!desc.trim()||sending} onClick={submit}/>
    </div>
  </div>;
}
function HostCompleteModal({S, set}) {
  const {useState} = React;
  const i = S.hostCompleteIdx;
  const m = S.myHostedMeetups[i];
  if (m==null) return null;
  // 실제로 호스트가 승인한 신청자 명단을 사용 — 이전에는 테스트용 가짜 인원(ATTENDEES)을 보여줘서
  // 실제 신청자가 0명인 모임에서도 화면이 뭘 보여줘야 할지 헷갈렸던 원인이었음
  const roster = (m.applicants||[]).filter(a=>a.status==='confirmed');
  const names = roster.map(a=>a.name);
  const [checked, setChecked] = useState(()=>Object.fromEntries(names.map(nm=>[nm,true])));
  const [saving, setSaving] = useState(false);
  const [saveErr, setSaveErr] = useState('');
  const toggle = nm => setChecked(c=>({...c,[nm]:!c[nm]}));
  const report = nm => set({hostCompleteIdx:null, reportOpen:true, reportPrefill:`${nm} · ${m.title||'모임'}`});
  const attended = names.filter(nm=>checked[nm]);
  const finish = () => {
    if (saving) return;
    const applyLocal = () => {
      const arr = S.myHostedMeetups.map((mm,idx)=>idx===i?{...mm, done:true, attendedCount:attended.length}:mm);
      const hostedId = 'my'+i;
      const nextCompleted = S.completed.includes(hostedId) ? S.completed : [...S.completed, hostedId];
      set({myHostedMeetups:arr, completed:nextCompleted, hostCompleteIdx:null, reviewPopup:m.title||'모임', toast:`모임 완료 처리됐어요 — 참석 ${attended.length}명 기록 완료`});
    };
    // 21단계: 서버 모임이면 참석·노쇼를 서버에 먼저 기록(성공해야 완료) — 22단계 노쇼 제한의 근거 데이터
    if (m.serverId && isServerMeetupId(m.serverId)) {
      const attendedEmails = roster.filter(a=>a.email && checked[a.name]).map(a=>a.email);
      const noshowEmails = roster.filter(a=>a.email && !checked[a.name]).map(a=>a.email);
      setSaving(true); setSaveErr('');
      saveAttendance(m.serverId, attendedEmails, noshowEmails).then(res=>{
        setSaving(false);
        if (res && res.error) { setSaveErr(String(res.error.message||'').includes('MM_ATTENDANCE_LOCKED') ? '모임 후 48시간이 지나 참석 기록을 바꿀 수 없어요 — 정정이 필요하면 운영진에게 문의해주세요.' : '참석 기록 저장에 실패했어요 — 잠시 후 다시 시도해주세요.'); return; }
        applyLocal();
      });
      return;
    }
    applyLocal();
  };
  return <div className="overlay" onClick={()=>set({hostCompleteIdx:null})}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <h2 style={{fontSize:18, margin:'0 0 4px'}}>모임을 마쳤나요?</h2>
      <div className="meta" style={{marginBottom:16}}>{m.title||'(제목 없음)'} · {m.when||'일정 미정'}</div>
      <p style={{fontSize:12.5, color:'var(--c-sub)', margin:'0 0 14px', lineHeight:1.6}}>모임이 끝났다면 실제로 참석한 사람만 남겨주세요 — 체크된 참가자에게 모임 참석 +1회가 반영돼요. 노쇼했거나 정산을 하지 않은 사람은 신고할 수 있어요.</p>
      <div style={{marginBottom:16}}>
        {names.length===0&&<p className="meta" style={{textAlign:'center', padding:'14px 0'}}>참가 확정된 신청자가 없어요.</p>}
        {names.map(nm=><div key={nm} className="row" style={{justifyContent:'space-between', padding:'9px 0', borderBottom:'1px solid var(--c-line)'}}>
          <label className="row" style={{gap:8, cursor:'pointer'}}><input type="checkbox" checked={!!checked[nm]} onChange={()=>toggle(nm)}/><Av name={nm} size={26}/><span style={{fontSize:13.5, fontWeight:600}}>{nm}</span></label>
          <button style={{background:'none', border:'none', color:'var(--c-red)', fontFamily:'inherit', fontSize:12, fontWeight:700, cursor:'pointer'}} onClick={()=>report(nm)}>신고하기</button>
        </div>)}
      </div>
      {saveErr&&<div role="alert" className="notice orange" style={{marginBottom:12}}><Ic n="IconNormalCircleInfo2NameCircleInfoFillFalse" s={15}/><span>{saveErr}</span></div>}
      <DSBtn label={saving?'기록 저장 중…':`모임 완료 · 참석자 ${attended.length}명 +1회 반영`} size="md" disable={saving} onClick={finish}/>
    </div>
  </div>;
}
function ReviewPopup({S, set}) {
  const title = S.reviewPopup;
  if (!title) return null;
  const tags = ['도움이 됐어요','새로운 친구를 만났어요','현지 정보가 유용했어요'];
  const pick = t => set({reviewPopup:null, toast:`후기 감사해요 — "${t}" 의견을 반영할게요`});
  return <div className="overlay" onClick={()=>set({reviewPopup:null})}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <div className="row" style={{justifyContent:'space-between', marginBottom:6}}>
        <h2 style={{fontSize:17, margin:0}}>모두모여, 어땠나요?</h2>
        <button style={{background:'none', border:'none', cursor:'pointer', padding:4}} onClick={()=>set({reviewPopup:null})}><Ic n="IconNormalCloseNameCloseThickFalse" s={17} c="var(--c-faint)"/></button>
      </div>
      <p style={{fontSize:12.5, color:'var(--c-sub)', margin:'0 0 16px', lineHeight:1.6}}>"{title}" 모임이 완료됐어요. 모두모여가 어떤 부분에서 도움이 됐는지 알려주세요.</p>
      <div style={{display:'flex', flexDirection:'column', gap:8}}>{tags.map(t=><button key={t} onClick={()=>pick(t)} style={{textAlign:'left', border:'1px solid var(--c-line)', borderRadius:10, padding:'11px 14px', fontFamily:'inherit', fontSize:13.5, fontWeight:600, background:'var(--c-bg)', cursor:'pointer'}}>{t}</button>)}</div>
      <button style={{background:'none', border:'none', color:'var(--c-faint)', fontFamily:'inherit', fontSize:12.5, padding:'14px 0 0', cursor:'pointer'}} onClick={()=>set({reviewPopup:null})}>다음에 할게요</button>
    </div>
  </div>;
}
function TrustSignals({S, size='md'}) {
  const big = size==='lg';
  const attended = S.completed.length;
  const hosted = S.myHostedMeetups.length;
  const items = [
    [attended>0, `모임 ${attended}회 참석`],
    [true, '최근 노쇼 없음'],
    [hosted>0, `호스트 모임 ${hosted}회 운영`],
    [S.schoolBadges.length>0, '학교 인증 완료'],
    [S.consented, '커뮤니티 수칙 확인 완료'],
  ].filter(([on])=>on);
  return <div style={{display:'flex', flexDirection:'column', gap:big?8:6}}>
    {items.map(([,label])=><div key={label} className="row" style={{gap:7}}>
      <Ic n="IconNormalCircleCheck2NameCircleCheckFillFillTrue" s={big?16:14} c="var(--c-green)"/>
      <span style={{fontSize:big?14:12.5, fontWeight:600, color:'var(--c-text)'}}>{label}</span>
    </div>)}
  </div>;
}
// 신뢰 배지 (사실 확인형 — 부정 라벨 없음)
function TrustBadges({items, size=12}) {
  return <div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
    {items.slice(0,2).map(t=><span key={t} className="chip green" style={{fontSize:size}}><Ic n="IconNormalVerifiedCheck2NameVerifiedCheckFillFillTrue" s={size+2}/>{t}</span>)}
    {items.length>2&&<span className="chip" style={{fontSize:size}}>+{items.length-2}</span>}
  </div>;
}
function InfoRow({icon, children}) {
  return <div className="row" style={{gap:8, marginBottom:9, fontSize:13.5, alignItems:'flex-start'}}><Ic n={icon} s={17} c="var(--c-faint)" style={{marginTop:1}}/><span style={{flex:1, minWidth:0}}>{children}</span></div>;
}
// ------- 데이터 -------
const MEETUPS_DEMO = {
  m1:{id:'m1', title:'유학생 귀국 환영 웰컴파티', region:'서울 · 강남', when:'8월 2일(일) 오후 6시', whenFull:'2026년 8월 2일 일요일 · 오후 6:00', size:'대형 이벤트 · 정원 40명', lat:37.4979, lng:127.0276,
    cardTags:[['동문교류','primary'],['활발한 네트워킹','']], soloLine:'혼자 참여 환영', soloWelcome:true,
    tags:[['목적 · 동문교류','primary'],['분위기 · 활발한 네트워킹',''],['첫 참가자 환영','green'],['한국어·영어 혼합','line'],['대형 이벤트','line']],
    place:'강남 OO라운지', fee:'참가비 무료 (스폰서 협찬)', capMax:37, capJoined:37, waitlist:2, sponsor:'협찬 · OO은행', approvalMode:'instant', ageRange:'20~30대', prep:'준비물 없음', startsAt:'2026-08-02T18:00:00', durationHours:3,
    host:{name:'윤영진', badges:['인증 호스트','모임 12회 운영'], reviews:34},
    desc:'귀국한 지 얼마 안 됐거나, 곧 귀국 예정인 유학생을 위한 개주년 네트워킹 파티예요. 혼자 오셔도 웰컴 그룹으로 자연스럽게 어울릴 수 있게 도와드려요. OO은행에서 웰컴 키트를 준비했어요.'},
  m2:{id:'m2', title:'강남 밤모임', region:'서울 · 강남', when:'8월 2일(일) 오후 7시', whenFull:'2026년 8월 2일 일요일 · 오후 7:00', size:'4~6명', lat:37.4980, lng:127.0296,
    cardTags:[['친구 만들기','primary'],['조용한 대화','']], soloLine:'혼자 참여 환영', soloWelcome:true,
    tags:[['목적 · 친구 만들기','primary'],['분위기 · 조용한 대화',''],['술 없는 모임','green'],['한국어','line'],['4~6명','line']],
    purposes:['카페','네트워킹'],
    place:'강남역 카페', fee:'예상 1인 1.5~2.5만원 (커피·간단한 안주 · 현장에서 각자 결제)', capMax:6, capJoined:5, waitlist:0, approvalMode:'approval', placeVisibility:'confirmed', ageRange:'20~30대', prep:'준비물 없음', startsAt:'2026-08-02T19:00:00', durationHours:2,
    host:{name:'지은', badges:['인증 호스트','모임 12회 운영'], reviews:24},
    desc:'퇴근 후 조용히 이야기 나누는 소규모 모임이에요. 유학 시절 이야기, 귀국 후 적응 이야기 모두 환영해요.'},
  m6:{id:'m6', title:'서울 웰컴모임 — 9월 귀국자 환영', region:'서울 · 성수', when:'9월 6일(일) 오후 4시', whenFull:'2026년 9월 6일 일요일 · 오후 4:00', size:'6명 내외', lat:37.5445, lng:127.0557,
    cardTags:[['최근 귀국자','violet'],['첫 참가자 환영','green']], soloLine:'런던 모임 멤버 2명 참가 예정', soloWelcome:true,
    tags:[['목적 · 귀국 적응','primary'],['분위기 · 조용한 대화',''],['첫 참가자 환영','green'],['권장 · 최근 귀국자','violet'],['6명 내외','line']],
    place:'성수 OO카페', fee:'예상 1인 1~1.5만원 (음료·디저트 · 현장에서 각자 결제)', capMax:6, capJoined:5, waitlist:0, approvalMode:'instant', ageRange:'20~30대', prep:'준비물 없음', startsAt:'2026-09-06T16:00:00', durationHours:2,
    host:{name:'윤영진', badges:['인증 호스트','모임 12회 운영'], reviews:34},
    desc:'이번 가을 귀국한 분들을 위한 웰컴모임이에요. 런던·토론토에서 만났던 멤버들도 서울에서 다시 모여요.'},
};
const MEETUPS = DEMO_MODE ? MEETUPS_DEMO : {};
const COUNTRIES = ['대한민국','영국','미국','캐나다','호주','뉴질랜드','아일랜드','독일','프랑스','네덜란드','스위스','스웨덴','싱가포르','일본','중국','홍콩','대만','말레이시아','필리핀','베트남','태국','스페인','이탈리아','오스트리아','벨기에','덴마크','노르웨이','핀란드','포르투갈'];
const COUNTRY_META = {
  '대한민국':{code:'KR',city:'서울'}, '영국':{code:'GB',city:'런던'}, '미국':{code:'US',city:'뉴욕'}, '캐나다':{code:'CA',city:'토론토'},
  '호주':{code:'AU',city:'시드니'}, '뉴질랜드':{code:'NZ',city:'오클랜드'}, '아일랜드':{code:'IE',city:'더블린'}, '독일':{code:'DE',city:'베를린'},
  '프랑스':{code:'FR',city:'파리'}, '네덜란드':{code:'NL',city:'암스테르담'}, '스위스':{code:'CH',city:'취리히'}, '스웨덴':{code:'SE',city:'스톡홀름'},
  '싱가포르':{code:'SG',city:'싱가포르'}, '일본':{code:'JP',city:'도쿄'}, '중국':{code:'CN',city:'상하이'}, '홍콩':{code:'HK',city:'홍콩'},
  '대만':{code:'TW',city:'타이베이'}, '말레이시아':{code:'MY',city:'쿠알라룸푸르'}, '필리핀':{code:'PH',city:'마닐라'}, '베트남':{code:'VN',city:'호치민'},
  '태국':{code:'TH',city:'방콕'}, '스페인':{code:'ES',city:'마드리드'}, '이탈리아':{code:'IT',city:'밀라노'}, '오스트리아':{code:'AT',city:'빈'},
  '벨기에':{code:'BE',city:'브뤼셀'}, '덴마크':{code:'DK',city:'코펜하겐'}, '노르웨이':{code:'NO',city:'오슬로'}, '핀란드':{code:'FI',city:'헬싱키'}, '포르투갈':{code:'PT',city:'리스본'},
};
function countryOf(name){ return COUNTRY_META[name] || COUNTRY_META['대한민국']; }
const NEIGHBORHOODS = {
  '서울':['강남','홍대','이태원'], '런던':['소호','캠든','쇼디치'], '뉴욕':['맨해튼','브루클린','퀸즈'], '토론토':['다운타운','노스욕','스카버러'],
  '시드니':['서리힐스','본다이','CBD'], '오클랜드':['폰소비','뉴마켓','CBD'], '더블린':['템플바','랜스다운','시티센터'], '베를린':['크로이츠베르크','미테','프렌츠라우어베르크'],
  '파리':['마레','라탱','몽마르트'], '암스테르담':['요르단','데 파이프','중앙역 인근'], '취리히':['니더도르프','취리히 서부','시내'], '스톡홀름':['쇠데르말름','감라스탄','외스테르말름'],
  '싱가포르':['오차드','티옹바루','부기스'], '도쿄':['신주쿠','시부야','코엔지'], '상하이':['징안','프렌치 콘세션','푸동'], '홍콩':['센트럴','몽콕','코즈웨이베이'],
  '타이베이':['다안','시먼딩','신이'], '쿠알라룸푸르':['방사르','KLCC','몽키아라'], '마닐라':['마카티','BGC','말라테'], '호치민':['1군','빈탄','푸미흥'],
  '방콕':['통로','실롬','아리'], '마드리드':['말라사냐','살라망카','라바피에스'], '밀라노':['나빌리','브레라','이솔라'], '빈':['노이바우','레오폴트슈타트','시내'],
  '브뤼셀':['유러피언 쿼터','이슬레크','생 질'], '코펜하겐':['뇌레브로','베스터브로','크리스티안스하운'], '오슬로':['그뤼네르뢰카','프로그네르','시내'], '헬싱키':['칼리오','퇸퇴','시내'], '리스본':['시아두','알파마','벨렝'],
};
function genCityMeetups(city){
  if (!DEMO_MODE) return [];
  const hoods = NEIGHBORHOODS[city] || ['시내','중심가','대학가'];
  return [
    {id:'gen-'+city+'-1', title:`${hoods[0]} 유학생 네트워킹`, when:`토 오후 7시 · ${hoods[0]}`, size:'4~6명', cardTags:[['네트워킹','primary'],['조용한 대화','']], soloLine:'첫 참가자 환영 · 대기 1'},
    {id:'gen-'+city+'-2', title:`${hoods[1]} 스터디 모임`, when:`수 오후 6시 · ${hoods[1]}`, size:'4~6명', cardTags:[['스터디','primary'],['첫 참가자 환영','green']], soloLine:'방금 등록됨'},
    {id:'gen-'+city+'-1p', title:`${hoods[2]} 브런치 모임`, when:`지난 화 오후 11시 · ${hoods[2]}`, size:'4~6명', cardTags:[['브런치','primary']], soloLine:'21명 참여 완료', expired:true},
  ];
}
function DatePicker({value, onChange}) {
  const {useState, useRef, useEffect} = React;
  const [open, setOpen] = useState(false);
  const [viewDate, setViewDate] = useState(()=> value ? new Date(value+'T00:00:00') : new Date());
  const ref = useRef(null);
  useEffect(()=>{
    if(!open) return;
    const onDoc = e => { if(ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return ()=>document.removeEventListener('mousedown', onDoc);
  },[open]);
  const y = viewDate.getFullYear(), m = viewDate.getMonth();
  const first = new Date(y,m,1);
  const startDay = first.getDay();
  const daysInMonth = new Date(y,m+1,0).getDate();
  const todayStr = new Date().toISOString().slice(0,10);
  const cells = [];
  for (let i=0;i<startDay;i++) cells.push(null);
  for (let d=1; d<=daysInMonth; d++) cells.push(d);
  const pick = d => { const iso = `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; if (iso<todayStr) return; onChange(iso); setOpen(false); };
  const isPastMonth = (y<new Date().getFullYear()) || (y===new Date().getFullYear() && m<new Date().getMonth());
  const label = value ? `${value.slice(0,4)}년 ${+value.slice(5,7)}월 ${+value.slice(8,10)}일` : '날짜 선택';
  return <div ref={ref} style={{position:'relative'}}>
    <button type="button" onClick={()=>setOpen(o=>!o)} style={{width:'100%', display:'flex', alignItems:'center', gap:8, border:'1px solid var(--c-line)', borderRadius:12, padding:'10px 14px', fontFamily:'inherit', fontSize:14, background:'var(--c-bg)', cursor:'pointer', color:value?'var(--c-text)':'var(--c-faint)'}}>
      <Ic n="IconNormalCalendarNameCalendar" s={16} c="var(--c-primary)"/>{label}
    </button>
    {open&&<div className="card" style={{position:'absolute', top:'calc(100% + 6px)', left:0, zIndex:20, width:264, padding:14, boxShadow:'0 8px 24px rgba(0,0,0,.12)'}}>
      <div className="row" style={{justifyContent:'space-between', marginBottom:10}}>
        <button type="button" disabled={isPastMonth} onClick={()=>setViewDate(new Date(y,m-1,1))} style={{background:'none', border:'none', cursor:isPastMonth?'not-allowed':'pointer', color:isPastMonth?'var(--c-line)':'var(--c-faint)', fontSize:16}}>‹</button>
        <b style={{fontSize:13.5}}>{y}년 {m+1}월</b>
        <button type="button" onClick={()=>setViewDate(new Date(y,m+1,1))} style={{background:'none', border:'none', cursor:'pointer', color:'var(--c-faint)', fontSize:16}}>›</button>
      </div>
      <div style={{display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:2, marginBottom:2}}>{['일','월','화','수','목','금','토'].map(d=><div key={d} style={{textAlign:'center', fontSize:11, color:'var(--c-faint)', padding:'4px 0'}}>{d}</div>)}</div>
      <div style={{display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:2}}>
        {cells.map((d,i)=>{
          if (!d) return <div key={i}></div>;
          const iso = `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
          const isToday = iso===todayStr, isSel = iso===value, isPast = iso<todayStr;
          return <button key={i} type="button" disabled={isPast} onClick={()=>pick(d)} title={isPast?'지난 날짜는 선택할 수 없어요':undefined} style={{aspectRatio:'1', border:'none', borderRadius:8, cursor:isPast?'not-allowed':'pointer', fontFamily:'inherit', fontSize:12.5, fontWeight:isSel?800:500, background:isSel?'var(--c-primary)':'none', color:isPast?'var(--c-line)':isSel?'#fff':isToday?'var(--c-primary)':'var(--c-text)'}}>{d}</button>;
        })}
      </div>
    </div>}
  </div>;
}
function EventStatBar({label, count, cap, color}) {
  const pct = Math.min(100, Math.round(count/cap*100));
  return <div style={{marginBottom:8}}>
    <div className="row" style={{justifyContent:'space-between', marginBottom:4}}>
      <span style={{fontSize:12, fontWeight:700, color:'var(--c-sub)'}}>{label}</span>
      <span style={{fontSize:12, fontWeight:800, color}}>{count}/{cap}명</span>
    </div>
    <div style={{height:7, borderRadius:4, background:'var(--c-fill)', overflow:'hidden'}}><div style={{height:'100%', width:pct+'%', background:color, borderRadius:4, transition:'width .5s ease'}}></div></div>
  </div>;
}
function EventStats({e}) {
  if (e.mode==='firstcome') {
    const full = e.applied>=e.cap;
    const hot = e.applied >= e.cap*0.75;
    return <div>
      {hot&&<Chip label="🔥 마감임박" tone="primary" style={{marginBottom:8}}/>}
      <EventStatBar label="신청 현황" count={e.applied} cap={e.cap} color={full?'var(--c-red)':'var(--c-primary)'}/>
      <p className="meta" style={{margin:'2px 0 0'}}>선착순 마감 — 정원이 차면 자동으로 마감되고, 이후엔 대기 신청으로 전환돼요.</p>
    </div>;
  }
  const hot = e.applied >= e.cap;
  return <div>
    {hot&&<Chip label="🔥 정원 초과 · 추첨 진행" tone="violet" style={{marginBottom:8}}/>}
    <EventStatBar label="신청 현황" count={e.applied} cap={e.cap} color={hot?'var(--c-violet)':'var(--c-primary)'}/>
    <p className="meta" style={{margin:'2px 0 0'}}>{hot?'벌써 정원을 넘었어요 — 운영진이 사연을 보고 추첨해요.':'참여할수록 더 재밌어져요 — 지금 신청해보세요.'}</p>
  </div>;
}
function TimePicker({value, onChange}) {
  const {useState, useRef, useEffect} = React;
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  const [hh, mm] = (value||'19:00').split(':').map(Number);
  const pm = hh>=12;
  const h12 = ((hh%12)||12);
  useEffect(()=>{
    if(!open) return;
    const onDoc = e => { if(ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return ()=>document.removeEventListener('mousedown', onDoc);
  },[open]);
  const set24 = (h12v, mmv, pmv) => { let h = h12v%12; if(pmv) h+=12; onChange(`${String(h).padStart(2,'0')}:${String(mmv).padStart(2,'0')}`); };
  const label = value ? `${pm?'오후':'오전'} ${h12}:${String(mm).padStart(2,'0')}` : '시간 선택';
  return <div ref={ref} style={{position:'relative'}}>
    <button type="button" onClick={()=>setOpen(o=>!o)} style={{width:'100%', display:'flex', alignItems:'center', gap:8, border:'1px solid var(--c-line)', borderRadius:12, padding:'10px 14px', fontFamily:'inherit', fontSize:14, background:'var(--c-bg)', cursor:'pointer', color:value?'var(--c-text)':'var(--c-faint)'}}>
      <Ic n="IconNormalClockNameClockFillFalse" s={16} c="var(--c-primary)"/>{label}
    </button>
    {open&&<div className="card" style={{position:'absolute', top:'calc(100% + 6px)', left:0, zIndex:20, width:220, padding:14, boxShadow:'0 8px 24px rgba(0,0,0,.12)'}}>
      <div style={{display:'flex', gap:6, marginBottom:12}}>
        {['오전','오후'].map(t=><button key={t} type="button" onClick={()=>set24(h12, mm, t==='오후')} style={{flex:1, border:'none', borderRadius:8, padding:'7px 0', fontFamily:'inherit', fontSize:12.5, fontWeight:700, cursor:'pointer', background:(t==='오후')===pm?'var(--c-primary)':'var(--c-fill)', color:(t==='오후')===pm?'#fff':'var(--c-sub)'}}>{t}</button>)}
      </div>
      <div style={{display:'flex', alignItems:'center', justifyContent:'center', gap:6}}>
        <div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:6}}>
          <button type="button" onClick={()=>set24(h12%12+1, mm, pm)} style={{background:'none', border:'none', cursor:'pointer', color:'var(--c-faint)'}}>▲</button>
          <b style={{fontSize:20, width:36, textAlign:'center'}}>{String(h12).padStart(2,'0')}</b>
          <button type="button" onClick={()=>set24((h12+10)%12+1, mm, pm)} style={{background:'none', border:'none', cursor:'pointer', color:'var(--c-faint)'}}>▼</button>
        </div>
        <b style={{fontSize:20}}>:</b>
        <div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:6}}>
          <button type="button" onClick={()=>set24(h12, (mm+5)%60, pm)} style={{background:'none', border:'none', cursor:'pointer', color:'var(--c-faint)'}}>▲</button>
          <b style={{fontSize:20, width:36, textAlign:'center'}}>{String(mm).padStart(2,'0')}</b>
          <button type="button" onClick={()=>set24(h12, (mm+55)%60, pm)} style={{background:'none', border:'none', cursor:'pointer', color:'var(--c-faint)'}}>▼</button>
        </div>
      </div>
      <DSBtn label="완료" size="md" style={{width:'100%', marginTop:12}} onClick={()=>setOpen(false)}/>
    </div>}
  </div>;
}
function synthDetailFromCard(m) {
  const nums = (m.size||'').match(/\d+/g);
  const capMax = nums&&nums.length ? +nums[nums.length-1] : 6;
  const place = (m.when||'').includes('·') ? m.when.split('·').slice(1).join('·').trim() : '현지 장소 (주소 안내 예정)';
  // 표시된 "오전/오후 N시" 텍스트에서 시간을 뽑고, id를 시드로 한 결정적 날짜와 합쳐 실제 겹침 판정에 쓸 시작시각을 만들어요.
  let hour = 19;
  const tm = (m.when||'').match(/(오전|오후)\s*(\d{1,2})시/);
  if (tm) { hour = parseInt(tm[2],10)%12; if (tm[1]==='오후') hour += 12; }
  let seed = 0; for (const ch of String(m.id||'')) seed = (seed*31 + ch.charCodeAt(0)) % 9973;
  const dayOffset = seed % 14;
  const base = new Date('2026-08-01T00:00:00'); base.setDate(base.getDate()+dayOffset);
  base.setHours(hour, 0, 0, 0);
  return {
    id:m.id, title:m.title, region:place, place, when:m.when, size:m.size,
    fee:'예상 1인 1.5~2.5만원 (현장에서 각자 결제)', lat:null, lng:null,
    capJoined:Math.max(1, Math.round(capMax*0.6)), capMax,
    tags:m.cardTags||[], cardTags:m.cardTags||[],
    host:{name:'현지 호스트', badges:['운영진 확인'], reviews:0}, approvalMode:'instant',
    startsAt: base.toISOString().slice(0,19), durationHours:2,
    desc:m.soloLine||'현지 회원이 개설한 모임이에요. 참가 전 이용수칙을 꼭 확인해주세요.',
  };
}
const EMAIL_DOMAINS = ['gmail.com','naver.com','daum.net','kakao.com','icloud.com','outlook.com','nate.com','hanmail.net'];
// 단일 이메일 입력란 + 네이티브 자동완성(datalist)만 보조로 제공 — 아이디/도메인 분리 입력 사용성 문제 해결.
let __emailFieldSeq = 0;
function EmailField({value, onChange, placeholder, externalError, id}) {
  const {useRef} = React;
  const listId = React.useRef('email-domains-'+(++__emailFieldSeq)).current;
  const formatBad = value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
  const invalid = formatBad || !!externalError;
  const errText = externalError || (formatBad?'이메일 형식을 확인해주세요 (예: you@example.com)':'');
  const errId = id?id+'-err':undefined;
  return <div>
    <input id={id} type="email" list={listId} value={value} onChange={e=>onChange(e.target.value)} placeholder={placeholder||'you@example.com'} autoComplete="email" aria-invalid={invalid||undefined} aria-describedby={invalid?errId:undefined} style={{width:'100%', border:'1px solid '+(invalid?'var(--c-red)':'var(--c-line)'), borderRadius:8, padding:'10px 12px', fontFamily:'inherit', fontSize:14, outline:'none'}}/>
    <datalist id={listId}>{EMAIL_DOMAINS.map(d=>{ const local=(value||'').split('@')[0]; return <option key={d} value={(local||'')+'@'+d}/>; })}</datalist>
    {invalid&&<div id={errId} className="meta" style={{color:'var(--c-red)', marginTop:5, fontSize:12}}>{errText}</div>}
  </div>;
}
// 비밀번호 입력 — 요구조건 명시 + 보이기/숨기기 + 실시간 충족 표시
function PasswordField({value, onChange, placeholder, showRequirements, externalError, id, autoComplete}) {
  const {useState} = React;
  const [show, setShow] = useState(false);
  const longEnough = value.length>=8;
  const invalid = !!externalError;
  const errId = id?id+'-err':undefined;
  return <div>
    <div style={{position:'relative'}}>
      <input id={id} type={show?'text':'password'} value={value} onChange={e=>onChange(e.target.value)} placeholder={placeholder||'8자 이상, 영문·숫자 포함'} autoComplete={autoComplete||'current-password'} aria-invalid={invalid||undefined} aria-describedby={invalid?errId:undefined} style={{width:'100%', border:'1px solid '+(invalid?'var(--c-red)':'var(--c-line)'), borderRadius:8, padding:'10px 40px 10px 14px', fontFamily:'inherit', fontSize:14, outline:'none'}}/>
      <button type="button" aria-label={show?'비밀번호 숨기기':'비밀번호 보이기'} onClick={()=>setShow(s=>!s)} style={{position:'absolute', right:2, top:'50%', transform:'translateY(-50%)', background:'none', border:'none', cursor:'pointer', padding:'6px 10px', minHeight:44, minWidth:44, display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'inherit', fontSize:12, fontWeight:700, color:'var(--c-primary)'}}>
        {show?'숨기기':'보기'}
      </button>
    </div>
    {invalid&&<div id={errId} className="meta" style={{color:'var(--c-red)', marginTop:5, fontSize:12}}>{externalError}</div>}
    {showRequirements&&<div className="row" style={{gap:6, marginTop:6}}>
      <Ic n={longEnough?'IconNormalCircleCheck2NameCircleCheckFillFillTrue':'IconNormalCircleInfo2NameCircleInfoFillFalse'} s={13} c={longEnough?'var(--c-green)':'var(--c-faint)'}/>
      <span style={{fontSize:12, color:longEnough?'var(--c-green)':'var(--c-faint)'}}>8자 이상 (영문·숫자·특수문자 조합 권장)</span>
    </div>}
  </div>;
}
const ACTIVITY_PLACEHOLDERS = ['예: 보드게임을 하거나 같이 저녁을 먹고 싶어요','예: 보드게임, 저녁 식사, 한강 러닝'];
// 목록에 없는 활동을 자유 입력받는 공용 컴포넌트 — 관심사 설정, 탐색, 모임 개설에서 재사용.
function CustomActivityInput({items, onAdd, onRemove, existingPool, limit=100}) {
  const {useState} = React;
  const [text, setText] = useState('');
  const [phIdx] = useState(()=>Math.floor(Math.random()*ACTIVITY_PLACEHOLDERS.length));
  const trimmed = text.trim();
  const similar = existingPool && trimmed ? existingPool.find(p=>p.includes(trimmed)||trimmed.includes(p)) : null;
  const commit = () => {
    if (!trimmed || (items||[]).includes(trimmed)) { setText(''); return; }
    onAdd(trimmed);
    setText('');
  };
  return <div>
    <div style={{fontSize:13.5, fontWeight:700, marginBottom:4}}>원하는 활동이 목록에 없나요?</div>
    <div className="meta" style={{marginBottom:8}}>하고 싶은 활동을 직접 입력해 주세요.</div>
    <div style={{display:'flex', gap:8}}>
      <input value={text} onChange={e=>setText(e.target.value.slice(0,limit))} onKeyDown={e=>{if(e.key==='Enter'){e.preventDefault(); commit();}}} placeholder={ACTIVITY_PLACEHOLDERS[phIdx]} aria-label="원하는 활동 직접 입력" style={{flex:1, minWidth:0, border:'1px solid var(--c-line)', borderRadius:'var(--r-sm)', padding:'10px 14px', fontFamily:'inherit', fontSize:14, outline:'none'}}/>
      <button type="button" onClick={commit} disabled={!trimmed} style={{background:trimmed?'var(--c-primary)':'var(--c-fill)', color:trimmed?'#fff':'var(--c-faint)', border:'none', borderRadius:'var(--r-sm)', padding:'0 18px', fontFamily:'inherit', fontSize:13.5, fontWeight:700, cursor:trimmed?'pointer':'default'}}>추가</button>
    </div>
    <div className="row" style={{justifyContent:'space-between', marginTop:6}}>
      <span className="meta">목록에 없는 활동이나 여러 활동을 자유롭게 적어도 괜찮아요.</span>
      <span className="meta">{text.length}/{limit}</span>
    </div>
    {similar&&<div className="meta" style={{marginTop:6, color:'var(--c-primary)'}}>&ldquo;{similar}&rdquo;과(와) 비슷한 관심사가 있어요. 기존 항목을 선택하거나 직접 입력한 표현을 그대로 사용할 수 있어요.</div>}
    {items&&items.length>0&&<div style={{display:'flex', flexWrap:'wrap', gap:8, marginTop:12}}>
      {items.map(t=><span key={t} className="chip coral" style={{gap:6}}>{t}<button type="button" aria-label={t+' 삭제'} onClick={()=>onRemove(t)} style={{background:'none', border:'none', color:'inherit', cursor:'pointer', fontSize:13, fontWeight:800, padding:0, lineHeight:1}}>×</button></span>)}
    </div>}
  </div>;
}
function Tooltip({text}) {
  const [show, setShow] = React.useState(false);
  return <span style={{position:'relative', display:'inline-flex'}} onMouseEnter={()=>setShow(true)} onMouseLeave={()=>setShow(false)}>
    <span style={{width:16, height:16, borderRadius:'50%', background:'var(--c-fill)', color:'var(--c-faint)', fontSize:10.5, fontWeight:700, display:'flex', alignItems:'center', justifyContent:'center', cursor:'default'}}>?</span>
    {show&&<span style={{position:'absolute', bottom:'calc(100% + 6px)', right:0, width:200, background:'var(--c-text)', color:'var(--c-bg)', fontSize:11.5, lineHeight:1.5, padding:'8px 10px', borderRadius:8, zIndex:50, fontWeight:400}}>{text}</span>}
  </span>;
}
function Pagination({page, total, onChange}) {
  if (total<=1) return null;
  const pages = [];
  const push = n => { if(!pages.includes(n)) pages.push(n); };
  push(1); push(total);
  for (let d=-1; d<=1; d++) push(page+d);
  const sorted = pages.filter(n=>n>=1&&n<=total).sort((a,b)=>a-b);
  const withGaps = [];
  sorted.forEach((n,i)=>{ if(i>0 && n-sorted[i-1]>1) withGaps.push('...'); withGaps.push(n); });
  const btn = {background:'none', border:'1px solid var(--c-line)', borderRadius:10, minWidth:34, height:34, padding:'0 8px', fontFamily:'inherit', fontSize:13, fontWeight:600, cursor:'pointer', color:'var(--c-text)'};
  return <div className="row" style={{gap:6, justifyContent:'center', marginTop:24}}>
    <button style={{...btn, display:'flex', alignItems:'center', gap:4, opacity:page<=1?.4:1}} disabled={page<=1} onClick={()=>onChange(page-1)}><Ic n="IconNormalChevronLeftNameChevronLeftTightFalseThick" s={11}/>Previous</button>
    {withGaps.map((n,i)=>n==='...'?<span key={'g'+i} style={{padding:'0 4px', color:'var(--c-faint)'}}>···</span>:<button key={n} style={{...btn, background:n===page?'var(--c-primary)':'var(--c-bg)', color:n===page?'#fff':'var(--c-text)', borderColor:n===page?'var(--c-primary)':'var(--c-line)'}} onClick={()=>onChange(n)}>{n}</button>)}
    <button style={{...btn, display:'flex', alignItems:'center', gap:4, opacity:page>=total?.4:1}} disabled={page>=total} onClick={()=>onChange(page+1)}>Next<Ic n="IconNormalChevronRightNameChevronRightTightFalseThick" s={11}/></button>
  </div>;
}
function computeHomeData(S) {
  const CUR_BY_CODE=DEMO_MODE?{
    KR:[MEETUPS.m2,MEETUPS.m1,{id:'x1',title:'귀국 취준 커피챗',when:'수 오후 8시',size:'4~6명',cardTags:[['커리어','primary'],['조용한 대화','']],soloLine:'첫 참가자 환영 · 대기 1'},{id:'x9',title:'홍대 스터디 모임',when:'토 오후 2시',size:'4~6명',cardTags:[['스터디','primary'],['첫 참가자 환영','green']],soloLine:'방금 등록됨'},{id:'x1p',title:'강남 커리어 네트워킹',when:'지난 화 오후 7시',size:'4~6명',cardTags:[['네트워킹','primary']],soloLine:'28명 참여 완료',expired:true}],
    GB:[{id:'c-gb1',title:'런던 한인 브런치 모임',when:'토 오전 11시',size:'4~6명',cardTags:[['브런치','primary'],['조용한 대화','']],soloLine:'첫 참가자 환영 · 대기 1'},{id:'c-gb2',title:'런던 스터디 카페 모임',when:'화 오후 7시',size:'4~6명',cardTags:[['스터디','primary'],['첫 참가자 환영','green']],soloLine:'방금 등록됨'},{id:'c-gb1p',title:'런던 유학생 펍모임',when:'지난 목 오후 8시',size:'4~6명',cardTags:[['펍모임','primary']],soloLine:'22명 참여 완료',expired:true}],
    US:[{id:'c-us1',title:'뉴욕 커리어 커피챗',when:'수 오후 6시',size:'4~6명',cardTags:[['커리어','primary'],['조용한 대화','']],soloLine:'첫 참가자 환영 · 대기 1'},{id:'c-us2',title:'뉴욕 러닝 크루',when:'토 오전 9시',size:'4~6명',cardTags:[['운동','primary'],['첫 참가자 환영','green']],soloLine:'방금 등록됨'},{id:'c-us1p',title:'뉴욕 유학생 네트워킹',when:'지난 일 오전 11시',size:'4~6명',cardTags:[['네트워킹','primary']],soloLine:'31명 참여 완료',expired:true}],
    CA:[{id:'c-ca1',title:'토론토 유학생 네트워킹',when:'금 오후 7시',size:'4~6명',cardTags:[['네트워킹','primary'],['조용한 대화','']],soloLine:'첫 참가자 환영 · 대기 1'},{id:'c-ca2',title:'토론토 스터디 모임',when:'일 오후 2시',size:'4~6명',cardTags:[['스터디','primary'],['첫 참가자 환영','green']],soloLine:'방금 등록됨'},{id:'c-ca1p',title:'토론토 워홀러 브런치',when:'지난 토 오전 11시',size:'4~6명',cardTags:[['브런치','primary']],soloLine:'19명 참여 완료',expired:true}],
    BE:[{id:'c-be1',title:'브뤼셀 유학생 브런치',when:'토 오전 11시',size:'4~6명',cardTags:[['브런치','primary'],['조용한 대화','']],soloLine:'첫 참가자 환영 · 대기 1'},{id:'c-be2',title:'브뤼셀 스터디 모임',when:'화 오후 7시',size:'4~6명',cardTags:[['스터디','primary'],['첫 참가자 환영','green']],soloLine:'방금 등록됨'},{id:'c-be1p',title:'브뤼셀 유학생 모임',when:'지난 금 오후 7시',size:'4~6명',cardTags:[['모임','primary']],soloLine:'15명 참여 완료',expired:true}],
  }:{};
  const ABROAD_ALL=DEMO_MODE?[{code:'GB',id:'x2',title:'한인 유학생 펍모임',when:'금 오후 8시 · 런던',size:'4~6명',cardTags:[['GB 런던','line'],['현지생활 적응','primary']],soloLine:'혼자 참여 환영'},{code:'CA',id:'x3',title:'워홀러 브런치 모임',when:'토 오전 11시 · 토론토',size:'4~6명',cardTags:[['CA 토론토','line'],['첫 참가자 환영','green']],soloLine:'첫 참가자 비율 70%'},{code:'KR',id:'x5',title:'서울 귀국생 네트워킹',when:'토 오후 3시 · 서울',size:'10명 내외',cardTags:[['KR 서울','line'],['동문 교류','primary']],soloLine:'혼자 참여 환영'},{code:'US',id:'x6',title:'뉴욕 유학생 네트워킹 브런치',when:'일 오전 11시 · 뉴욕',size:'4~6명',cardTags:[['US 뉴욕','line'],['현지생활 적응','primary']],soloLine:'혼자 참여 환영'},{code:'BE',id:'x7',title:'브뤼셀 유학생 모임',when:'금 오후 7시 · 브뤼셀',size:'4~6명',cardTags:[['BE 브뤼셀','line'],['현지생활 적응','primary']],soloLine:'혼자 참여 환영'},{code:'GB',id:'x2p',title:'런던 워홀러 네트워킹',when:'지난 수 오후 7시 · 런던',size:'4~6명',cardTags:[['GB 런던','line']],soloLine:'26명 참여 완료',expired:true},{code:'US',id:'x6p',title:'뉴욕 커리어 네트워킹',when:'지난 월 오후 6시 · 뉴욕',size:'4~6명',cardTags:[['US 뉴욕','line']],soloLine:'33명 참여 완료',expired:true},{code:'CA',id:'x3p',title:'토론토 스터디 모임',when:'지난 화 오후 2시 · 토론토',size:'4~6명',cardTags:[['CA 토론토','line']],soloLine:'17명 참여 완료',expired:true},{code:'BE',id:'x7p',title:'브뤼셀 커리어 네트워킹',when:'지난 목 오후 7시 · 브뤼셀',size:'4~6명',cardTags:[['BE 브뤼셀','line']],soloLine:'14명 참여 완료',expired:true}]:[];
  const myMeta=countryOf(S.regionVals.cur);
  const interestMeta=countryOf(S.regionVals.interest);
  const myCode=myMeta.code, myCity=myMeta.city;
  const interestCode=interestMeta.code, interestCity=interestMeta.city;
  const myHostedForCity = S.myHostedMeetups
    .map((m,i)=>({m, i}))
    .filter(({m})=> myCode==='KR' ? (!m.regionType || m.regionType==='kr') : m.regionType==='abroad')
    .map(({m,i})=>({id:'my'+i, title:m.title||'(제목 없음)', when:m.when||'일정 미정', size:(m.cap||'6')+'명 정원', cardTags:[[m.purpose||'모임','primary'],['개설예정','violet']], soloLine:'내가 개설한 모임', mine:true}));
  const seoul=visibleMeetups([...(CUR_BY_CODE[myCode]||genCityMeetups(myCity)), ...myHostedForCity], S.viewAsUser);
  const curCityTitle=`${myCity} · 이번 주 모임`;
  const interestFromAbroad=ABROAD_ALL.filter(m=>m.code===interestCode && m.code!==myCode);
  const abroad=interestFromAbroad.length?interestFromAbroad:genCityMeetups(interestCity);
  const abroadTitle=`타지 모임 — ${interestCity}`;
  return {seoul, curCityTitle, abroad, abroadTitle, myCode, myCity, interestCode, interestCity};
}
const HOME_TIMELINE_SEED = [
  {who:'닉네임A', text:'강남 밤모임 후기 올렸어요 — 조용해서 좋았어요', meta:'방금 전 · 좋아요 8', action:{screen:'detail', detailId:'m2'}},
  {who:'닉네임B', text:'"영국 워홀 질문있어요" 글을 남겼어요', meta:'10분 전', action:{screen:'postdetail', post:{cat:1, tag:'해외거주 Q&A', title:'지금 영국에서 워홀 중인데, 저 같은 사람 여기 있나요? GB', body:'런던 온 지 3개월 됐는데 여기서도 한인 유학생·워홀러 분들과 연결돼서 지내고 싶어요.', meta:'닉네임B · 런던'}}},
  {who:'닉네임C', text:'유학생 귀국 환영 웰컴파티에 참가했어요', meta:'32분 전', action:{screen:'detail', detailId:'m1'}},
  {who:'닉네임D', text:'"서울 사는 미국 고등학교 출신 분 계신가요?" 글에 댓글을 남겼어요', meta:'1시간 전', action:{screen:'postdetail', post:{cat:2, tag:'친구만들기', title:'서울 사는 미국 고등학교 출신 분 계신가요? 밤약 하실 분', body:'귀국한 지 1년 됐는데 아직도 유학 시절 얘기할 친구가 없어요.', meta:'닉네임E · 서울'}}},
  {who:'닉네임E', text:'모두모여 이벤트 — 서울 유학생 소개팅 파티에 관심 등록했어요', meta:'2시간 전', action:{screen:'events'}},
  {who:'닉네임F', text:'런던 펍모임에 참가했어요', meta:'3시간 전', action:{screen:'homeabroad'}},
  {who:'닉네임G', text:'"토론토 워홀 비자 팁 나눠요" 글을 올렸어요', meta:'5시간 전', action:{screen:'postdetail', post:{cat:1, tag:'해외거주 Q&A', title:'토론토 워홀 비자 팁 나눠요', body:'워홀 비자 준비하며 헷갈렸던 부분 정리해봤어요.', meta:'닉네임G · 토론토'}}},
  {who:'닉네임H', text:'홍대 스터디 모임 후기를 남겼어요', meta:'어제', action:{screen:'detail', detailId:'m1'}},
];
const HOME_TIMELINE = DEMO_MODE ? HOME_TIMELINE_SEED : [];
function CountrySelect({value, onChange, exclude}) {
  const {useState, useRef, useEffect} = React;
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState('');
  const ref = useRef(null);
  useEffect(()=>{
    if(!open) return;
    const onDoc = e => { if(ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return ()=>document.removeEventListener('mousedown', onDoc);
  },[open]);
  const pool = exclude ? COUNTRIES.filter(c=>!exclude.includes(c)) : COUNTRIES;
  const filtered = q ? pool.filter(c=>c.includes(q)) : pool;
  const pick = c => { onChange(c); setOpen(false); setQ(''); };
  const commitCustom = () => { if (!q.trim()) return; onChange(q.trim()); setOpen(false); setQ(''); };
  return <div ref={ref} style={{position:'relative', zIndex:open?50:'auto'}}>
    <button type="button" onClick={()=>setOpen(o=>!o)} style={{width:'100%', display:'flex', alignItems:'center', gap:8, border:'1px solid var(--c-line)', borderRadius:12, padding:'10px 14px', fontFamily:'inherit', fontSize:14, background:'var(--c-bg)', cursor:'pointer', color:value?'var(--c-text)':'var(--c-faint)', textAlign:'left'}}>
      <Ic n="IconNormalGlobe2NameGlobeFillFalse" s={15} c="var(--c-primary)"/><span style={{flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}}>{value||'국가 검색'}</span>
    </button>
    {open&&<div className="card" style={{position:'absolute', top:'calc(100% + 6px)', left:0, right:0, zIndex:200, padding:10, boxShadow:'0 8px 24px rgba(0,0,0,.12)'}}>
      <input autoFocus value={q} onChange={e=>setQ(e.target.value)} onKeyDown={e=>{if(e.key==='Enter' && !filtered.length) commitCustom();}} placeholder="국가명을 입력하세요" style={{width:'100%', border:'1px solid var(--c-line)', borderRadius:8, padding:'8px 12px', fontFamily:'inherit', fontSize:13.5, outline:'none', marginBottom:8}}/>
      <div style={{maxHeight:180, overflowY:'auto', display:'flex', flexDirection:'column', gap:2}}>
        {filtered.length?filtered.map(c=><button key={c} type="button" onClick={()=>pick(c)} style={{textAlign:'left', background:c===value?'var(--c-primary-bg)':'none', border:'none', borderRadius:8, padding:'8px 10px', fontFamily:'inherit', fontSize:13.5, cursor:'pointer', color:c===value?'var(--c-primary)':'var(--c-text)'}}>{c}</button>):<p className="meta" style={{padding:'6px 10px'}}>검색 결과가 없어요.</p>}
      </div>
      <button type="button" onClick={commitCustom} disabled={!q.trim()} style={{width:'100%', marginTop:8, background:'none', border:'1px dashed var(--c-line)', borderRadius:8, padding:'9px 10px', fontFamily:'inherit', fontSize:12.5, fontWeight:700, color:'var(--c-primary)', cursor:q.trim()?'pointer':'default', opacity:q.trim()?1:.5}}>찾는 나라가 없나요? "{q||'...'}" 직접 입력하기</button>
    </div>}
  </div>;
}
const MAJOR_SCHOOLS = [
  'Harvard University','Stanford University','MIT','UC Berkeley','UCLA','University of Southern California','New York University','Columbia University','University of Toronto',
  'University of British Columbia','University of Waterloo','McGill University','University of Oxford','University of Cambridge','Imperial College London','University College London',
  'King\'s College London','University of Manchester','University of Edinburgh','University of Sydney','University of Melbourne','Australian National University','University of Auckland',
  'National University of Singapore','Nanyang Technological University','University of Tokyo','Waseda University','Keio University','Peking University','Tsinghua University','University of Hong Kong',
];
function schoolStoreKey() { return 'modumoyeo_pending_schools_v2'; }
function loadCustomSchools() { try { return JSON.parse(localStorage.getItem(schoolStoreKey())||'[]'); } catch(e) { return []; } }
function saveCustomSchool(name) {
  if (!name || !name.trim()) return;
  const list = loadCustomSchools();
  if (!list.includes(name) && !MAJOR_SCHOOLS.includes(name)) { list.push(name); localStorage.setItem(schoolStoreKey(), JSON.stringify(list)); }
}
function SchoolSelect({value, onChange}) {
  const {useState, useRef, useEffect} = React;
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState('');
  const [verified, setVerified] = useState({});
  const ref = useRef(null);
  useEffect(()=>{
    if(!open) return;
    const onDoc = e => { if(ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return ()=>document.removeEventListener('mousedown', onDoc);
  },[open]);
  const isCustom = value && !MAJOR_SCHOOLS.includes(value);
  useEffect(()=>{
    if (!isCustom || verified[value]) return;
    const t = setTimeout(()=>setVerified(v=>({...v, [value]:true})), 3000);
    return ()=>clearTimeout(t);
  }, [value, isCustom]);
  const filtered = q ? MAJOR_SCHOOLS.filter(s=>s.toLowerCase().includes(q.toLowerCase())) : MAJOR_SCHOOLS;
  const pick = s => { onChange(s); setOpen(false); setQ(''); };
  const commitCustom = () => { if (!q.trim()) return; onChange(q.trim()); saveCustomSchool(q.trim()); setOpen(false); setQ(''); };
  return <div>
  <div ref={ref} style={{position:'relative', zIndex:open?50:'auto'}}>
    <button type="button" onClick={()=>setOpen(o=>!o)} style={{width:'100%', display:'flex', alignItems:'center', gap:8, border:'1px solid var(--c-line)', borderRadius:12, padding:'10px 14px', fontFamily:'inherit', fontSize:14, background:'var(--c-bg)', cursor:'pointer', color:value?'var(--c-text)':'var(--c-faint)', textAlign:'left'}}>
      <Ic n="IconNormalSearchNameSearchThickFalse" s={15} c="var(--c-primary)"/><span style={{flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}}>{value||'학교 검색'}</span>
    </button>
    {open&&<div className="card" style={{position:'absolute', top:'calc(100% + 6px)', left:0, right:0, zIndex:200, padding:10, boxShadow:'0 8px 24px rgba(0,0,0,.12)'}}>
      <input autoFocus value={q} onChange={e=>setQ(e.target.value)} onKeyDown={e=>{if(e.key==='Enter' && !filtered.length) commitCustom();}} placeholder="학교명을 입력하세요 (영문)" style={{width:'100%', border:'1px solid var(--c-line)', borderRadius:8, padding:'8px 12px', fontFamily:'inherit', fontSize:13.5, outline:'none', marginBottom:8}}/>
      <div style={{maxHeight:180, overflowY:'auto', display:'flex', flexDirection:'column', gap:2}}>
        {filtered.length?filtered.map(s=><button key={s} type="button" onClick={()=>pick(s)} style={{textAlign:'left', background:s===value?'var(--c-primary-bg)':'none', border:'none', borderRadius:8, padding:'8px 10px', fontFamily:'inherit', fontSize:13.5, cursor:'pointer', color:s===value?'var(--c-primary)':'var(--c-text)'}}>{s}</button>):<p className="meta" style={{padding:'6px 10px'}}>검색 결과가 없어요.</p>}
      </div>
      <button type="button" onClick={commitCustom} disabled={!q.trim()} style={{width:'100%', marginTop:8, background:'none', border:'1px dashed var(--c-line)', borderRadius:8, padding:'9px 10px', fontFamily:'inherit', fontSize:12.5, fontWeight:700, color:'var(--c-primary)', cursor:q.trim()?'pointer':'default', opacity:q.trim()?1:.5}}>목록에 없는 학교인가요? "{q||'...'}" 입력하기</button>
      <p className="meta" style={{margin:'6px 2px 0', lineHeight:1.5}}>일단 입력하신 이름 그대로 가입·이용할 수 있어요 — 운영진이 확인되는 대로 정식 학교명으로 자동 반영돼요.</p>
    </div>}
  </div>
  {isCustom&&<div className="row" style={{gap:5, marginTop:6}}>
    {verified[value]?<Chip icon="IconNormalCircleCheck2NameCircleCheckFillFillTrue" label="운영진 확인 완료" tone="green"/>:<Chip icon="IconNormalCircleInfo2NameCircleInfoFillFalse" label="운영진 검토 중" tone=""/>}
  </div>}
  </div>;
}
function Brand({go, size=19}) {
  return <button onClick={()=>go('home')} style={{background:'none', border:'none', cursor:'pointer', padding:0, display:'flex', alignItems:'center', gap:8, textAlign:'left'}}>
    <img src={asset('assets/modumoyeo-icon.png')} alt="" style={{width:size*1.7, height:size*1.7, borderRadius:8, flexShrink:0, objectFit:'cover'}} onError={e=>{e.target.style.display='none'; e.target.nextSibling.style.display='flex';}}/>
    <span style={{display:'none', width:size*1.7, height:size*1.7, borderRadius:8, flexShrink:0, background:'var(--c-primary)', color:'#fff', alignItems:'center', justifyContent:'center', fontFamily:'var(--f-disp)', fontWeight:800, fontSize:size*0.9}}>M</span>
    <span style={{display:'flex', flexDirection:'column', lineHeight:1.05, fontFamily:'var(--f-disp)', fontWeight:800}}>
      <span style={{fontSize:size, color:'var(--c-text)'}}>modu</span>
      <span style={{fontSize:size, color:'var(--c-primary)'}}>moyeo</span>
    </span>
  </button>;
}
// 카카오맵 JS 키 — 실제 배포 시 빌드 환경변수로 주입되면 자동 인식돼요.
// Vite: VITE_KAKAO_MAP_JAVASCRIPT_KEY / Next.js: NEXT_PUBLIC_KAKAO_MAP_JAVASCRIPT_KEY
function getKakaoMapKey() {
  try { if (typeof process !== 'undefined' && process.env) { return process.env.NEXT_PUBLIC_KAKAO_MAP_JAVASCRIPT_KEY || process.env.VITE_KAKAO_MAP_JAVASCRIPT_KEY || ''; } } catch(e) {}
  return (window.__APP_CONFIG__ && (window.__APP_CONFIG__.NEXT_PUBLIC_KAKAO_MAP_JAVASCRIPT_KEY || window.__APP_CONFIG__.VITE_KAKAO_MAP_JAVASCRIPT_KEY)) || '';
}
let __kakaoSdkPromise = null;
// ── Supabase 연동 (1단계: 모임 데이터 공유만 — 로그인은 아직 데모 그대로 유지) ──────────
// 실제 배포 시 window.__APP_CONFIG__.SUPABASE_URL / SUPABASE_PUBLISHABLE_KEY로 주입됨.
// 둘 중 하나라도 없거나 supabase-js 스크립트가 로드되지 않았으면 아래 함수들은 전부 안전하게 빈 값/no-op으로 동작
// → 기존 데모 동작에는 전혀 영향 없음(연동 안 된 상태와 동일하게 작동).
function getSupabaseConfig() {
  const cfg = (typeof window !== 'undefined' && window.__APP_CONFIG__) || {};
  return { url: cfg.SUPABASE_URL || '', key: cfg.SUPABASE_PUBLISHABLE_KEY || '' };
}
let __supabaseClient;
function getSupabaseClient() {
  if (__supabaseClient !== undefined) return __supabaseClient;
  const { url, key } = getSupabaseConfig();
  if (!url || !key || typeof window === 'undefined' || typeof window.supabase === 'undefined') { __supabaseClient = null; return null; }
  try { __supabaseClient = window.supabase.createClient(url, key); } catch(e) { __supabaseClient = null; }
  return __supabaseClient;
}
// Supabase의 meetups 행 → 기존 화면(MeetCard, WScrDetail 등)이 기대하는 모임 객체 형태로 변환
// 실제 날짜(YYYY-MM-DD)를 기준으로 오늘/이번주/다음주/이번달/한달 이상 뒤로 구분
function dateBucketOf(dateStr) {
  if (!dateStr) return null;
  const d = new Date(dateStr+'T00:00:00');
  if (isNaN(d)) return null;
  const now = new Date(); now.setHours(0,0,0,0);
  const diffDays = Math.round((d-now)/86400000);
  if (diffDays < 0) return 'past';
  if (diffDays === 0) return 'today';
  const curDow = now.getDay(); // 0=일요일
  const daysToSunday = 7-curDow;
  if (diffDays <= daysToSunday) return 'week';
  if (diffDays <= daysToSunday+7) return 'nextweek';
  if (diffDays <= 31) return 'month';
  return 'later';
}
function dateBucketLabel(bucket) {
  return {today:'오늘', week:'이번 주', nextweek:'다음 주', month:'이번 달', later:'한 달 이후', past:'지난 모임'}[bucket] || '';
}
function mapRemoteMeetup(row) {
  const purposes = row.purposes || [];
  return {
    id: 'r_'+row.id, remote:true,
    title: row.title, when: row.when_label||'일정 미정', region: [row.country, row.city].filter(Boolean).join(' · '),
    place: row.place||'', purposes, purpose: purposes[0]||'',
    cardTags: purposes.map(p=>[p,'primary']), size:(row.cap||6)+'명 정원',
    tags: purposes.map(p=>[p,'primary']),
    capMax: row.cap||6, capJoined: 0, approvalMode: row.approval_mode||'instant', waitlistOk: row.waitlist_ok!==false,
    fee: row.fee_text || '참가비 없음', desc: row.description||'', soloWelcome: !!row.solo_welcome, photoUrl: row.photo_url||null, eventDate: row.event_date||null, dateBucket: dateBucketOf(row.event_date), expired: dateBucketOf(row.event_date)==='past',
    host: { name: row.host_name||'호스트', badges:['실제 등록 모임'], reviews:null, photo: row.host_photo_url||null },
    hostEmail: (row.host_email||'').toLowerCase(),
    placeVisibility: row.place_visibility||'public',
    lat: row.lat||null, lng: row.lng||null,
  };
}
// 실제 서버에 등록된 모임 전체를 가져옴(연동 안 됐으면 빈 배열 — 기존 데모 동작 그대로)
// 실제 서버(Supabase)에서 불러온 모임을 전역 캐시에 저장 — "내 모임", "찜한 모임" 등 다른 화면에서도 이 ID로 찾을 수 있게 함
const REMOTE_MEETUP_CACHE = {};
async function fetchRemoteMeetups() {
  const sb = getSupabaseClient();
  if (!sb) return [];
  try {
    const { data, error } = await sb.from('meetups').select('*').order('created_at', {ascending:false}).limit(100);
    if (error) { console.error('fetchRemoteMeetups', error); return []; }
    let mapped = (data||[]).map(mapRemoteMeetup);
    mapped = mapped.filter(m=>!isBlockedEmail(m.hostEmail)); // 24단계: 차단한/차단당한 상대의 모임 숨김
    // 확정 인원 실계산(12단계 보강) — participants 서버 카운트로 capJoined를 채움
    const counts = await fetchMeetupCounts(mapped.map(m=>m.id));
    mapped.forEach(m=>{ m.capJoined = counts[m.id]||0; REMOTE_MEETUP_CACHE[m.id] = m; });
    return mapped;
  } catch(e) { console.error('fetchRemoteMeetups', e); return []; }
}
// 모임을 ID로 찾을 때 데모 목록만 보지 않고, 내가 개설한 모임·실제 서버 모임(캐시)까지 전부 확인
function resolveMeetup(id, S) {
  if (!id) return null;
  if (MEETUPS[id]) return MEETUPS[id];
  if (REMOTE_MEETUP_CACHE[id]) return REMOTE_MEETUP_CACHE[id];
  const hostedMatch = /^my(\d+)$/.exec(id);
  if (hostedMatch && S && S.myHostedMeetups && S.myHostedMeetups[+hostedMatch[1]]) {
    const hm = S.myHostedMeetups[+hostedMatch[1]];
    return {id, title:hm.title||'(제목 없음)', when:hm.when||'일정 미정', place:hm.place||'', region:hm.place||'', capMax:parseInt(hm.cap)||6, capJoined:(hm.applicants||[]).filter(a=>a.status==='confirmed').length, host:{name:S.profile.nickname||'나', badges:['인증 호스트'], reviews:null}, purposes:hm.purposes||[], cardTags:(hm.purposes||[]).map(p=>[p,'primary'])};
  }
  return null;
}
// 새 모임을 실제 서버에 등록. 연동 안 됐으면 { skipped:true }를 반환하고 아무 것도 하지 않음(로컬 데모 흐름만 그대로 진행됨)
async function createRemoteMeetup(payload) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  try {
    // RLS(주최자 본인 확인)를 반드시 통과하도록 host_email은 항상 실제 로그인 세션의 이메일로 강제
    // (예전에 화면 상태의 이메일이 비어 me@example.com 같은 값이 실려 저장이 조용히 실패하던 버그 방지)
    try { const s = await sb.auth.getSession(); const em = s && s.data && s.data.session && s.data.session.user && s.data.session.user.email; if (em) payload = {...payload, host_email: em}; } catch(e) {}
    const { data, error } = await sb.from('meetups').insert([payload]).select().single();
    if (error) { console.error('createRemoteMeetup', error); return { error }; }
    // 주최자는 정원에 자동 포함(사용자 정책 2026-07-29) — 개설 즉시 확정 참가자로 등록
    try { await sb.from('participants').insert([{meetup_id:data.id, user_email:payload.host_email, user_name:payload.host_name||'호스트', status:'confirmed'}]); } catch(e) { console.error('host auto-join', e); }
    return { data };
  } catch(e) { return { error:e }; }
}
// 장소 상세(16단계) — 보호 테이블 저장·조회. RLS: 조회는 호스트·운영진·확정 참가자만
async function saveMeetupPlaceDetail(meetupId, detail) {
  const sb = getSupabaseClient();
  const mid = serverMeetupId(meetupId);
  if (!sb || !mid) return { skipped:true };
  try {
    if (!detail) { const { error } = await sb.from('meetup_place_details').delete().eq('meetup_id', mid); if (error) return { error }; return { ok:true }; }
    const { error } = await sb.from('meetup_place_details').upsert([{meetup_id:mid, detail, updated_at:new Date().toISOString()}], {onConflict:'meetup_id'});
    if (error) { console.error('saveMeetupPlaceDetail', error); return { error }; }
    return { ok:true };
  } catch(e) { return { error:e }; }
}
async function fetchMeetupPlaceDetail(meetupId) {
  const sb = getSupabaseClient();
  const mid = serverMeetupId(meetupId);
  if (!sb || !mid) return null;
  try {
    const { data, error } = await sb.from('meetup_place_details').select('detail').eq('meetup_id', mid).maybeSingle();
    if (error) { console.error('fetchMeetupPlaceDetail', error); return null; }
    return (data && data.detail) || null;
  } catch(e) { return null; }
}
// 모임 수정 서버 반영(15단계) — RLS상 주최자 본인·운영진만 성공. 수정 요약(edit_note)을 남기고
// 확정 참가자 전원에게 재확인 플래그(needs_ack)를 세움(17단계 알림에서 이 플래그로 통지 예정)
async function updateRemoteMeetup(meetupId, payload, editNote) {
  const sb = getSupabaseClient();
  const mid = serverMeetupId(meetupId);
  if (!sb || !mid) return { skipped:true };
  try {
    const { data, error } = await sb.from('meetups').update({...payload, edited_at:new Date().toISOString(), edit_note:editNote||null}).eq('id', mid).select('id');
    if (error) { console.error('updateRemoteMeetup', error); return { error }; }
    // 0건 수정 = 서버에 그 모임이 없음(삭제됨) — 성공으로 착각하면 안 됨(TEETEST 사건)
    if (!data || data.length===0) return { error:{ message:'MM_MEETUP_GONE' } };
    const { error: ackErr } = await sb.from('participants').update({needs_ack:true, updated_at:new Date().toISOString()}).eq('meetup_id', mid).eq('status','confirmed');
    if (ackErr) console.error('updateRemoteMeetup needs_ack', ackErr);
    return { ok:true };
  } catch(e) { return { error:e }; }
}
// 실제 서버에 저장된 모임 삭제 (RLS상 본인 host_email이거나 운영진만 성공함) — 삭제 전에 참가자들이 볼 수 있도록 취소 사유를 먼저 남김
async function deleteRemoteMeetup(id, title, reason, hostEmail, silent) {
  const sb = getSupabaseClient();
  const mid = serverMeetupId(id); // 화면 id(r_uuid)도 받아서 정규화 — 접두어 그대로 보내면 서버가 거부(운영진 삭제 무반응 사건)
  if (!sb || !mid) return { skipped:true };
  try {
    if (!silent) await sb.from('meetup_cancellations').insert([{meetup_id:mid, title:title||'모임', reason:reason||'', host_email:hostEmail||null}]);
    const { error } = await sb.from('meetups').delete().eq('id', mid);
    if (error) return { error };
    return { ok:true };
  } catch(e) { return { error:e }; }
}
// 최근 취소된 모임 내역 조회 — 참가자 쪽에서 "내가 신청한 모임이 취소됐는지" 확인할 때 씀
async function fetchMeetupCancellations() {
  const sb = getSupabaseClient();
  if (!sb) return [];
  try {
    const { data, error } = await sb.from('meetup_cancellations').select('*').order('canceled_at', {ascending:false}).limit(50);
    if (error) return [];
    return data||[];
  } catch(e) { return []; }
}
// 로그인한 이메일이 운영진 목록에 있는지로 관리자 여부를 매번 계산 — 별도 플래그를 저장/동기화할 필요 없이 항상 정확함
function isAdminUser(S) {
  try {
    const cfg = (typeof window!=='undefined' && window.__APP_CONFIG__) || {};
    const list = (cfg.ADMIN_EMAILS||'').split(',').map(s=>s.trim().toLowerCase()).filter(Boolean);
    return S.authStatus==='authenticated' && list.includes((S.acctEmail||'').trim().toLowerCase());
  } catch(e) { return false; }
}
// 게시물 CRUD (이야기/Q&A/모임후기/공지사항 공용) — category로 구분해서 저장·조회
async function fetchPosts(category) {
  const sb = getSupabaseClient(); if (!sb) return [];
  try { const { data, error } = await sb.from('posts').select('*').eq('category', category).order('created_at', {ascending:false}); if (error) { console.error('fetchPosts', error); return []; } return data||[]; }
  catch(e) { console.error('fetchPosts', e); return []; }
}
async function createPost(payload) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { data, error } = await sb.from('posts').insert([payload]).select().single(); if (error) { console.error('createPost', error); return { error }; } return { data }; }
  catch(e) { return { error:e }; }
}
async function deletePost(id) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { error } = await sb.from('posts').delete().eq('id', id); if (error) return { error }; return { ok:true }; }
  catch(e) { return { error:e }; }
}
async function fetchComments(postId) {
  const sb = getSupabaseClient(); if (!sb) return [];
  try { const { data, error } = await sb.from('comments').select('*').eq('post_id', postId).order('created_at', {ascending:true}); if (error) { console.error('fetchComments', error); return []; } return data||[]; }
  catch(e) { console.error('fetchComments', e); return []; }
}
async function createComment(payload) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { data, error } = await sb.from('comments').insert([payload]).select().single(); if (error) { console.error('createComment', error); return { error }; } return { data }; }
  catch(e) { return { error:e }; }
}
async function deleteComment(id) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { error } = await sb.from('comments').delete().eq('id', id); if (error) return { error }; return { ok:true }; }
  catch(e) { return { error:e }; }
}
// ── 참가 신청 서버화(12단계) — 서버 participants 테이블이 기준, S.joined는 화면용 미러 ──
// 서버에 실제로 저장된 모임(uuid id)만 서버 호출. 데모·로컬 전용 id(my1, m1 등)는 기존 로컬 동작 유지.
// ⚠️ 화면에서 서버 모임 id는 'r_<uuid>' 접두어 형태(mapRemoteMeetup)로 다닌다.
// 서버 호출 전에 반드시 serverMeetupId()로 접두어를 벗겨 순수 uuid로 바꿔야 함 — 이걸 빼먹으면
// 모든 서버 호출이 조용히 skip되는 버그가 남(2026-07-29 실제 발생).
const MM_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function serverMeetupId(id){ const s = String(id||''); const raw = s.startsWith('r_') ? s.slice(2) : s; return MM_UUID_RE.test(raw) ? raw : null; }
function isServerMeetupId(id){ return !!serverMeetupId(id); }
async function applyToMeetupServer(meetupId, email, name, status) {
  const sb = getSupabaseClient();
  const mid = serverMeetupId(meetupId);
  if (!sb || !mid || !email) return { skipped:true };
  try {
    // upsert: 취소했다가 다시 신청하는 경우 기존 행의 상태를 되살림(모임당 1인 1행 유지)
    // 서버가 정원 초과 시 자동으로 waitlisted로 바꿀 수 있으므로 최종 상태를 돌려받는다(14단계)
    const { data, error } = await sb.from('participants').upsert([{meetup_id:mid, user_email:email, user_name:name||null, status, updated_at:new Date().toISOString()}], {onConflict:'meetup_id,user_email'}).select('status').single();
    if (error) { console.error('applyToMeetupServer', error); return { error }; }
    return { ok:true, status:(data&&data.status)||status };
  } catch(e) { return { error:e }; }
}
async function cancelParticipationServer(meetupId, email) {
  const sb = getSupabaseClient();
  const mid = serverMeetupId(meetupId);
  if (!sb || !mid || !email) return { skipped:true };
  try {
    const { error } = await sb.from('participants').update({status:'canceled', updated_at:new Date().toISOString()}).eq('meetup_id', mid).eq('user_email', email);
    if (error) { console.error('cancelParticipationServer', error); return { error }; }
    return { ok:true };
  } catch(e) { return { error:e }; }
}
// 참석 체크 서버 저장(21단계) — 완료 시 확정 참가자를 attended/noshow로 기록(트리거상 호스트·운영진만 가능)
async function saveAttendance(meetupId, attendedEmails, noshowEmails) {
  const sb = getSupabaseClient(); const mid = serverMeetupId(meetupId);
  if (!sb || !mid) return { skipped:true };
  try {
    if (attendedEmails && attendedEmails.length) {
      const { error } = await sb.from('participants').update({status:'attended', updated_at:new Date().toISOString()}).eq('meetup_id', mid).in('user_email', attendedEmails).eq('status','confirmed');
      if (error) { console.error('saveAttendance attended', error); return { error }; }
    }
    if (noshowEmails && noshowEmails.length) {
      const { error } = await sb.from('participants').update({status:'noshow', updated_at:new Date().toISOString()}).eq('meetup_id', mid).in('user_email', noshowEmails).eq('status','confirmed');
      if (error) { console.error('saveAttendance noshow', error); return { error }; }
    }
    try { await sb.from('meetups').update({attendance_state:'done'}).eq('id', mid); } catch(e) {}
    return { ok:true };
  } catch(e) { return { error:e }; }
}
// 호스트용(13단계): 내 모임의 신청자 명단 서버 조회 — RLS가 주최자·운영진·확정자에게만 허용
async function fetchMeetupParticipants(meetupId) {
  const sb = getSupabaseClient();
  const mid = serverMeetupId(meetupId);
  if (!sb || !mid) return null;
  try {
    const { data, error } = await sb.from('participants').select('user_email,user_name,status,created_at').eq('meetup_id', mid).order('created_at', {ascending:true});
    if (error) { console.error('fetchMeetupParticipants', error); return null; }
    return data||[];
  } catch(e) { console.error('fetchMeetupParticipants', e); return null; }
}
// 호스트용(13단계): 신청자 상태 변경(승인 confirmed/거절 rejected/대기 waitlisted) — RLS가 주최자·운영진에게만 허용
async function updateParticipantStatus(meetupId, userEmail, status) {
  const sb = getSupabaseClient();
  const mid = serverMeetupId(meetupId);
  if (!sb || !mid || !userEmail) return { skipped:true };
  try {
    const { error } = await sb.from('participants').update({status, updated_at:new Date().toISOString()}).eq('meetup_id', mid).eq('user_email', userEmail);
    if (error) { console.error('updateParticipantStatus', error); return { error }; }
    return { ok:true };
  } catch(e) { return { error:e }; }
}
// 모임별 확정 인원 수 조회 — 명단은 비공개(RLS 유지), 숫자만 공개 함수(mm_meetup_counts)로 받아옴
async function fetchMeetupCounts(ids) {
  const sb = getSupabaseClient();
  // 화면 id('r_uuid') → 서버 uuid로 벗겨서 조회하고, 결과는 호출자가 준 원래 id로 되돌려 키를 맞춘다
  const backMap = {}; (ids||[]).forEach(id=>{ const mid = serverMeetupId(id); if (mid) backMap[mid] = id; });
  const list = Object.keys(backMap);
  if (!sb || !list.length) return {};
  try {
    const { data, error } = await sb.rpc('mm_meetup_counts', {mids:list});
    if (error) { console.error('fetchMeetupCounts', error); return {}; }
    const map = {}; list.forEach(mid=>{ map[backMap[mid]] = 0; });
    (data||[]).forEach(r=>{ map[backMap[r.meetup_id]] = Number(r.confirmed_count)||0; });
    return map;
  } catch(e) { console.error('fetchMeetupCounts', e); return {}; }
}
// 로그인 계정의 참가 내역을 서버에서 읽어 S.joined 미러 형태({모임id: 상태})로 반환
async function fetchMyParticipations(email) {
  const sb = getSupabaseClient();
  if (!sb || !email) return null;
  try {
    const { data, error } = await sb.from('participants').select('meetup_id,status').eq('user_email', email);
    if (error) { console.error('fetchMyParticipations', error); return null; }
    const map = {};
    (data||[]).forEach(r=>{
      if (r.status==='canceled'||r.status==='rejected'||r.status==='noshow') return;
      // 화면의 S.joined 키는 'r_<uuid>' 형태이므로 접두어를 붙여 돌려준다
      map['r_'+r.meetup_id] = r.status==='attended' ? 'confirmed' : r.status;
    });
    return map;
  } catch(e) { console.error('fetchMyParticipations', e); return null; }
}
// 신고 접수 — 연동 안 됐으면 조용히 스킵(기존 데모 토스트만 뜨는 동작 유지)
async function createReport(payload) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  try { const { data, error } = await sb.from('reports').insert([payload]).select().single(); if (error) { console.error('createReport', error); return { error }; } return { data }; }
  catch(e) { return { error:e }; }
}
// 운영진 문의 접수
async function createInquiry(payload) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  try { const { data, error } = await sb.from('inquiries').insert([payload]).select().single(); if (error) { console.error('createInquiry', error); return { error }; } return { data }; }
  catch(e) { return { error:e }; }
}
// 내 신고 내역만 조회 — 전체 다운로드 후 걸러내던 방식 대신 서버에서 본인 것만 가져옴(11단계 RLS와 한 쌍)
async function fetchMyReports(email) {
  const sb = getSupabaseClient(); if (!sb || !email) return [];
  try { const { data, error } = await sb.from('reports').select('*').eq('reporter_email', email).order('created_at', {ascending:false}); if (error) { console.error('fetchMyReports', error); return []; } return data||[]; }
  catch(e) { console.error('fetchMyReports', e); return []; }
}
// 관리자 페이지 전용 — 신고/문의 전체 조회 및 상태 변경
async function fetchReports() {
  const sb = getSupabaseClient(); if (!sb) return [];
  try { const { data, error } = await sb.from('reports').select('*').order('created_at', {ascending:false}); if (error) { console.error('fetchReports', error); return []; } return data||[]; }
  catch(e) { console.error('fetchReports', e); return []; }
}
async function fetchInquiries() {
  const sb = getSupabaseClient(); if (!sb) return [];
  try { const { data, error } = await sb.from('inquiries').select('*').order('created_at', {ascending:false}); if (error) { console.error('fetchInquiries', error); return []; } return data||[]; }
  catch(e) { console.error('fetchInquiries', e); return []; }
}
async function updateReportStatus(id, status) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { error } = await sb.from('reports').update({status}).eq('id', id); if (error) return { error }; return { ok:true }; }
  catch(e) { return { error:e }; }
}
async function updateInquiryStatus(id, status) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { error } = await sb.from('inquiries').update({status}).eq('id', id); if (error) return { error }; return { ok:true }; }
  catch(e) { return { error:e }; }
}
// ── 이벤트(운영진 특별 게시판) 서버 연동 — 로드맵 외 추가 작업(2026-07-29). sql/extra_events.sql로 만든 events/event_applications 테이블 사용 ──
function mapEventRow(row, appliedCount) {
  return {
    id: row.id, title: row.title, tag: row.tag||'', tone: row.tone||'primary',
    when: row.when_label||'일정 미정', city: row.city||'', cost: row.cost_text||'',
    host: row.host_label||'모두모여 운영진', sponsor: row.sponsor||null, sponsorItem: row.sponsor_item||null,
    desc: row.description||'', mode: row.mode||'firstcome', cap: row.cap||10, applied: appliedCount||0,
  };
}
async function fetchEvents() {
  const sb = getSupabaseClient(); if (!sb) return [];
  try {
    const { data: rows, error } = await sb.from('events').select('*').eq('is_active', true).order('created_at', {ascending:true});
    if (error) { console.error('fetchEvents', error); return []; }
    const { data: apps } = await sb.from('event_applications').select('event_id').in('status', ['pending','confirmed','waitlisted']);
    const counts = {};
    (apps||[]).forEach(a=>{ counts[a.event_id] = (counts[a.event_id]||0)+1; });
    return (rows||[]).map(row=>mapEventRow(row, counts[row.id]||0));
  } catch(e) { console.error('fetchEvents', e); return []; }
}
// 실시간 이벤트 목록 훅 — WScrEvents/WScrEventsList/WScrEventDetail/WScrMyMeetUpcoming 공용
function useEvents() {
  const {useState, useEffect} = React;
  const [events, setEvents] = useState([]);
  useEffect(()=>{
    let alive=true;
    const load = () => fetchEvents().then(list=>{ if(alive) setEvents(list); });
    load();
    document.addEventListener('mm:eventschanged', load);
    return ()=>{ alive=false; document.removeEventListener('mm:eventschanged', load); };
  }, []);
  return events;
}
// 로그인한 이메일의 이벤트 신청 현황을 {event_id: status} 형태로 반환 — 로그인 시 S.eventApplied를 채우는 데 사용
async function fetchMyEventApplications(email) {
  const sb = getSupabaseClient(); if (!sb || !email) return {};
  try {
    const { data, error } = await sb.from('event_applications').select('event_id, status').eq('applicant_email', email);
    if (error) return {};
    const map = {}; (data||[]).forEach(r=>{ map[r.event_id] = r.status; });
    return map;
  } catch(e) { return {}; }
}
async function applyToEvent(eventId, payload) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { data, error } = await sb.from('event_applications').insert([{ event_id:eventId, ...payload }]).select().single(); if (error) return { error }; return { data }; }
  catch(e) { return { error:e }; }
}
async function cancelEventApplication(eventId, email) {
  const sb = getSupabaseClient(); if (!sb || !email) return { skipped:true };
  try { const { error } = await sb.from('event_applications').delete().eq('event_id', eventId).eq('applicant_email', email); if (error) return { error }; return { ok:true }; }
  catch(e) { return { error:e }; }
}
function loadKakaoMapSdk(key) {
  if (window.kakao && window.kakao.maps) return Promise.resolve(window.kakao);
  if (__kakaoSdkPromise) return __kakaoSdkPromise;
  __kakaoSdkPromise = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${key}&autoload=false&libraries=services`;
    s.onload = () => { window.kakao.maps.load(() => resolve(window.kakao)); };
    s.onerror = () => { __kakaoSdkPromise = null; reject(new Error('kakao sdk load failed')); };
    document.head.appendChild(s);
  });
  return __kakaoSdkPromise;
}
// 카카오 장소 검색(30단계) — 키워드 → 장소 선택 → {name, lat, lng, address} 반환. 키 없으면 사용 불가(호출측에서 버튼 숨김)
function PlaceSearchModal({initial, onPick, onClose}) {
  const {useState, useRef, useEffect} = React;
  const [q, setQ] = useState(initial||'');
  const [rows, setRows] = useState(null);
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);
  const inputRef = useRef(null);
  useEffect(()=>{ inputRef.current && inputRef.current.focus(); }, []);
  const search = () => {
    const kw = q.trim(); if (!kw) return;
    const key = getKakaoMapKey(); if (!key) { setErr('지도 키가 아직 설정되지 않았어요.'); return; }
    setBusy(true); setErr('');
    loadKakaoMapSdk(key).then(kakao=>{
      const ps = new kakao.maps.services.Places();
      ps.keywordSearch(kw, (data, status)=>{
        setBusy(false);
        if (status === kakao.maps.services.Status.OK) setRows(data.slice(0,8));
        else if (status === kakao.maps.services.Status.ZERO_RESULT) { setRows([]); }
        else setErr('검색에 실패했어요 — 잠시 후 다시 시도해주세요.');
      });
    }).catch(()=>{ setBusy(false); setErr('지도를 불러오지 못했어요 — 도메인 등록을 확인해주세요.'); });
  };
  return <div className="overlay" onClick={onClose}>
    <div className="sheet" onClick={e=>e.stopPropagation()} style={{maxWidth:440}}>
      <div className="row" style={{justifyContent:'space-between', marginBottom:10}}>
        <b style={{fontSize:15.5}}>카카오맵 장소 검색</b>
        <button style={{background:'none', border:'none', cursor:'pointer', padding:4}} onClick={onClose}><Ic n="IconNormalCloseNameCloseThickFalse" s={17} c="var(--c-faint)"/></button>
      </div>
      <div className="row" style={{gap:8, marginBottom:10}}>
        <input ref={inputRef} value={q} onChange={e=>setQ(e.target.value)} onKeyDown={e=>{ if(e.key==='Enter') search(); }} placeholder="가게명·건물명·주소 검색" style={{flex:1, minWidth:0, border:'1px solid var(--c-line)', borderRadius:8, padding:'10px 12px', fontFamily:'inherit', fontSize:14, outline:'none'}}/>
        <DSBtn label={busy?'검색 중…':'검색'} size="md" fit disable={busy} onClick={search}/>
      </div>
      {err&&<div role="alert" className="notice orange" style={{marginBottom:10}}><Ic n="IconNormalCircleInfo2NameCircleInfoFillFalse" s={15}/><span>{err}</span></div>}
      <div style={{maxHeight:300, overflowY:'auto'}}>
        {rows==null&&!err&&<p className="meta" style={{textAlign:'center', padding:'14px 0'}}>모임 장소를 검색해보세요 — 선택하면 지도에 정확한 핀이 표시돼요.</p>}
        {rows&&rows.length===0&&<p className="meta" style={{textAlign:'center', padding:'14px 0'}}>검색 결과가 없어요 — 다른 키워드로 시도해보세요.</p>}
        {rows&&rows.map(r=><button key={r.id} className="card" style={{width:'100%', textAlign:'left', marginBottom:8, padding:'11px 14px', cursor:'pointer', fontFamily:'inherit'}} onClick={()=>onPick({name:r.place_name, lat:parseFloat(r.y), lng:parseFloat(r.x), address:r.road_address_name||r.address_name||''})}>
          <b style={{fontSize:13.5}}>{r.place_name}</b>
          <span className="meta" style={{display:'block', marginTop:2}}>{r.road_address_name||r.address_name}</span>
        </button>)}
      </div>
    </div>
  </div>;
}
function MapEmbed({lat, lng, height=110, label}) {
  const {useRef, useEffect, useState} = React;
  const mapRef = useRef(null);
  const [state, setState] = useState('nokey');
  const [retryN, setRetryN] = useState(0);
  const key = getKakaoMapKey();
  useEffect(() => {
    if (!key || !lat || !lng) { setState('nokey'); return; }
    let cancelled = false;
    setState('loading');
    // 로딩이 8초 안에 못 끝나면 실패로 전환(무한 스피너 방지) — 재시도 시 SDK 캐시도 초기화
    const timeout = new Promise((_, rej) => setTimeout(()=>rej(new Error('MM_MAP_TIMEOUT')), 8000));
    Promise.race([loadKakaoMapSdk(key), timeout]).then(kakao => {
      if (cancelled || !mapRef.current) return;
      const center = new kakao.maps.LatLng(lat, lng);
      const map = new kakao.maps.Map(mapRef.current, {center, level:3});
      map.setZoomable(true);
      new kakao.maps.Marker({position:center, map});
      requestAnimationFrame(()=>{ try { map.relayout(); map.setCenter(center); } catch(e) {} });
      const onResize = () => map.relayout();
      window.addEventListener('resize', onResize);
      setState('ready');
    }).catch(() => { if (!cancelled) { __kakaoSdkPromise = null; setState('error'); } });
    return () => { cancelled = true; };
  }, [key, lat, lng, retryN]);
  if (state==='nokey') return <div style={{position:'relative', borderRadius:12, overflow:'hidden', height, background:'linear-gradient(135deg,var(--c-primary-bg),var(--c-fill-a))', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:6, textAlign:'center', padding:'0 16px'}}>
    <Ic n="IconNormalLocation2NameLocationFillFillTrue" s={26} c="var(--c-primary)"/>
    <span style={{fontSize:12.5, fontWeight:700, color:'var(--c-primary)'}}>{label||'카카오맵은 modumoyeo.com 정식 배포 후 표시돼요.'}</span>
    <span style={{fontSize:11, color:'var(--c-faint)'}}>정확한 위치는 참가 확정 후 공개돼요</span>
  </div>;
  if (state==='error') return <div style={{borderRadius:12, height, background:'var(--c-fill-a)', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:8, fontSize:12.5, color:'var(--c-sub)'}}><span className="row" style={{gap:6}}><Ic n="IconNormalCircleExclamation2NameCircleExclamationFillFalse" s={16} c="var(--c-orange)"/>지도를 불러오지 못했어요</span><button style={{background:'none', border:'1px solid var(--c-line)', borderRadius:8, padding:'6px 14px', fontFamily:'inherit', fontSize:12.5, fontWeight:700, cursor:'pointer', color:'var(--c-primary)'}} onClick={()=>setRetryN(n=>n+1)}>다시 시도</button></div>;
  return <div style={{position:'relative', borderRadius:12, overflow:'hidden', height}}>
    <div ref={mapRef} style={{width:'100%', height:'100%'}}></div>
    {state==='loading'&&<div style={{position:'absolute', inset:0, display:'flex', alignItems:'center', justifyContent:'center', background:'var(--c-fill-a)'}}><span className="spinner"></span></div>}
  </div>;
}
const PROFILES_SEED = [
  {region:'kr', name:'지은', school:"UCLA '19", loc:'현재: 서울 · 이전: 런던(8개월)', interest:'주말 러닝과 전시 관람 관심', topic:'답변 가능 주제 — 미국 대학 편입', meets:'모임 4회 참여', meetCount:4, badges:['학교 인증'], common:['같은 관심사 2개','같은 지역']},
  {region:'gb', name:'민수', school:'런던 워홀 중', loc:'GB 런던 · 9월 서울 귀국 예정', interest:'카페 투어와 사진 촬영 관심', topic:'답변 가능 주제 — 영국 집 구하기', meets:'모임 2회 참여', meetCount:2, badges:['이메일 인증'], common:['같은 귀국 예정 시기']},
  {region:'ca', name:'하나', school:'토론토 교환학생', loc:'CA 토론토 · 12월 귀국 예정', interest:'요가와 보드게임 모임 관심', topic:'답변 가능 주제 — 캐나다 워홀 비자', meets:'모임 1회 참여', meetCount:1, badges:['학교 인증'], common:['같은 관심사 1개']},
  {region:'kr', name:'태호', school:'서울 귀국 1년 차', loc:'서울 마포 거주 · 호주 유학 출신', interest:'등산과 사진 모임 관심', topic:'답변 가능 주제 — 귀국 후 취업준비', meets:'모임 5회 참여', meetCount:5, badges:['동문 인증'], common:['같은 지역','귀국 선배']},
  {region:'gb', name:'유진', school:'런던 어학연수 중', loc:'GB 런던 · 내년 1월 서울 귀국 예정', interest:'뮤지컬과 펍 투어 관심', topic:'답변 가능 주제 — 런던 어학원 추천', meets:'모임 1회 참여', meetCount:1, badges:['이메일 인증'], common:['같은 귀국 예정 지역']},
  {region:'ca', name:'수빈', school:'밴쿠버 워홀러', loc:'CA 밴쿠버 · 체류 기간 미정', interest:'등산과 캠핑 관심', topic:'답변 가능 주제 — 워홀 구직 팁', meets:'모임 3회 참여', meetCount:3, badges:['학교 인증'], common:['같은 관심사 1개','같은 국가']},
];
const PROFILES = DEMO_MODE ? PROFILES_SEED : [];
const NOTIFICATIONS_SEED = [
  {name:'닉네임A', text:'메시지를 보냈어요 — "저 첫 참여인데 8시에 가면 될까요?"', time:'방금 전', icon:'IconNormalChatNameChat', action:{screen:'chat', detailId:'m1'}},
  {name:'닉네임B', text:'강남 밤모임에 참가했어요', time:'10분 전', icon:'IconNormalPersonPlus2NamePersonPlusFillFillTrue', action:{screen:'attendees', detailId:'m2'}},
  {name:'윤영진', text:'유학생 귀국 환영 웰컴파티 채팅방에 메시지를 보냈어요', time:'1시간 전', icon:'IconNormalChatNameChat', action:{screen:'chat', detailId:'m1'}},
  {name:'닉네임D', text:'"지금 영국에서 워홀 중인데, 저 같은 사람 여기 있나요? GB" 글에 댓글을 남겼어요', time:'1시간 전', icon:'IconNormalBubbleNameBubbleFillFalse', action:{screen:'postdetail', post:{cat:1, tag:'해외거주 Q&A', title:'지금 영국에서 워홀 중인데, 저 같은 사람 여기 있나요? GB', body:'런던 온 지 3개월 됐는데 여기서도 한인 유학생·워홀러 분들과 연결돼서 지내고 싶어요.', meta:'닉네임D · 런던'}}},
  {name:'시스템', text:'내일 모임 참석 재확인이 도착했어요', time:'2시간 전', icon:'IconNormalBell2NameBellFillFillTrue', action:{screen:'mymeet'}},
];
const NOTIFICATIONS = DEMO_MODE ? NOTIFICATIONS_SEED : [];
// 사용자 간 차단(24단계) — 서버 저장, 양방향으로 서로 안 보이게. window.__mmBlockSet에 이메일 소문자 캐시
window.__mmBlockSet = window.__mmBlockSet || new Set();
async function loadMyBlocks(email) {
  const sb = getSupabaseClient(); if (!sb || !email) { window.__mmBlockSet = new Set(); return; }
  const em = String(email).toLowerCase();
  try {
    const { data } = await sb.from('user_blocks').select('blocker_email,blocked_email');
    const set = new Set();
    (data||[]).forEach(r=>{ const a=String(r.blocker_email).toLowerCase(), b=String(r.blocked_email).toLowerCase(); if (a===em) set.add(b); if (b===em) set.add(a); });
    window.__mmBlockSet = set;
  } catch(e) { window.__mmBlockSet = new Set(); }
}
function isBlockedEmail(email) { return !!email && window.__mmBlockSet.has(String(email).toLowerCase()); }
async function blockUser(myEmail, targetEmail) {
  const sb = getSupabaseClient(); if (!sb || !myEmail || !targetEmail) return { skipped:true };
  const me=String(myEmail).toLowerCase(), tg=String(targetEmail).toLowerCase();
  if (me===tg) return { self:true };
  try {
    const { error } = await sb.from('user_blocks').upsert([{blocker_email:me, blocked_email:tg}], {onConflict:'blocker_email,blocked_email'});
    if (error) { console.error('blockUser', error); return { error }; }
    window.__mmBlockSet.add(tg);
    try { document.dispatchEvent(new CustomEvent('mm:blockschanged')); } catch(e) {}
    return { ok:true };
  } catch(e) { return { error:e }; }
}
async function unblockUser(myEmail, targetEmail) {
  const sb = getSupabaseClient(); if (!sb || !myEmail || !targetEmail) return { skipped:true };
  const me=String(myEmail).toLowerCase(), tg=String(targetEmail).toLowerCase();
  try {
    const { error } = await sb.from('user_blocks').delete().eq('blocker_email', me).eq('blocked_email', tg);
    if (error) return { error };
    // 반대 방향(상대가 나를 차단)까지 풀 순 없으므로, 로컬 셋은 재조회로 정확화
    await loadMyBlocks(myEmail);
    try { document.dispatchEvent(new CustomEvent('mm:blockschanged')); } catch(e) {}
    return { ok:true };
  } catch(e) { return { error:e }; }
}
// 참가 전 주최자 문의(2026-07-30 사용자 설계) — 모임당 1:1 스레드, 당사자만 열람, 종료 시 사라짐
async function getOrCreateInquiryThread(meetupId, askerEmail, askerName) {
  const sb = getSupabaseClient(); const mid = serverMeetupId(meetupId);
  if (!sb || !mid || !askerEmail) return { skipped:true };
  try {
    const { data: ex } = await sb.from('meetup_inquiry_threads').select('*').eq('meetup_id', mid).eq('asker_email', String(askerEmail).toLowerCase()).maybeSingle();
    if (ex) return { data: ex }; // 닫힌 스레드도 그대로 반환 — 모달이 '종료된 문의' 읽기 전용으로 보여주고, 원할 때만 명시적으로 재시작
    const { data, error } = await sb.from('meetup_inquiry_threads').insert([{meetup_id:mid, asker_email:String(askerEmail).toLowerCase(), asker_name:askerName||null, host_email:'x'}]).select().single();
    if (error) { if (String(error.message||'').includes('MM_SELF_INQUIRY')) return { self:true }; console.error('inquiry thread', error); return { error }; }
    return { data };
  } catch(e) { return { error:e }; }
}
async function fetchInquiryThreads(meetupId) {
  const sb = getSupabaseClient(); const mid = serverMeetupId(meetupId);
  if (!sb || !mid) return [];
  try { const { data } = await sb.from('meetup_inquiry_threads').select('*').eq('meetup_id', mid).eq('status','open').order('created_at'); return data||[]; } catch(e) { return []; }
}
async function fetchInquiryMessages(threadId) {
  const sb = getSupabaseClient(); if (!sb || !threadId) return null;
  try { const { data } = await sb.from('meetup_inquiry_messages').select('*').eq('thread_id', threadId).order('created_at').limit(100); return data||[]; } catch(e) { return null; }
}
async function sendInquiryMessage(threadId, email, body) {
  const sb = getSupabaseClient(); if (!sb || !threadId || !email || !body) return { skipped:true };
  try { const { data, error } = await sb.from('meetup_inquiry_messages').insert([{thread_id:threadId, sender_email:String(email).toLowerCase(), body}]).select().single(); if (error) return { error }; return { data }; }
  catch(e) { return { error:e }; }
}
async function reopenInquiryThread(threadId) {
  const sb = getSupabaseClient(); if (!sb || !threadId) return { skipped:true };
  try { const { error } = await sb.from('meetup_inquiry_threads').update({status:'open'}).eq('id', threadId); if (error) return { error }; return { ok:true }; }
  catch(e) { return { error:e }; }
}
async function closeInquiryThread(threadId) {
  const sb = getSupabaseClient(); if (!sb || !threadId) return { skipped:true };
  try { const { error } = await sb.from('meetup_inquiry_threads').update({status:'closed'}).eq('id', threadId); if (error) return { error }; return { ok:true }; }
  catch(e) { return { error:e }; }
}
// 문의 미니 채팅 모달 — S.inquiryOpen = {meetupId, title, hostEmail, mode:'asker'|'hostlist'}
function MeetupInquiryModal({S, set}) {
  const t = S.inquiryOpen;
  const me = (S.acctEmail||'').toLowerCase();
  const [thread, setThread] = React.useState(null);
  const [threads, setThreads] = React.useState(null);
  const [msgs, setMsgs] = React.useState([]);
  const [input, setInput] = React.useState('');
  const [err, setErr] = React.useState('');
  const [closedNotice, setClosedNotice] = React.useState(false);
  const listRef = React.useRef(null);
  const isHostList = t && t.mode==='hostlist' && !thread;
  React.useEffect(()=>{
    if (!t) return;
    setErr(''); setClosedNotice(false); setThread(null); setThreads(null); setMsgs([]);
    if (t.mode==='hostlist') { fetchInquiryThreads(t.meetupId).then(setThreads); return; }
    getOrCreateInquiryThread(t.meetupId, S.acctEmail, S.profile.nickname||'나').then(r=>{
      if (r && r.self) { setErr('내가 주최하는 모임에는 문의를 보낼 수 없어요.'); return; }
      if (r && (r.error||r.skipped)) { setErr('문의 창을 여는 데 실패했어요 — 잠시 후 다시 시도해주세요.'); return; }
      setThread(r.data);
    });
  }, [t && t.meetupId, t && t.mode]);
  React.useEffect(()=>{
    if (!thread) return;
    let alive = true;
    const load = () => fetchInquiryMessages(thread.id).then(rows=>{ if (alive && rows) setMsgs(rows); });
    load();
    const iv = setInterval(load, 6000);
    return ()=>{ alive=false; clearInterval(iv); };
  }, [thread && thread.id]);
  React.useEffect(()=>{ const el=listRef.current; if (el) requestAnimationFrame(()=>{ el.scrollTop = el.scrollHeight; }); }, [msgs.length]);
  if (!t) return null;
  const close = () => set({inquiryOpen:null});
  const send = () => {
    const v = input.trim(); if (!v || !thread) return;
    setInput('');
    sendInquiryMessage(thread.id, S.acctEmail, v).then(r=>{
      if (r && r.error) { setErr('전송에 실패했어요 — 문의가 종료됐을 수 있어요.'); setInput(v); return; }
      if (r && r.data) setMsgs(prev=>[...prev, r.data]);
    });
  };
  const finish = () => {
    if (!thread) { close(); return; }
    closeInquiryThread(thread.id).then(()=>{ setClosedNotice(true); setTimeout(close, 1500); });
  };
  return <div className="overlay" onClick={close}>
    <div className="sheet" onClick={e=>e.stopPropagation()} style={{maxWidth:440, display:'flex', flexDirection:'column'}}>
      <div className="row" style={{justifyContent:'space-between', marginBottom:10}}>
        <b style={{fontSize:15.5}}>{isHostList?'받은 문의':'주최자에게 문의'} · {t.title||'모임'}</b>
        <button style={{background:'none', border:'none', cursor:'pointer', padding:4}} onClick={close}><Ic n="IconNormalCloseNameCloseThickFalse" s={17} c="var(--c-faint)"/></button>
      </div>
      {isHostList ? <div>
        {threads==null?<p className="meta">불러오는 중…</p>
        :threads.length===0?<p className="meta" style={{padding:'12px 0', lineHeight:1.6}}>아직 열려있는 문의가 없어요. 문의가 오면 알림으로 알려드려요.</p>
        :threads.map(th=><button key={th.id} className="card row" style={{width:'100%', gap:10, marginBottom:8, cursor:'pointer', fontFamily:'inherit', textAlign:'left'}} onClick={()=>setThread(th)}>
          <Av name={th.asker_name||th.asker_email} size={32}/>
          <span style={{flex:1, fontSize:13.5}}><b>{th.asker_name||th.asker_email}</b><br/><span className="meta">{timeAgo(Date.parse(th.created_at))} 문의 시작</span></span>
          <Ic n="IconNormalChevronRightNameChevronRightTightFalseThick" s={13} c="var(--c-faint)"/>
        </button>)}
      </div>
      : closedNotice ? <div style={{textAlign:'center', padding:'18px 0'}}><Ic n="IconNormalCircleCheck2NameCircleCheckFillFillTrue" s={36} c="var(--c-green)"/><p style={{fontSize:14, fontWeight:700, margin:'10px 0 2px'}}>문의가 종료됐어요</p><p className="meta" style={{margin:0}}>대화창이 정리돼요. 이용해주셔서 감사해요.</p></div>
      : <>
        <p className="meta" style={{margin:'0 0 10px', lineHeight:1.5}}>모임 내용·시간·장소 같은 궁금한 점을 자유롭게 물어보세요. 이 대화는 주최자와 나만 볼 수 있어요.</p>
        {err&&<div role="alert" className="notice orange" style={{marginBottom:10}}><Ic n="IconNormalCircleInfo2NameCircleInfoFillFalse" s={15}/><span>{err}</span></div>}
        <div ref={listRef} style={{height:240, overflowY:'auto', background:'var(--c-fill-a)', borderRadius:12, padding:'12px'}}>
          {!thread&&!err&&<p className="meta" style={{textAlign:'center'}}>문의 창 여는 중…</p>}
          {thread&&msgs.length===0&&<p className="meta" style={{textAlign:'center', padding:'16px 0'}}>아직 대화가 없어요 — 첫 질문을 남겨보세요.</p>}
          {msgs.map(m2=>{ const mine=(m2.sender_email||'').toLowerCase()===me; return <div key={m2.id} style={{display:'flex', justifyContent:mine?'flex-end':'flex-start', marginBottom:8}}>
            <div style={{maxWidth:'78%', background:mine?'var(--c-primary)':'#fff', color:mine?'#fff':'var(--c-text)', borderRadius:12, padding:'8px 12px', fontSize:13.5, lineHeight:1.5, whiteSpace:'pre-wrap', wordBreak:'break-word', border:mine?'none':'1px solid var(--c-line)'}}>{m2.body}</div>
          </div>; })}
        </div>
        {thread&&thread.status==='closed'
        ? <div style={{marginTop:10}}>
            <div className="notice" style={{marginBottom:10}}><Ic n="IconNormalCircleInfo2NameCircleInfoFillFalse" s={15}/><span>종료된 문의예요 — 대화 내용은 보관돼 있어요. 더 궁금한 점이 있으면 다시 시작할 수 있어요.</span></div>
            <DSBtn label="새 문의 시작하기" size="md" onClick={()=>{ reopenInquiryThread(thread.id).then(r=>{ if (r&&r.error) { setErr('다시 시작하지 못했어요 — 잠시 후 시도해주세요.'); return; } setThread({...thread, status:'open'}); }); }}/>
          </div>
        : <>
        <div className="row" style={{gap:8, marginTop:10}}>
          <input value={input} onChange={e=>setInput(e.target.value)} onKeyDown={e=>{ if(e.key==='Enter') send(); }} placeholder="메시지 입력" disabled={!thread} style={{flex:1, minWidth:0, background:'var(--c-fill)', border:'none', borderRadius:999, padding:'10px 14px', fontSize:13.5, fontFamily:'inherit', outline:'none'}}/>
          <button className="iconbtn" aria-label="보내기" onClick={send} disabled={!thread}><Ic n="IconNormalSendNameSendFillFillTrue" s={19} c="var(--c-primary)"/></button>
        </div>
        <button style={{background:'none', border:'none', color:'var(--c-faint)', fontFamily:'inherit', fontSize:12, cursor:'pointer', padding:'10px 0 0', textDecoration:'underline'}} onClick={finish}>문의 종료하기</button>
        </>}
      </>}
    </div>
  </div>;
}
// 삭제된 컨텐츠 접근 확인(2026-07-30 사용자 지시) — 목록이 낡아 삭제된 항목을 눌렀을 때 판별용
async function recordExists(table, id) {
  const sb = getSupabaseClient(); if (!sb || !id) return true; // 확인 불가 시 기존 동작 유지
  try { const { data, error } = await sb.from(table).select('id').eq('id', id).maybeSingle(); if (error) return true; return !!data; }
  catch(e) { return true; }
}
// 운영진 도구(2026-07-30 사용자 지시) — 사유와 함께 삭제하고 작성자에게 알림
const ADMIN_REMOVE_REASONS = ['음란·선정성','서비스 목적에 맞지 않음','금전 거래·영업','사기 의심','타인 사칭·개인정보 노출','기타'];
async function adminNotify(email, title, body) {
  const sb = getSupabaseClient(); if (!sb || !email) return;
  try { await sb.from('notifications').insert([{user_email:String(email).toLowerCase(), type:'admin_removed', title, body}]); } catch(e) { console.error('adminNotify', e); }
}
async function adminCreateEvent(payload) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { data, error } = await sb.from('events').insert([payload]).select().single(); if (error) { console.error('adminCreateEvent', error); return { error }; } try { document.dispatchEvent(new CustomEvent('mm:eventschanged')); } catch(e) {} return { data }; }
  catch(e) { return { error:e }; }
}
async function adminDeleteEvent(id) {
  const sb = getSupabaseClient(); if (!sb || !id) return { skipped:true };
  try { const { error } = await sb.from('events').delete().eq('id', id); if (error) return { error }; try { document.dispatchEvent(new CustomEvent('mm:eventschanged')); } catch(e) {} return { ok:true }; }
  catch(e) { return { error:e }; }
}
// 운영진 삭제 확인 시트 — kind: 'meetup' | 'post' | 'comment'. 삭제 후 작성자에게 사유 알림
function AdminRemoveModal({S, set}) {
  const t = S.adminRemove;
  const [reason, setReason] = React.useState(ADMIN_REMOVE_REASONS[0]);
  const [busy, setBusy] = React.useState(false);
  const [errText, setErrText] = React.useState('');
  if (!t) return null;
  const close = () => set({adminRemove:null});
  const label = t.kind==='meetup'?'모임':t.kind==='comment'?'댓글':'게시글';
  const run = async () => {
    if (busy) return; setBusy(true);
    let r = {};
    if (t.kind==='meetup') r = await deleteRemoteMeetup(t.id, t.title, '운영정책 위반('+reason+')', S.acctEmail) || {};
    else if (t.kind==='post') r = await deletePost(t.id) || {};
    else if (t.kind==='comment') r = await deleteComment(t.id) || {};
    setBusy(false);
    if (r && (r.error || r.skipped)) { setErrText('삭제에 실패했어요 — 잠시 후 다시 시도해주세요. 계속 안 되면 새로고침 후 재시도해주세요.'); return; }
    if (t.ownerEmail) adminNotify(t.ownerEmail, label+'이(가) 운영정책 위반으로 삭제됐어요', '"'+(t.title||'')+'" — 사유: '+reason+'. 반복 위반 시 이용이 제한될 수 있어요. 문의: support@modumoyeo.com');
    try { document.dispatchEvent(new CustomEvent('mm:contentremoved', {detail:{kind:t.kind, id:t.id}})); } catch(e) {}
    set({adminRemove:null, toast:label+'을(를) 삭제하고 작성자에게 사유 알림을 보냈어요'});
    if (t.kind==='meetup') set({adminRemove:null, screen:'home', toast:'모임을 삭제하고 주최자에게 사유 알림을 보냈어요'});
  };
  return <div className="overlay" onClick={close}>
    <div className="sheet" onClick={e=>e.stopPropagation()} role="dialog" aria-modal="true">
      <h2 style={{fontSize:17, margin:'0 0 6px'}}>운영진 삭제 — {label}</h2>
      <p className="meta" style={{margin:'0 0 12px', lineHeight:1.6}}>"{t.title||''}"을(를) 삭제합니다. 선택한 사유가 작성자에게 알림으로 전달돼요. 되돌릴 수 없어요.</p>
      <div style={{display:'flex', gap:6, flexWrap:'wrap', marginBottom:16}}>{ADMIN_REMOVE_REASONS.map(r=><Chip key={r} label={r} tone={reason===r?'primary':'line'} onClick={()=>setReason(r)}/>)}</div>
      {errText&&<div role="alert" className="notice orange" style={{marginBottom:12}}><Ic n="IconNormalCircleInfo2NameCircleInfoFillFalse" s={15}/><span>{errText}</span></div>}
      <div style={{display:'flex', gap:8}}>
        <div style={{flex:1}}><DSBtn label="취소" variant="outlined" color="assistive" size="md" onClick={close}/></div>
        <div style={{flex:1}}><DSBtn label={busy?'삭제 중…':'삭제하고 알림 보내기'} size="md" disable={busy} onClick={run}/></div>
      </div>
    </div>
  </div>;
}
// 운영진 공식 배지(2026-07-30 사용자 지시, 사칭 방지) — 닉네임이 아니라 운영진 이메일 목록 기준으로만 표시되므로 위조 불가
function isAdminEmail(email) {
  const list = String((window.__APP_CONFIG__&&window.__APP_CONFIG__.ADMIN_EMAILS)||'').toLowerCase().split(',').map(x=>x.trim()).filter(Boolean);
  return !!email && list.includes(String(email).toLowerCase());
}
function AdminBadge({small}) {
  return <span style={{display:'inline-flex', alignItems:'center', gap:3, background:'var(--c-primary)', color:'#fff', borderRadius:999, fontWeight:800, whiteSpace:'nowrap', fontSize:small?9.5:11, padding:small?'1px 6px':'2px 8px', verticalAlign:'middle'}}><Ic n="IconNormalCircleCheck2NameCircleCheckFillFillTrue" s={small?10:12} c="#fff"/>공식 운영진</span>;
}
// 호스트 페이지 실데이터(20단계) — 프로필(RLS: 회원만)·개설 모임·실제 후기. 익명이면 프로필은 null로 옴(정책대로)
async function fetchHostPublicData(hostEmail) {
  const sb = getSupabaseClient(); if (!sb || !hostEmail) return null;
  const em = String(hostEmail).toLowerCase();
  try {
    const [profRes, meetsRes] = await Promise.all([
      sb.from('profiles').select('nickname,photo_url,photo_public,bio,school').eq('email', em).maybeSingle(),
      sb.from('meetups').select('id,title,when_label,created_at,attendance_state').eq('host_email', em).order('created_at', {ascending:false})
    ]);
    const meets = (meetsRes && meetsRes.data) || [];
    const doneMeets = meets.filter(x=>x.attendance_state==='done'); // ④ 통계·후기는 완료 처리된 모임 기준(2026-07-30 확정)
    let reviews = [];
    if (doneMeets.length) {
      const ids = doneMeets.map(x=>x.id); const both = ids.concat(ids.map(i=>'r_'+i));
      const rv = await sb.from('posts').select('author_name,body,created_at').eq('category','review').in('related_meetup_id', both).order('created_at', {ascending:false}).limit(5);
      reviews = (rv && rv.data) || [];
    }
    return { profile:(profRes && profRes.data) || null, meetups:meets, doneCount:doneMeets.length, reviews };
  } catch(e) { console.error('fetchHostPublicData', e); return null; }
}
// 프로필 서버 저장(19단계) — 닉네임 유니크(서버 인덱스), 사진 공개 여부 포함. 저장 시 내 모임의 호스트 이름·사진도 함께 갱신
async function fetchMyProfile(email) {
  const sb = getSupabaseClient(); if (!sb || !email) return null;
  try { const { data, error } = await sb.from('profiles').select('*').eq('email', String(email).toLowerCase()).maybeSingle(); if (error) { console.error('fetchMyProfile', error); return null; } return data||null; }
  catch(e) { return null; }
}
async function saveMyProfile(email, prof) {
  const sb = getSupabaseClient(); if (!sb || !email) return { skipped:true };
  const em = String(email).toLowerCase();
  const nickname = (prof.nickname||'').trim();
  if (nickname.length < 2) return { error:{ message:'닉네임은 2자 이상이어야 해요' } };
  // 운영진 사칭 방지(예약어) — 서버 트리거(MM_NICKNAME_RESERVED)가 최종 강제, 여기선 친절한 사전 안내
  const RESERVED_RE = /(운영진|운영자|관리자|어드민|admin|administrator|staff|스태프|매니저|manager|모두모여|modumoyeo|시스템|system|공식|official|고객센터|support)/i;
  const adminList = String((window.__APP_CONFIG__&&window.__APP_CONFIG__.ADMIN_EMAILS)||'').toLowerCase();
  if (RESERVED_RE.test(nickname) && !adminList.includes(em)) return { reserved:true };
  try {
    // 세션이 본인 계정이 아닐 때(가입 인증 전, 또는 다른 계정 로그인 상태) 서버 저장은 RLS에 막힘 —
    // 이때는 조용히 건너뛰고 로컬에만 두며, 본인 로그인 후 첫 저장 때 서버 등록·중복확인이 이뤄진다
    try { const sess = await sb.auth.getSession(); const je = ((sess&&sess.data&&sess.data.session&&sess.data.session.user&&sess.data.session.user.email)||'').toLowerCase(); if (!je || je !== em) return { skipped:true, nosession:true }; } catch(e) {}
    const row = { email:em, nickname, photo_url:prof.photoUrl||null, photo_public:!!prof.photoPublic, school:prof.school||null, bio:prof.bio||null, updated_at:new Date().toISOString() };
    const { error } = await sb.from('profiles').upsert([row], {onConflict:'email'});
    if (error) {
      if (String(error.code)==='23505' || String(error.message||'').toLowerCase().includes('duplicate')) return { dup:true };
      if (String(error.message||'').includes('MM_NICKNAME_RESERVED')) return { reserved:true };
      console.error('saveMyProfile', error); return { error };
    }
    // 내가 주최한 모임 카드에도 이름·(공개 시)사진 반영 — RLS상 내 모임만 갱신됨
    try { await sb.from('meetups').update({host_name:nickname, host_photo_url: row.photo_public ? row.photo_url : null}).eq('host_email', em); } catch(e) {}
    return { ok:true };
  } catch(e) { return { error:e }; }
}
// 진짜 채팅(18단계) — 서버 저장 + 실시간 구독. RLS: 확정 참가자·호스트·운영진만
async function fetchChatMessages(meetupId) {
  const sb = getSupabaseClient(); const mid = serverMeetupId(meetupId);
  if (!sb || !mid) return null;
  try { const { data, error } = await sb.from('chat_messages').select('*').eq('meetup_id', mid).order('created_at', {ascending:true}).limit(200); if (error) { console.error('fetchChatMessages', error); return null; } return data||[]; }
  catch(e) { return null; }
}
async function sendChatMessage(meetupId, email, name, body) {
  const sb = getSupabaseClient(); const mid = serverMeetupId(meetupId);
  if (!sb || !mid || !email || !body) return { skipped:true };
  try {
    const { data, error } = await sb.from('chat_messages').insert([{meetup_id:mid, sender_email:email, sender_name:name||null, body}]).select().single();
    if (error) { console.error('sendChatMessage', error); return { error }; }
    return { data };
  } catch(e) { return { error:e }; }
}
// 실시간 새 메시지 구독 — 해제 함수 반환. 실패해도 폴링이 백업하므로 조용히 무시
function subscribeChatMessages(meetupId, onInsert) {
  const sb = getSupabaseClient(); const mid = serverMeetupId(meetupId);
  if (!sb || !mid || !sb.channel) return null;
  try {
    const ch = sb.channel('chat-'+mid)
      .on('postgres_changes', {event:'INSERT', schema:'public', table:'chat_messages', filter:'meetup_id=eq.'+mid}, payload=>{ try { onInsert(payload.new); } catch(e) {} })
      .subscribe();
    return ()=>{ try { sb.removeChannel(ch); } catch(e) {} };
  } catch(e) { return null; }
}
// 인앱 알림(17단계) — 생성은 전부 서버 트리거, 클라이언트는 조회·읽음·삭제만
async function fetchNotifications(email) {
  const sb = getSupabaseClient(); if (!sb || !email) return null;
  try { const { data, error } = await sb.from('notifications').select('*').eq('user_email', email).order('created_at', {ascending:false}).limit(30); if (error) { console.error('fetchNotifications', error); return null; } return data||[]; }
  catch(e) { return null; }
}
async function markNotificationsRead(email, id) {
  const sb = getSupabaseClient(); if (!sb || !email) return;
  try { let q = sb.from('notifications').update({read:true}).eq('user_email', email); if (id) q = q.eq('id', id); await q; } catch(e) {}
}
async function clearNotifications(email) {
  const sb = getSupabaseClient(); if (!sb || !email) return;
  try { await sb.from('notifications').delete().eq('user_email', email); } catch(e) {}
}
function NotifBell({size=19, go, set, S}) {
  const [open, setOpen] = React.useState(false);
  const [readSet, setReadSet] = React.useState(()=>new Set());
  const [cleared, setCleared] = React.useState(false);
  const wrapRef = React.useRef(null);
  const [serverRows, setServerRows] = React.useState([]);
  React.useEffect(()=>{
    const email = S && S.acctEmail;
    if (!email || (S.authStatus&&S.authStatus!=='authenticated')) { setServerRows([]); return; }
    let alive = true;
    fetchNotifications(email).then(rows=>{ if (alive && rows) setServerRows(rows); });
    return ()=>{ alive = false; };
  }, [S&&S.acctEmail, open]);
  const btnRef = React.useRef(null);
  React.useEffect(()=>{
    if (!open) return;
    const onKey = e => { if (e.key==='Escape') { setOpen(false); btnRef.current&&btnRef.current.focus(); } };
    const onDocClick = e => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
    const onOtherMenu = () => setOpen(false);
    document.addEventListener('keydown', onKey);
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('mm:menuopen', onOtherMenu);
    return () => { document.removeEventListener('keydown', onKey); document.removeEventListener('mousedown', onDocClick); document.removeEventListener('mm:menuopen', onOtherMenu); };
  }, [open]);
  const dynamicItems = React.useMemo(()=>{
    if (!S) return [];
    const out = [];
    Object.entries(S.joined||{}).forEach(([id,status])=>{
      const m = MEETUPS[id]; if (!m) return;
      if (status==='pending') out.push({name:'시스템', text:`"${m.title}" 참가 신청이 모임장 승인을 기다리고 있어요`, time:'대기 중', icon:'IconNormalCircleInfo2NameCircleInfoFillFalse', action:{screen:'mymeetupcoming'}});
      else if (status==='confirmed') out.push({name:'시스템', text:`"${m.title}" 참가가 확정됐어요`, time:'확정됨', icon:'IconNormalCircleCheck2NameCircleCheckFillFillTrue', action:{screen:'detail', detailId:id}});
      else if (status==='waitlisted') out.push({name:'시스템', text:`"${m.title}" 대기 명단에 등록됐어요`, time:'대기 중', icon:'IconNormalCircleInfo2NameCircleInfoFillFalse', action:{screen:'detail', detailId:id}});
    });
    Object.entries(S.eventApplied||{}).forEach(([id,status])=>{
      if (!status) return;
      const e = EVENTS.find(x=>x.id===id); if (!e) return;
      if (status==='pending') out.push({name:'시스템', text:`"${e.title}" 추첨 결과를 기다리고 있어요`, time:'대기 중', icon:'IconNormalCircleInfo2NameCircleInfoFillFalse', action:{screen:'mymeetupcoming'}});
      else if (status==='waitlisted') out.push({name:'시스템', text:`"${e.title}" 대기 신청 중이에요 — 자리가 나면 알려드려요`, time:'대기 중', icon:'IconNormalCircleInfo2NameCircleInfoFillFalse', action:{screen:'mymeetupcoming'}});
      else if (status==='confirmed') out.push({name:'시스템', text:`"${e.title}" 참가가 확정됐어요`, time:'확정됨', icon:'IconNormalCircleCheck2NameCircleCheckFillFillTrue', action:{screen:'eventdetail', eventId:id}});
    });
    (S.completed||[]).forEach(id=>{
      const m = MEETUPS[id]; if (!m) return;
      out.push({name:'시스템', text:`"${m.title}" 후기를 남겨주세요`, time:'참석 완료', icon:'IconNormalBubbleNameBubbleFillFalse', action:{screen:'postmeetup', detailId:id}});
    });
    return out;
  }, [S&&S.joined, S&&S.eventApplied, S&&S.completed]);
  const NOTIF_ICON = {approved:'IconNormalCircleCheck2NameCircleCheckFillFillTrue', promoted:'IconNormalCircleCheck2NameCircleCheckFillFillTrue', rejected:'IconNormalCircleInfo2NameCircleInfoFillFalse', new_applicant:'IconNormalPersonsNamePersonsFillFalse', new_participant:'IconNormalPersonsNamePersonsFillFalse', meetup_edited:'IconNormalCircleInfo2NameCircleInfoFillFalse', meetup_canceled:'IconNormalCircleInfo2NameCircleInfoFillFalse', participant_canceled:'IconNormalCircleInfo2NameCircleInfoFillFalse'};
  const serverItems = serverRows.map(r=>({sid:r.id, sread:r.read, name:'알림', text:r.title+(r.body?(' — '+r.body):''), time:timeAgo(Date.parse(r.created_at)), icon:NOTIF_ICON[r.type]||'IconNormalBell2NameBellFillFalse', action: r.meetup_id?{screen:'detail', detailId:'r_'+r.meetup_id}:{screen:'mymeetupcoming'}}));
  const items = cleared ? [] : [...serverItems, ...dynamicItems];
  const unread = items.some((n,i)=> n.sid ? !n.sread : !readSet.has(i));
  const openItem = (n,i) => { if (n.sid && S && S.acctEmail) { markNotificationsRead(S.acctEmail, n.sid); setServerRows(rs=>rs.map(r=>r.id===n.sid?{...r, read:true}:r)); } setReadSet(s=>new Set(s).add(i)); setOpen(false); if(!go) return; const a=n.action||{}; if(a.screen==='postdetail'&&a.post){ set&&set({qnaDetailPost:a.post, screen:'postdetail'}); } else { go(a.screen||'home', {...(a.detailId?{detailId:a.detailId}:{}), ...(a.eventId?{eventId:a.eventId}:{})}); } };
  return <div ref={wrapRef} style={{position:'relative'}}>
    <button ref={btnRef} className="iconbtn" aria-label="알림" aria-expanded={open} aria-controls="notif-panel" onClick={()=>{ if(!open) document.dispatchEvent(new CustomEvent('mm:menuopen')); setOpen(o=>!o); }} style={{position:'relative'}}>
      <Ic n={open?'IconNormalBell2NameBellFillFillTrue':'IconNormalBell2NameBellFillFalse'} s={size}/>
      {unread&&<span style={{position:'absolute', top:5, right:5, width:7, height:7, borderRadius:'50%', background:'var(--c-red)'}}></span>}
    </button>
    {open&&<div id="notif-panel" className="notifpanel" role="menu" onClick={e=>e.stopPropagation()}>
      <div className="row" style={{justifyContent:'space-between', padding:'6px 8px 8px'}}>
        <span style={{fontSize:12.5, fontWeight:700}}>알림</span>
        <div className="row" style={{gap:8}}>
          <button style={{background:'none', border:'none', color:'var(--c-primary)', fontSize:11.5, fontFamily:'inherit', cursor:'pointer', padding:0}} onClick={()=>{ setReadSet(new Set(items.map((_,i)=>i))); if (S && S.acctEmail) { markNotificationsRead(S.acctEmail); setServerRows(rs=>rs.map(r=>({...r, read:true}))); } }}>모두 읽음</button>
          <button style={{background:'none', border:'none', color:'var(--c-faint)', fontSize:11.5, fontFamily:'inherit', cursor:'pointer', padding:0}} onClick={()=>{ setCleared(true); if (S && S.acctEmail) { clearNotifications(S.acctEmail); setServerRows([]); } }}>지우기</button>
        </div>
      </div>
      {items.length===0?<p className="meta" style={{padding:'10px 8px'}}>알림이 없어요. 모임에 참가하거나 신청하면 진행 상태가 여기 표시돼요.</p>:items.map((n,i)=><div key={i} className="notifitem" onClick={()=>openItem(n,i)} style={{cursor:'pointer', opacity:(n.sid? n.sread : readSet.has(i))?.55:1}}>
        <Ic n={n.icon} s={20} c="var(--c-primary)"/>
        <div style={{flex:1}}>
          <div style={{fontSize:12.5, lineHeight:1.5}}>{n.text}</div>
          <div className="meta" style={{marginTop:2}}>{n.time}</div>
        </div>
      </div>)}
    </div>}
  </div>;
}
const INTERESTS_POOL = ['러닝','전시 관람','카페 투어','보드게임','등산','사진','요가','스터디','펍·바'];
function LocationPrompt({S, set}) {
  if (!S.locPromptOpen) return null;
  const allow = () => {
    set({locPromptOpen:false, locPromptDismissed:true, locGranted:true, regionVals:{...S.regionVals, cur:'대한민국'}, toast:'현재 위치를 확인했어요 — 대한민국'});
    if (navigator.geolocation) { try { navigator.geolocation.getCurrentPosition(()=>{}, ()=>{}, {timeout:2000}); } catch(e){} }
  };
  const chooseCity = () => set({locPromptOpen:false, regionEdit:'cur', regionDraft:S.regionVals.cur, screen:'region'});
  const later = () => set({locPromptOpen:false});
  return <div className="overlay" onClick={later}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <div className="row" style={{gap:10, marginBottom:10}}>
        <img src={asset('assets/modumoyeo-icon.png')} alt="" style={{width:36, height:36, borderRadius:9}}/>
        <b style={{fontSize:16}}>내 주변 모임을 더 쉽게 찾을까요?</b>
      </div>
      <p style={{fontSize:13.5, color:'var(--c-sub)', lineHeight:1.6, margin:'0 0 18px'}}>위치 권한을 허용하면 현재 도시의 모임을 먼저 보여드려요. 허용하지 않아도 도시를 직접 선택해 모든 기능을 사용할 수 있어요.</p>
      <div style={{display:'flex', flexDirection:'column', gap:8}}>
        <DSBtn label="위치로 찾기" onClick={allow}/>
        <DSBtn label="도시 직접 선택" variant="outlined" onClick={chooseCity}/>
        <DSBtn label="다음에 하기" variant="outlined" color="assistive" onClick={later}/>
      </div>
    </div>
  </div>;
}
function MeetupBadge({count}) {
  let label, icon, style;
  if (count>=10) { label = `모임 마스터 · ${count}회`; icon='IconNormalTrophyNameTrophyFillFillTrue'; style={background:'linear-gradient(135deg,#FFE9A8,#FFC233)', color:'#6b4400', boxShadow:'inset 0 0 0 1px rgba(180,120,0,.35)'}; }
  else if (count>=5) { label = `활동 우수 회원 · ${count}회`; icon='IconNormalMedalNameMedal'; style={background:'linear-gradient(135deg,#EDEFF3,#C7CDD6)', color:'#333a44', boxShadow:'inset 0 0 0 1px rgba(90,100,115,.25)'}; }
  else if (count>=1) { label = `모임 ${count}회 참여`; icon='IconNormalVerifiedCheck2NameVerifiedCheckFillFillTrue'; style={background:'var(--c-green-bg)', color:'var(--c-green)'}; }
  else return null;
  return <span className="chip" style={{...style, gap:5, fontWeight:700}}><Ic n={icon} s={13}/>{label}</span>;
}
function CapacityBar({m}) {
  const joined = effectiveJoined(m);
  const full = joined >= m.capMax;
  const pct = Math.min(100, Math.round(joined/m.capMax*100));
  const companionN = companionApprovedCount(m.id);
  return <div style={{marginBottom:10}}>
    <div className="row" style={{justifyContent:'space-between', marginBottom:5, fontSize:12.5}}>
      <span style={{fontWeight:700}}>{joined}/{m.capMax}명 신청{companionN>0?` (동반자 ${companionN}명 포함)`:''}</span>
      {m.waitlist>0&&<span className="meta">대기 {m.waitlist}명</span>}
    </div>
    <div style={{height:7, borderRadius:4, background:'var(--c-fill)', overflow:'hidden'}}>
      <div style={{height:'100%', width:pct+'%', background: full?'var(--c-orange)':'var(--c-primary)', borderRadius:4}}></div>
    </div>
  </div>;
}
function CompanionInfoCard() {
  return <div className="card" style={{marginBottom:16}}>
    <b style={{fontSize:13.5}}>친구와 함께 참여하고 싶나요?</b>
    <p style={{fontSize:12.5,color:'var(--c-sub)',lineHeight:1.6,margin:'8px 0 0'}}>참가자는 각자 가입하고 신청하는 것을 권장해요. 가입하지 않은 지인과 함께하고 싶다면 모임 채팅에서 주최자에게 먼저 문의해주세요. 동반자는 주최자 승인 후 정원에 포함됩니다.</p>
    <p style={{fontSize:11.5,color:'var(--c-faint)',lineHeight:1.6,margin:'8px 0 0'}}>사전 승인 없이 지인을 데려오는 경우 현장에서 참여가 어려울 수 있어요.</p>
  </div>;
}
function CompanionAskLink({onOpen, style}) {
  return <button type="button" onClick={onOpen} style={{background:'none',border:'none',color:'var(--c-faint)',fontFamily:'inherit',fontSize:12,cursor:'pointer',padding:'8px 2px',textDecoration:'underline',textAlign:'left',minHeight:24,...style}}>비회원 지인과 함께하고 싶다면 주최자에게 먼저 문의해주세요</button>;
}
function CompanionSafetyNoteCard({set}) {
  const text = '동반 참가자는 모두모여 회원이 아니더라도 모임의 기본 이용수칙과 주최자의 진행 안내를 따라야 해요. 문제가 발생하면 주최자가 참여를 중단시킬 수 있어요.';
  return <div className="card" style={{marginTop:10}}>
    <b style={{fontSize:13}}>동반자에게 전달할 안내</b>
    <p style={{fontSize:12.5,color:'var(--c-sub)',lineHeight:1.6,margin:'8px 0 10px'}}>{text}</p>
    <button style={{background:'none',border:'1px solid var(--c-line)',borderRadius:8,padding:'8px 14px',fontFamily:'inherit',fontSize:12,cursor:'pointer',minHeight:36}} onClick={()=>{ try{navigator.clipboard&&navigator.clipboard.writeText(text);}catch(e){} set&&set({toast:'안내 문구를 복사했어요'}); }}>안내 복사</button>
  </div>;
}
function CompanionDecisionCard({m, S, set}) {
  const {useState} = React;
  const stored = getCompanion(m.id) || {};
  const [name,setName] = useState(stored.name||'');
  const [count,setCount] = useState(stored.count||1);
  const [adult,setAdult] = useState(stored.adult!=null?stored.adult:true);
  const [note,setNote] = useState(stored.note||'');
  const [declineMode,setDeclineMode] = useState(false);
  const [declineReason,setDeclineReason] = useState('남은 정원이 부족해요');
  const [declineCustom,setDeclineCustom] = useState('');
  const capMax = Number(m.capMax || (m.size&&(m.size.match(/\d+/)||[])[0]) || 0);
  const current = Number(m.capJoined||0);
  const projected = current + Number(count);
  const over = capMax>0 && projected>capMax;
  const notify = text => set({chatMsgs:[...S.chatMsgs, {sys:true, text}]});
  if (stored.status==='approved') return <div className="card" style={{marginBottom:12,borderColor:'var(--c-primary)'}}>
    <div className="row" style={{gap:6,marginBottom:6}}><Chip label="비회원 동반자 승인됨" tone="primary"/></div>
    <div style={{fontSize:12.5,color:'var(--c-sub)'}}>{stored.name||'동반자'} 외 {stored.count||1}명 · 현재 인원 {Math.min(effectiveJoined(m),capMax)}/{capMax}명</div>
    <button style={{background:'none',border:'1px solid var(--c-line)',borderRadius:8,padding:'8px 14px',fontFamily:'inherit',fontSize:12,cursor:'pointer',marginTop:8,minHeight:36}} onClick={()=>{ setCompanion(m.id,null); notify(`주최자가 동반자 승인을 취소했어요. 현재 인원은 ${current}/${capMax}명이에요.`); set({toast:'동반자 승인을 취소했어요'}); }}>동반자 승인 취소</button>
    <CompanionSafetyNoteCard set={set}/>
  </div>;
  if (stored.status==='individual' || stored.status==='declined') return <div className="card" style={{marginBottom:12}}>
    <Chip label={stored.status==='individual'?'개별 가입 요청함':'요청 거절됨'} tone={stored.status==='individual'?'violet':''}/>
    {stored.declineReason&&<div className="meta" style={{marginTop:6}}>사유 · {stored.declineReason}</div>}
    <button style={{background:'none',border:'none',color:'var(--c-primary)',fontFamily:'inherit',fontSize:12,cursor:'pointer',padding:'8px 0 0',minHeight:36}} onClick={()=>{ setCompanion(m.id,{status:'requested'}); set({toast:'다시 문의를 받았어요'}); }}>다시 검토하기</button>
  </div>;
  return <div className="card" style={{marginBottom:12}}>
    <div className="row" style={{gap:6,marginBottom:8}}><Chip label="동반자 요청 처리 (주최자)" tone="violet"/></div>
    <div style={{display:'flex',flexDirection:'column',gap:8}}>
      <div><div className="meta" style={{marginBottom:4}}>동반자 표시 이름 또는 별칭</div>
        <input value={name} onChange={e=>setName(e.target.value)} placeholder="예: 지민님의 친구" style={{width:'100%',border:'1px solid var(--c-line)',borderRadius:8,padding:'9px 11px',fontFamily:'inherit',fontSize:13.5,outline:'none'}}/></div>
      <div className="row" style={{gap:14,flexWrap:'wrap'}}>
        <div>
          <div className="meta" style={{marginBottom:4}}>동반 인원</div>
          <div className="row" style={{gap:6}}>
            <button style={{width:36,height:36,borderRadius:8,border:'1px solid var(--c-line)',background:'var(--c-bg)',cursor:'pointer',fontSize:16}} onClick={()=>setCount(c=>Math.max(1,c-1))}>−</button>
            <span style={{minWidth:20,textAlign:'center',fontWeight:700}}>{count}</span>
            <button style={{width:36,height:36,borderRadius:8,border:'1px solid var(--c-line)',background:'var(--c-bg)',cursor:'pointer',fontSize:16}} onClick={()=>setCount(c=>c+1)}>+</button>
          </div>
        </div>
        <label className="row" style={{gap:6,fontSize:12.5,cursor:'pointer',minHeight:36}}><input type="checkbox" checked={adult} onChange={e=>setAdult(e.target.checked)} style={{width:18,height:18,accentColor:'var(--c-primary)'}}/>만 19세 이상</label>
      </div>
      <div><div className="meta" style={{marginBottom:4}}>요청 메모 (선택)</div>
        <input value={note} onChange={e=>setNote(e.target.value)} placeholder="예: 같은 학교 친구예요" style={{width:'100%',border:'1px solid var(--c-line)',borderRadius:8,padding:'9px 11px',fontFamily:'inherit',fontSize:13.5,outline:'none'}}/></div>
      <div style={{fontSize:12,color:'var(--c-sub)'}}>현재 정원 현황 {current}/{capMax}명 · 승인 후 예상 인원 {projected}/{capMax}명</div>
      {over&&<div style={{fontSize:12,color:'var(--c-coral)',fontWeight:600}}>동반자를 승인하면 정원을 초과해요. 정원을 확인하거나 개별 가입을 요청해주세요.</div>}
      {!declineMode?<div style={{display:'flex',gap:8,flexWrap:'wrap',marginTop:2}}>
        <DSBtn label="동반자 승인" size="md" disable={over} onClick={()=>{ setCompanion(m.id,{status:'approved', name:name||'동반자', count, adult, note, requestedBy:'나'}); notify(`주최자가 나님의 동반자 ${count}명을 승인했어요. 현재 인원은 ${Math.min(projected,capMax)}/${capMax}명이에요.`); set({toast:'동반자를 승인했어요'}); }} style={{flex:'1 1 auto'}}/>
        <DSBtn label="개별 가입 요청" variant="outlined" color="assistive" size="md" onClick={()=>{ setCompanion(m.id,{status:'individual', name, count, adult, note}); notify('안전한 모임 운영을 위해 동반자도 모두모여에 가입한 후 별도로 신청해주세요.'); }} style={{flex:'1 1 auto'}}/>
        <button style={{background:'none',border:'none',color:'var(--c-faint)',fontFamily:'inherit',fontSize:12.5,cursor:'pointer',padding:'8px 4px',minHeight:36}} onClick={()=>setDeclineMode(true)}>요청 거절</button>
      </div>:<div style={{display:'flex',flexDirection:'column',gap:8,marginTop:2}}>
        <div className="meta">거절 사유</div>
        <div style={{display:'flex',flexDirection:'column',gap:6}}>
          {['남은 정원이 부족해요','소규모 진행을 위해 추가 참여가 어려워요','참가 조건 확인이 필요해요','기타'].map(r=><label key={r} className="row" style={{gap:8,fontSize:13,cursor:'pointer',minHeight:32}}><input type="radio" name={'declreason'+m.id} checked={declineReason===r} onChange={()=>setDeclineReason(r)} style={{width:16,height:16,accentColor:'var(--c-primary)'}}/>{r}</label>)}
        </div>
        {declineReason==='기타'&&<input value={declineCustom} onChange={e=>setDeclineCustom(e.target.value)} placeholder="사유를 입력해주세요" style={{width:'100%',border:'1px solid var(--c-line)',borderRadius:8,padding:'9px 11px',fontFamily:'inherit',fontSize:13.5,outline:'none'}}/>}
        <div style={{display:'flex',gap:8}}>
          <DSBtn label="거절 확정" size="md" onClick={()=>{ const reason = declineReason==='기타'?(declineCustom.trim()||'사정상 이번엔 어려워요'):declineReason; setCompanion(m.id,{status:'declined', name, count, adult, note, declineReason:reason}); notify('이번 모임은 남은 정원 때문에 동반자 참여가 어려워요. 양해 부탁드려요.'); setDeclineMode(false); }} style={{flex:1}}/>
          <DSBtn label="취소" variant="outlined" color="assistive" size="md" onClick={()=>setDeclineMode(false)} style={{flex:1}}/>
        </div>
      </div>}
    </div>
  </div>;
}
// ── 대기열(진짜 큐) ─────────────────────────────────────────────
// 기존에는 "대기 3번째로 등록됐어요" 같은 문구가 실제 계산 없이 고정 텍스트였음.
// 아래 함수들은 모임별로 실제 대기 순번을 배열로 관리하고, 자리가 반환되면 맨 앞 대기자를 실제로 승격시킨다.
function myWaitlistPosition(id, S) {
  const q = (S.waitlists && S.waitlists[id]) || [];
  const idx = q.findIndex(e=>e.isMe);
  return idx===-1 ? null : idx+1;
}
function joinWaitlist(id, S) {
  const existing = (S.waitlists && S.waitlists[id]) || [];
  if (existing.some(e=>e.isMe)) return S.waitlists||{}; // 중복 등록 방지
  // 데모 모드에서 대기열이 비어 있으면 앞선 데모 대기자를 시딩해 실제 순번을 체감할 수 있게 함(공개 모드는 시딩 없음)
  const seeded = (DEMO_MODE && existing.length===0) ? ATTENDEES.slice(0,2).map(n=>({name:n, isMe:false})) : existing;
  return {...(S.waitlists||{}), [id]: [...seeded, {name:'나', isMe:true}]};
}
function fillDemoWaitlist(id, S, n) {
  // 데모 컨트롤 전용 — 대기열에 데모 인원을 추가로 채워 넣어 승계 동작을 반복 테스트할 수 있게 함
  const existing = (S.waitlists && S.waitlists[id]) || [];
  const pool = ATTENDEES.filter(name=>!existing.some(e=>e.name===name));
  const added = pool.slice(0, n||2).map(name=>({name, isMe:false}));
  return {...(S.waitlists||{}), [id]: [...existing, ...added]};
}
// 자리 하나가 반환됐을 때 실제로 처리하는 함수.
// mine=true: 내가 확정 상태에서 빠지는 경우(취소, 재확인 거절) — 정원 반환 + 내 참가상태 제거까지 함께 처리
// mine=false: 다른(데모) 확정자가 빠지는 경우(데모 컨트롤) — 정원만 반환
// 두 경우 모두 처리 후 대기열 맨 앞을 실제로 승격시킨다(대기자가 없으면 자리만 열어둠).
function releaseSlotAndPromote(id, S, set, mine, extraPatch) {
  const m = resolveMeetup(id, S);
  if (!m) { if (extraPatch) set(extraPatch); return; }
  let joined = {...S.joined};
  let waitlists = {...(S.waitlists||{})};
  let queue = [...(waitlists[id]||[])];
  m.capJoined = Math.max(0, (m.capJoined||0)-1);
  if (mine) delete joined[id];
  let toast;
  if (queue.length===0) {
    toast = mine ? '참가를 취소했어요' : '자리가 하나 났어요 — 지금은 대기 중인 분이 없어요';
  } else {
    const next = queue.shift();
    m.capJoined = (m.capJoined||0)+1; // 승격된 사람이 그 자리를 실제로 채움(정원 순증감 없음)
    if (next.isMe) {
      joined[id] = 'confirmed';
      toast = '대기하시던 자리가 확정됐어요! 채팅방이 열렸어요';
    } else {
      toast = mine ? `참가를 취소했어요 — 대기 1번째 ${next.name}님에게 자리가 넘어갔어요` : `${next.name}님에게 자리가 넘어갔어요 — 대기 순번이 당겨졌어요`;
    }
  }
  waitlists[id] = queue;
  set({joined, waitlists, toast, ...extraPatch});
}
// 비회원(미인증) 사용자가 실제 행동(참가신청·모임개설·글쓰기 등)을 하려 할 때 막고 회원가입으로 안내.
// 로그인된 사용자면 onAllowed()를 그대로 실행.
function AuthChoiceSheet({S, set, go}) {
  if (!S.authChoiceOpen) return null;
  return <div className="overlay" onClick={()=>set({authChoiceOpen:false})}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <h2 style={{fontSize:17, margin:'0 0 16px'}}>회원이신가요?</h2>
      <DSBtn label="로그인하기" size="md" onClick={()=>{ set({authChoiceOpen:false}); go('signup'); }} style={{marginBottom:10}}/>
      <p className="meta" style={{margin:'0 0 16px', lineHeight:1.6}}>아직 회원이 아니신가요?</p>
      <DSBtn label="회원가입하러 가기" variant="outlined" size="md" onClick={()=>{ set({authChoiceOpen:false}); go('onboarding'); }}/>
    </div>
  </div>;
}
function guardAuth(S, set, go, onAllowed) {
  if (S.authStatus === 'authenticated') { onAllowed(); return; }
  set({authChoiceOpen:true});
}
function cancelParticipation(id, S, set, force) {
  const m = resolveMeetup(id, S); if (!m) return;
  const companion = getCompanion(id);
  const hasApproved = companion && companion.status==='approved';
  // 취소는 항상 확인 팝업을 거친다 — 취소 페널티(1시간 신청 제한) 안내를 보여주기 위함
  if (!force) { set({cancelConfirmId:id}); return; }
  // 취소 확정 시각 기록 — 신청 화면에서 남은 제한 시간을 즉시 안내(서버 트리거가 최종 강제)
  // 페널티는 반드시 계정별로 기록 — 브라우저 공통 키로 두면 다른 계정의 신청까지 막아버림(실제 발생했던 버그)
  const markPenalty = () => { try { localStorage.setItem(nsKey('mm_cancel_penalty:'+((S.acctEmail||S.signupEmail||'').toLowerCase())), String(Date.now()+3600*1000)); } catch(e) {} };
  const doLocal = () => {
    markPenalty();
    if (hasApproved) setCompanion(id, null);
    const status = S.joined[id];
    const doneMsg = hasApproved ? '참가와 동반자 참가가 함께 취소되었습니다 — 1시간 동안 새 모임 신청이 제한돼요'
                                : '참가가 취소되었습니다 — 1시간 동안 새 모임 신청이 제한돼요';
    if (status==='confirmed') {
      // 확정 참가를 취소할 때만 정원이 실제로 반환되고 대기열 승격이 일어남
      const extraPatch = {cancelConfirmId:null, toast:doneMsg};
      // 상세 화면 표시용 인원 숫자·게이지도 즉시 내려가게 반영
      if (S.tempDetail && S.tempDetail.id===id) extraPatch.tempDetail = {...S.tempDetail, capJoined:Math.max(0,(S.tempDetail.capJoined||0)-1)};
      releaseSlotAndPromote(id, S, set, true, extraPatch);
      return;
    }
    // 승인대기·대기열 상태의 취소는 확정 인원(정원)에 영향을 준 적이 없으므로 정원은 그대로 두고,
    // 대기열에 있었다면 그 목록에서만 내 항목을 제거한다.
    const j = {...S.joined}; delete j[id];
    const waitlists = {...(S.waitlists||{})};
    if (waitlists[id]) waitlists[id] = waitlists[id].filter(e=>!e.isMe);
    set({joined:j, waitlists, cancelConfirmId:null, toast: doneMsg});
  };
  // 서버에 저장된 모임이면 서버 취소가 성공한 뒤에만 화면을 바꾼다(12단계 서버화)
  const email = S.acctEmail || S.signupEmail;
  if (isServerMeetupId(id) && email) {
    cancelParticipationServer(id, email).then(res=>{
      if (res && res.error) { set({cancelConfirmId:null, toast:'취소에 실패했어요 — 잠시 후 다시 시도해주세요'}); return; }
      doLocal();
    });
    return;
  }
  doLocal();
}
function CancelConfirmSheet({S, set}) {
  const id = S.cancelConfirmId; if (!id) return null;
  const companion = getCompanion(id);
  const hasApproved = companion && companion.status==='approved';
  const count = (companion&&companion.count)||1;
  return <div className="overlay" onClick={()=>set({cancelConfirmId:null})}>
    <div className="sheet" onClick={e=>e.stopPropagation()}>
      <h2 style={{fontSize:17,margin:'0 0 8px'}}>정말 취소할까요?</h2>
      <p style={{fontSize:13.5,color:'var(--c-sub)',lineHeight:1.65,margin:'0 0 10px'}}>취소하면 자리가 바로 비워지고, 대기 중인 분이 있으면 다음 순번에게 자동으로 넘어가요. 모임 시작 24시간 전까지는 자유롭게 취소할 수 있지만, 그 이후의 취소·노쇼는 다음 신청에 불이익이 있을 수 있어요.</p>
      {hasApproved&&<p style={{fontSize:13.5,color:'var(--c-sub)',lineHeight:1.6,margin:'0 0 10px'}}>회원님의 참가를 취소하면 승인된 동반자 {count}명의 참가도 함께 취소돼요.</p>}
      <div className="notice orange" style={{marginBottom:16}}><Ic n="IconNormalCircleInfo2NameCircleInfoFillFalse" s={15}/><span>취소하면 <b>1시간 동안 새 모임 신청이 제한</b>돼요. 취소가 잦으면(7일 내 4회 이상) 제한이 24시간으로 늘어나요 — 다른 분들의 자리를 지키기 위한 규칙이에요.</span></div>
      <div style={{display:'flex',flexDirection:'column',gap:8}}>
        <DSBtn label="참가 유지" variant="outlined" color="assistive" onClick={()=>set({cancelConfirmId:null})}/>
        <DSBtn label={hasApproved?'동반자와 함께 취소':'취소할게요'} onClick={()=>cancelParticipation(id, S, set, true)}/>
      </div>
    </div>
  </div>;
}
const ATTENDEES_SEED = ['닉네임A','닉네임B','닉네임C','닉네임D','민수','하나'];
const ATTENDEES = DEMO_MODE ? ATTENDEES_SEED : [];
function ScrollCounter({children, count, axis='x'}) {
  const ref = React.useRef(null);
  const [idx, setIdx] = React.useState(0);
  const onScroll = () => {
    const el = ref.current; if (!el || count<=1) return;
    if (axis==='x') { const cw = el.scrollWidth/count; setIdx(Math.min(count-1, Math.round(el.scrollLeft/cw))); }
    else { const ch = el.scrollHeight/count; setIdx(Math.min(count-1, Math.round(el.scrollTop/ch))); }
  };
  return <div>
    <div className={axis==='x'?'hscroll':''} ref={ref} onScroll={onScroll} style={axis==='y'?{maxHeight:420, overflowY:'auto'}:undefined}>{children}</div>
    {count>1&&<div className="row" style={{justifyContent:'center', gap:6, margin:'8px 0 4px'}}>
      <span className="meta">{idx+1} / {count}</span>
    </div>}
  </div>;
}
const KR_GEO = [
  ['강남', 37.4979, 127.0276], ['홍대', 37.5563, 126.9236], ['이태원', 37.5346, 126.9947], ['신촌', 37.5559, 126.9368],
  ['잠실', 37.5133, 127.1000], ['성수', 37.5445, 127.0559], ['부산', 35.1796, 129.0756], ['해운대', 35.1587, 129.1604],
  ['가평', 37.8315, 127.5095], ['제주', 33.4996, 126.5312], ['대구', 35.8714, 128.6014], ['인천', 37.4563, 126.7052],
];
function geocodeKR(text) {
  if (text) { const hit = KR_GEO.find(g=>text.includes(g[0])); if (hit) return {lat:hit[1], lng:hit[2]}; }
  return {lat:37.5665, lng:126.9780};
}
const EVENTS_SEED = [
  {id:'ev1', title:'서울 유학생 소개팅 파티', when:'8월 16일(토) 오후 6시 · 강남', city:'서울', cost:'4만원 (식사·음료 포함)', host:'모두모여 운영진', tone:'primary', desc:'귀국·유학 경험이 있는 20~30대를 위한 대표 주최 소개팅 파티예요. 참가비에 식사·음료가 포함돼요.', tag:'소개팅', mode:'firstcome', cap:20, applied:16},
  {id:'ev2', title:'런던 1박 2일 벙개 여행', when:'9월 5일(금)~6일(토) · 런던 근교', city:'런던', cost:'12만원 (숙박·조식 포함)', host:'GB 후원사 파트너', sponsor:'런던 게스트하우스 파트너', sponsorItem:'숙박비 50% 지원', tone:'violet', desc:'런던 거주 회원들을 위한 근교 여행 이벤트예요. 소규모 그룹으로 나눠 이동해요.', tag:'번개 여행', mode:'lottery', cap:6, applied:9},
  {id:'ev3', title:'가평 1박 2일 힐링 여행', when:'9월 20일(토)~21일(일) · 가평', city:'가평', cost:'8만원 (숙박·바비큐 포함)', host:'모두모여 운영진', tone:'green', desc:'귀국·유학생을 위한 국내 소모임 여행이에요. 바비큐·보드게임 등이 준비돼 있어요.', tag:'번개 여행', mode:'lottery', cap:10, applied:4},
];
const EVENTS = DEMO_MODE ? EVENTS_SEED : [];
// 실제 이미지 업로드 — Supabase Storage에 저장하고 공개 URL을 돌려줌. 연동 안 됐으면 null 반환.
// 업로드 전 자동 축소(2026-07-30) — 휴대폰 원본(수 MB~수십 MB)도 그대로 올릴 수 있게 긴 변 1600px·JPEG 85%로 변환.
// 변환 실패(구형 브라우저 등)나 축소해도 더 커지는 경우엔 원본 그대로 사용. GIF는 건드리지 않음.
async function compressImageFile(file, maxDim, quality) {
  maxDim = maxDim || 1600; quality = quality || 0.85;
  try {
    if (!file || !file.type || file.type.indexOf('image/')!==0 || file.type==='image/gif') return file;
    const bmp = await createImageBitmap(file).catch(()=>null);
    if (!bmp) return file;
    const scale = Math.min(1, maxDim/Math.max(bmp.width, bmp.height));
    const w = Math.max(1, Math.round(bmp.width*scale)), h = Math.max(1, Math.round(bmp.height*scale));
    if (scale===1 && file.size < 1200*1024) return file; // 이미 작으면 그대로
    const canvas = document.createElement('canvas'); canvas.width=w; canvas.height=h;
    canvas.getContext('2d').drawImage(bmp, 0, 0, w, h);
    const blob = await new Promise(res=>canvas.toBlob(res, 'image/jpeg', quality));
    if (!blob || blob.size >= file.size) return file;
    return new File([blob], ((file.name||'photo').replace(/\.[^.]+$/,''))+'.jpg', {type:'image/jpeg'});
  } catch(e) { return file; }
}
async function uploadImage(file, folder) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  if (!file || !file.type || file.type.indexOf('image/')!==0) return { error:'이미지 파일만 올릴 수 있어요' };
  file = await compressImageFile(file);
  if (file.size > 8*1024*1024) return { error:'사진이 너무 커요 — 8MB 이하로 줄여서 올려주세요' };
  try {
    const ext = (file.name.split('.').pop()||'jpg').toLowerCase().replace(/[^a-z0-9]/g,'') || 'jpg';
    const path = (folder||'misc') + '/' + Date.now() + '-' + Math.random().toString(36).slice(2,8) + '.' + ext;
    const { error: upErr } = await sb.storage.from('images').upload(path, file, { cacheControl:'3600', upsert:false });
    if (upErr) { console.error('uploadImage', upErr); return { error:upErr.message||'업로드 실패' }; }
    const { data } = sb.storage.from('images').getPublicUrl(path);
    return { url: data && data.publicUrl };
  } catch(e) { console.error('uploadImage', e); return { error:'업로드 중 오류가 발생했어요' }; }
}
// 신고 스크린샷 업로드(26단계) — 비공개 버킷 'reports'에 저장하고 경로만 반환. 열람은 운영진만(스토리지 RLS)
async function uploadReportScreenshot(file) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  if (!file || !file.type || file.type.indexOf('image/')!==0) return { error:'이미지 파일만 올릴 수 있어요' };
  file = await compressImageFile(file);
  if (file.size > 8*1024*1024) return { error:'사진이 너무 커요 — 8MB 이하로 줄여서 올려주세요' };
  try {
    const ext = (file.name.split('.').pop()||'jpg').toLowerCase().replace(/[^a-z0-9]/g,'') || 'jpg';
    const path = Date.now() + '-' + Math.random().toString(36).slice(2,8) + '.' + ext;
    const { error: upErr } = await sb.storage.from('reports').upload(path, file, { cacheControl:'3600', upsert:false });
    if (upErr) { console.error('uploadReportScreenshot', upErr); return { error:upErr.message||'업로드 실패' }; }
    return { path };
  } catch(e) { console.error('uploadReportScreenshot', e); return { error:'업로드 중 오류가 발생했어요' }; }
}
// 사진 크롭 편집기(2026-07-30 사용자 요청) — 라이브러리 없이 드래그(위치)+슬라이더(확대)로 맞추고 캔버스로 잘라냄
function PhotoCropModal({src, shape, onCancel, onDone}) {
  const circle = shape==='circle';
  const VW = circle?280:320, VH = circle?280:240;
  const [img, setImg] = React.useState(null);
  const [zoom, setZoom] = React.useState(1);
  const [pos, setPos] = React.useState({x:0, y:0});
  const drag = React.useRef(null);
  React.useEffect(()=>{ const im = new Image(); im.onload = ()=>setImg(im); im.src = src; }, [src]);
  if (!img) return <div className="overlay"><div className="sheet" style={{textAlign:'center'}}><p className="meta" style={{margin:0}}>사진 여는 중…</p></div></div>;
  const s0 = Math.max(VW/img.width, VH/img.height);
  const st = s0*zoom;
  const maxX = Math.max(0,(img.width*st-VW)/2), maxY = Math.max(0,(img.height*st-VH)/2);
  const cx = Math.max(-maxX, Math.min(maxX, pos.x)), cy = Math.max(-maxY, Math.min(maxY, pos.y));
  const start = e => { e.preventDefault(); drag.current = {sx:e.clientX, sy:e.clientY, ox:cx, oy:cy}; try { e.currentTarget.setPointerCapture(e.pointerId); } catch(er) {} };
  const move = e => { if (!drag.current) return; setPos({x:drag.current.ox+(e.clientX-drag.current.sx), y:drag.current.oy+(e.clientY-drag.current.sy)}); };
  const end = () => { drag.current = null; };
  const done = () => {
    const swr = VW/st, shr = VH/st;
    const scx = img.width/2 - cx/st, scy = img.height/2 - cy/st;
    const TW = circle?640:1200, TH = circle?640:900;
    const c = document.createElement('canvas'); c.width = TW; c.height = TH;
    c.getContext('2d').drawImage(img, scx-swr/2, scy-shr/2, swr, shr, 0, 0, TW, TH);
    c.toBlob(b=>{ if (!b) { onCancel(); return; } onDone(new File([b], 'crop.jpg', {type:'image/jpeg'})); }, 'image/jpeg', 0.9);
  };
  return <div className="overlay">
    <div className="sheet" onClick={e=>e.stopPropagation()} style={{maxWidth:VW+56}}>
      <h2 style={{fontSize:16, margin:'0 0 12px'}}>사진 위치·크기 조절</h2>
      <div onPointerDown={start} onPointerMove={move} onPointerUp={end} onPointerCancel={end}
        style={{width:VW, height:VH, margin:'0 auto', overflow:'hidden', position:'relative', borderRadius:circle?'50%':'12px', background:'#16161a', cursor:'grab', touchAction:'none'}}>
        <img src={src} alt="" draggable={false} style={{position:'absolute', left:'50%', top:'50%', width:img.width*st, height:img.height*st, maxWidth:'none', transform:'translate(calc(-50% + '+cx+'px), calc(-50% + '+cy+'px))', userSelect:'none', pointerEvents:'none'}}/>
      </div>
      <div className="row" style={{gap:10, margin:'14px 0 6px'}}>
        <span className="meta" style={{flexShrink:0}}>확대</span>
        <input type="range" min="1" max="3" step="0.01" value={zoom} onChange={e=>setZoom(Number(e.target.value))} style={{flex:1, accentColor:'var(--c-primary)'}}/>
      </div>
      <p className="meta" style={{margin:'0 0 14px', lineHeight:1.5}}>사진을 끌어서 위치를 맞추고, 슬라이더로 크기를 조절하세요.</p>
      <div style={{display:'flex', gap:8}}>
        <div style={{flex:1}}><DSBtn label="취소" variant="outlined" color="assistive" size="md" onClick={onCancel}/></div>
        <div style={{flex:1}}><DSBtn label="이 사진 사용" size="md" onClick={done}/></div>
      </div>
    </div>
  </div>;
}
function PhotoUpload({value, onChange, folder, shape='circle', size=120, placeholder='사진 올리기'}) {
  const {useState, useRef} = React;
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');
  const inputRef = useRef(null);
  const pick = () => inputRef.current && inputRef.current.click();
  const [cropSrc, setCropSrc] = useState(null);
  const onFile = e => {
    const f = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!f) return;
    setErr('');
    if (!f.type || f.type.indexOf('image/')!==0) { setErr('이미지 파일만 올릴 수 있어요'); return; }
    if (f.type==='image/gif') { doUpload(f); return; } // 움짤은 크롭 없이 원본
    const r = new FileReader(); r.onload = ()=>setCropSrc(r.result); r.readAsDataURL(f);
  };
  const doUpload = async f => {
    setBusy(true);
    const res = await uploadImage(f, folder);
    setBusy(false);
    if (res.error) { setErr(res.error); return; }
    if (res.skipped) { setErr('서버 연동이 안 돼 있어서 지금은 업로드할 수 없어요'); return; }
    onChange(res.url);
  };
  const radius = shape==='circle' ? '50%' : shape==='rounded' ? '14px' : '10px';
  return <div style={{width:size==='100%'?'100%':size, height:typeof size==='number'?size:200, position:'relative'}}>
    <input ref={inputRef} type="file" accept="image/png,image/jpeg,image/webp" style={{display:'none'}} onChange={onFile}/>
    {value?<div style={{position:'relative', width:'100%', height:'100%'}}>
      <img src={value} alt="" style={{width:'100%', height:'100%', objectFit:'cover', borderRadius:radius, display:'block'}}/>
      <button type="button" onClick={pick} disabled={busy} style={{position:'absolute', bottom:4, right:4, background:'rgba(0,0,0,.65)', color:'#fff', border:'none', borderRadius:8, padding:'4px 10px', fontSize:11.5, fontFamily:'inherit', cursor:'pointer'}}>{busy?'업로드 중...':'변경'}</button>
      <button type="button" onClick={()=>onChange(null)} disabled={busy} style={{position:'absolute', bottom:4, left:4, background:'rgba(0,0,0,.55)', color:'#fff', border:'none', borderRadius:8, padding:'4px 10px', fontSize:11.5, fontFamily:'inherit', cursor:'pointer'}}>제거</button>
    </div>:<button type="button" onClick={pick} disabled={busy} style={{width:'100%', height:'100%', border:'1px dashed var(--c-line)', borderRadius:radius, background:'var(--c-fill-a)', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:6, cursor:'pointer', fontFamily:'inherit', color:'var(--c-faint)'}}>
      <Ic n="IconNormalImageNameImage" s={22}/>
      <span style={{fontSize:12.5}}>{busy?'업로드 중...':placeholder}</span>
    </button>}
    {err&&<p style={{color:'var(--c-red)', fontSize:11.5, margin:'6px 0 0'}}>{err}</p>}
    {cropSrc&&<PhotoCropModal src={cropSrc} shape={shape} onCancel={()=>setCropSrc(null)} onDone={f=>{ setCropSrc(null); doUpload(f); }}/>}
  </div>;
}
// 회원 탈퇴 — 이 계정 이메일로 서버에 저장된 데이터(모임·게시물·댓글)를 실제로 삭제
// 31단계: 탈퇴 완전 삭제 — 서버 RPC가 참가·알림·채팅·문의·차단·이벤트신청·글·댓글·내 모임·프로필·계정(auth)까지 삭제.
// 신고·고객문의·제재 이력은 분쟁 대응과 재가입 제재 회피 방지를 위해 보존.
async function deleteMyData(email) {
  const sb = getSupabaseClient();
  if (!sb || !email) return { skipped:true };
  try {
    const { error } = await sb.rpc('mm_delete_my_account');
    if (error) { console.error('deleteMyData rpc', error); return { error }; }
    return { ok:true };
  } catch(e) { console.error('deleteMyData', e); return { error:e }; }
}
// 게시물 단건 조회(주소로 직접 들어온 경우 복원용)
async function fetchPostById(id) {
  const sb = getSupabaseClient(); if (!sb || !id) return null;
  try { const { data } = await sb.from('posts').select('*').eq('id', id).maybeSingle(); return data||null; } catch(e) { return null; }
}
// 마케팅(이벤트·혜택) 수신 동의 서버 저장 — 동의 일시까지 기록(법적 근거 보관)
async function saveMarketingConsent(email, ok) {
  const sb = getSupabaseClient(); if (!sb || !email) return { skipped:true };
  try {
    const { error } = await sb.from('profiles').update({ marketing_ok: !!ok, marketing_ok_at: ok ? new Date().toISOString() : null }).eq('email', String(email).toLowerCase());
    if (error) return { error }; return { ok:true };
  } catch(e) { return { error:e }; }
}
// 카카오 로그인(OAuth) — 카카오 동의 화면으로 이동, 완료되면 사이트로 복귀하며 세션이 실려 옴
async function kakaoSignIn() {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try {
    const { error } = await sb.auth.signInWithOAuth({ provider:'kakao', options:{ redirectTo: window.location.origin } });
    if (error) return { error };
    return { ok:true };
  } catch(e) { return { error:e }; }
}
// ── 진짜 로그인(Supabase Auth) ──────────────────────────────────────────
function translateAuthError(msg) {
  const m = (msg||'').toLowerCase();
  if (m.includes('already registered') || m.includes('already exists') || m.includes('user already')) return '이미 가입된 이메일이에요. 로그인해 주세요.';
  if (m.includes('invalid login credentials') || m.includes('invalid email or password')) return '이메일 또는 비밀번호를 확인해 주세요.';
  if (m.includes('email not confirmed')) return '이메일 인증이 아직 안 됐어요. 받은 메일함(스팸함도)을 확인해 주세요.';
  if (m.includes('password') && (m.includes('character')||m.includes('length')||m.includes('short'))) return '비밀번호는 8자 이상이어야 해요.';
  if (m.includes('rate limit') || m.includes('too many')) return '요청이 너무 많아요. 잠시 후 다시 시도해 주세요.';
  if (m.includes('network')) return '인터넷 연결을 확인한 뒤 다시 시도해 주세요.';
  // 서버가 사람이 읽을 수 없는 메시지(빈 값, "{}"처럼 JSON 그대로 온 값 등)를 보냈을 때는 그대로 보여주지 않고 안전한 문구로 대체
  if (!msg || !msg.trim() || /^[{}\[\]\s]*$/.test(msg)) return '일시적인 서버 오류가 발생했어요. 잠시 후 다시 시도해 주세요.';
  return msg;
}
async function authSignUp(email, password) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  try { const { data, error } = await sb.auth.signUp({ email, password }); if (error) return { error: translateAuthError(error.message) }; return { data }; }
  catch(e) { return { error: translateAuthError(e.message) }; }
}
// 운영진 제재(경고/3일 정지/7일 정지/영구정지) — 반드시 신고에 자동으로 확인된 target_email이
// 있을 때만 호출해야 함(관리자 페이지에서 이메일 없는 신고는 이 함수를 아예 호출하지 않도록 막아둠).
// 잘못된 계정에 제재가 걸리는 걸 막기 위한 안전장치.
async function applySanction(email, type, reason, adminEmail) {
  if (!email) return { error: '대상 계정 이메일이 확인되지 않아 처리할 수 없어요' };
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  const days = {warning:0, suspend_3d:3, suspend_7d:7, permanent:null}[type];
  const expires_at = (type==='permanent') ? null : new Date(Date.now()+days*86400000).toISOString();
  try {
    const { data, error } = await sb.from('user_sanctions').insert([{ email: email.trim().toLowerCase(), type, reason: reason||'', expires_at, created_by: adminEmail||'' }]).select().single();
    if (error) return { error };
    return { data };
  } catch(e) { return { error: e }; }
}
// 이벤트 운영 도구(31.5) — 선정·통보·이벤트 채팅(익명). 회원 채팅은 RPC 전용(이메일 노출 0)
async function adminFetchEventApplicants(eventId) {
  const sb = getSupabaseClient(); if (!sb || !eventId) return [];
  try { const { data } = await sb.from('event_applications').select('*').eq('event_id', eventId).order('created_at'); return data||[]; } catch(e) { return []; }
}
async function adminSetEventAppStatus(appId, status) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { error } = await sb.rpc('mm_event_set_status', { app_id: appId, new_status: status }); if (error) return { error }; return { ok:true }; } catch(e) { return { error:e }; }
}
async function adminNotifyRejected(eventId) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { data, error } = await sb.rpc('mm_event_notify_rejected', { eid: eventId }); if (error) return { error }; return { count: data }; } catch(e) { return { error:e }; }
}
async function adminOpenEventChat(eventId, anonymous) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { error } = await sb.rpc('mm_event_open_chat', { eid: eventId, anon: !!anonymous }); if (error) return { error }; return { ok:true }; } catch(e) { return { error:e }; }
}
async function fetchEventChatRoom(eventId) {
  const sb = getSupabaseClient(); if (!sb || !eventId) return null;
  try { const { data } = await sb.from('event_chat_rooms').select('*').eq('event_id', eventId).maybeSingle(); return data||null; } catch(e) { return null; }
}
async function fetchEventAliases(eventId) {
  const sb = getSupabaseClient(); if (!sb) return [];
  try { const { data } = await sb.from('event_aliases').select('*').eq('event_id', eventId).order('alias'); return data||[]; } catch(e) { return []; }
}
async function fetchEventChat(eventId) {
  const sb = getSupabaseClient(); if (!sb) return null;
  try { const { data, error } = await sb.rpc('mm_event_chat_fetch', { eid: eventId }); if (error) return null; return data||[]; } catch(e) { return null; }
}
async function sendEventChat(eventId, body) {
  const sb = getSupabaseClient(); if (!sb) return { skipped:true };
  try { const { error } = await sb.rpc('mm_event_chat_send', { eid: eventId, msg: body }); if (error) return { error }; return { ok:true }; } catch(e) { return { error:e }; }
}
async function fetchMyEventApplication(eventId, email) {
  const sb = getSupabaseClient(); if (!sb || !eventId || !email) return null;
  try { const { data } = await sb.from('event_applications').select('status,rejected_notified_at').eq('event_id', eventId).eq('applicant_email', String(email).toLowerCase()).maybeSingle(); return data||null; } catch(e) { return null; }
}
// 관리자 제재 관리(25단계) — 활성 제재 조회·해제, 노쇼 기록 조회·정정
async function fetchActiveSanctions() {
  const sb = getSupabaseClient(); if (!sb) return [];
  try {
    const { data, error } = await sb.from('user_sanctions').select('*').order('created_at', {ascending:false}).limit(100);
    if (error) { console.error('fetchActiveSanctions', error); return []; }
    const now = Date.now();
    return (data||[]).map(x=>({...x, active: x.type!=='warning' && (x.expires_at===null || new Date(x.expires_at).getTime()>now)}));
  } catch(e) { return []; }
}
async function liftSanction(id) {
  const sb = getSupabaseClient(); if (!sb || !id) return { skipped:true };
  try {
    // 해제 = 만료시각을 지금으로 당김(이력은 보존). 영구도 동일하게 즉시 만료
    const { error } = await sb.from('user_sanctions').update({expires_at:new Date().toISOString()}).eq('id', id);
    if (error) return { error }; return { ok:true };
  } catch(e) { return { error:e }; }
}
// 최근 노쇼 기록(운영진용) — 이메일로 조회. 모임 제목 포함
async function fetchNoshowRecords(email) {
  const sb = getSupabaseClient(); if (!sb || !email) return [];
  try {
    const { data, error } = await sb.from('participants').select('id,meetup_id,user_email,status,updated_at').eq('user_email', String(email).toLowerCase()).eq('status','noshow').order('updated_at', {ascending:false}).limit(30);
    if (error) { console.error('fetchNoshowRecords', error); return []; }
    const rows = data||[];
    const ids = [...new Set(rows.map(r=>r.meetup_id))];
    let titles = {};
    if (ids.length) { const { data: ms } = await sb.from('meetups').select('id,title').in('id', ids); (ms||[]).forEach(m=>{ titles[m.id]=m.title; }); }
    return rows.map(r=>({...r, meetup_title: titles[r.meetup_id]||'(삭제된 모임)'}));
  } catch(e) { return []; }
}
// 노쇼 → 참석으로 정정(운영진). 트리거상 운영진은 48시간 잠금도 예외
async function correctNoshow(participantId) {
  const sb = getSupabaseClient(); if (!sb || !participantId) return { skipped:true };
  try { const { error } = await sb.from('participants').update({status:'attended', updated_at:new Date().toISOString()}).eq('id', participantId); if (error) return { error }; return { ok:true }; }
  catch(e) { return { error:e }; }
}
// 로그인한 이메일에 현재 유효한(경고 제외) 정지 기록이 있는지 확인
async function checkActiveSanction(email) {
  if (!email) return null;
  const sb = getSupabaseClient(); if (!sb) return null;
  try {
    const { data, error } = await sb.from('user_sanctions').select('*').eq('email', email.trim().toLowerCase()).order('created_at', {ascending:false}).limit(20);
    if (error || !data) return null;
    const now = Date.now();
    return data.find(s => s.type!=='warning' && (s.expires_at===null || new Date(s.expires_at).getTime()>now)) || null;
  } catch(e) { return null; }
}
async function authSignIn(email, password) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  try {
    const { data, error } = await sb.auth.signInWithPassword({ email, password });
    if (error) return { error: translateAuthError(error.message) };
    const sanction = await checkActiveSanction(email);
    if (sanction) {
      await sb.auth.signOut();
      const until = sanction.expires_at ? `${new Date(sanction.expires_at).toLocaleDateString('ko-KR')}까지` : '영구적으로';
      return { error: `이용이 제한된 계정이에요 (${until} 정지). 사유: ${sanction.reason||'커뮤니티 이용수칙 위반'}. 문의: support@modumoyeo.com` };
    }
    return { data };
  }
  catch(e) { return { error: translateAuthError(e.message) }; }
}
async function authSignOut() {
  const sb = getSupabaseClient();
  if (!sb) return;
  try { await sb.auth.signOut(); } catch(e) {}
}
async function authGetSession() {
  const sb = getSupabaseClient();
  if (!sb) return null;
  try { const { data } = await sb.auth.getSession(); return (data && data.session) || null; } catch(e) { return null; }
}
// 비밀번호를 잊은 회원이 재설정 메일을 요청 — 메일의 링크를 누르면 type=recovery 해시로 앱에 돌아와 WScrResetPassword로 연결됨
async function authRequestPasswordReset(email) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  // redirectTo를 명시하지 않으면 Supabase 프로젝트에 기본 설정된 Site URL(다른 배포일 수 있음)로 돌아가버림 —
  // 지금 이 사이트 주소로 명시적으로 지정해서, 이 도메인이 Supabase의 Redirect URLs 허용 목록에 있어야만 정상 동작함.
  const redirectTo = typeof window!=='undefined' ? window.location.origin + window.location.pathname : undefined;
  try { const { error } = await sb.auth.resetPasswordForEmail(email, redirectTo?{ redirectTo }:undefined); if (error) return { error: translateAuthError(error.message) }; return { ok:true }; }
  catch(e) { return { error: translateAuthError(e.message) }; }
}
async function authChangePassword(newPassword) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  try { const { error } = await sb.auth.updateUser({ password:newPassword }); if (error) return { error: translateAuthError(error.message) }; return { ok:true }; }
  catch(e) { return { error: translateAuthError(e.message) }; }
}
async function authChangeEmail(newEmail) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  try { const { error } = await sb.auth.updateUser({ email:newEmail }); if (error) return { error: translateAuthError(error.message) }; return { ok:true }; }
  catch(e) { return { error: translateAuthError(e.message) }; }
}
async function authResendConfirmation(email) {
  const sb = getSupabaseClient();
  if (!sb) return { skipped:true };
  try { const { error } = await sb.auth.resend({ type:'signup', email }); if (error) return { error: translateAuthError(error.message) }; return { ok:true }; }
  catch(e) { return { error: translateAuthError(e.message) }; }
}
// Q&A 글의 제목 — posts 테이블에 아직 별도 title 컬럼이 없어(2부 서버 공사 때 추가 예정), 저장할 때 첫 줄을 제목으로 쓰는 규칙을 사용
function qnaCompose(title, body) { return (title||'').trim() + '\n' + (body||'').trim(); }
function qnaSplit(raw) {
  const text = raw || '';
  const idx = text.indexOf('\n');
  if (idx===-1) return { title: text, body: '' };
  return { title: text.slice(0, idx), body: text.slice(idx+1) };
}
function escapeHtml(str) {
  return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 공지사항 전용 — 아주 제한적인 서식만 허용(굵게: **텍스트**). 나머지는 전부 이스케이프해서 임의 HTML 주입을 막음.
function renderNoticeBody(text) {
  const escaped = escapeHtml(text).replace(/\n/g, '<br/>');
  return escaped.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
}
function timeAgo(ms) {
  if (!ms) return '';
  const diff = Date.now() - ms;
  const min = Math.floor(diff/60000);
  if (min < 1) return '방금 전';
  if (min < 60) return `${min}분 전`;
  const hr = Math.floor(min/60);
  if (hr < 24) return `${hr}시간 전`;
  const day = Math.floor(hr/24);
  return `${day}일 전`;
}
function formatWhen(date, time) {
  if (!date) return '';
  const d = new Date(date+'T00:00:00');
  const days = ['일','월','화','수','목','금','토'];
  let out = `${d.getMonth()+1}/${d.getDate()}(${days[d.getDay()]})`;
  if (time) { const [h,m] = time.split(':').map(Number); const ap = h<12?'오전':'오후'; const h12 = h%12===0?12:h%12; out += ` ${ap} ${h12}시${m?':'+String(m).padStart(2,'0'):''}`; }
  return out;
}
Object.assign(window, {WDS, Ic, DSBtn, Chip, Ph, Av, TopBar, NavBar, Sect, MeetCard, MiniThumb, TrustBadges, InfoRow, MEETUPS, COUNTRIES, COUNTRY_META, countryOf, genCityMeetups, NEIGHBORHOODS, CountrySelect, feeLine, getCompanion, setCompanion, companionApprovedCount, effectiveJoined, CompanionInfoCard, CompanionAskLink, CompanionSafetyNoteCard, CompanionDecisionCard, guardAuth, AuthChoiceSheet, isAdminUser, uploadImage, uploadReportScreenshot, PhotoUpload, deleteMyData, authSignUp, authSignIn, authSignOut, kakaoSignIn, saveMarketingConsent, authGetSession, authRequestPasswordReset, authChangePassword, qnaCompose, qnaSplit, fetchEvents, useEvents, fetchMyEventApplications, applyToEvent, cancelEventApplication, authChangeEmail, authResendConfirmation, translateAuthError, fetchPosts, fetchPostById, createPost, deletePost, fetchComments, createComment, deleteComment, cancelParticipation, CancelConfirmSheet, myWaitlistPosition, isServerMeetupId, serverMeetupId, applyToMeetupServer, cancelParticipationServer, fetchMyParticipations, fetchMeetupCounts, fetchMeetupParticipants, updateParticipantStatus, saveAttendance, joinWaitlist, fillDemoWaitlist, releaseSlotAndPromote, getSavedMeetupIds, getSavedMeetups, useSavedMeetups, getSupabaseClient, fetchRemoteMeetups, createRemoteMeetup, updateRemoteMeetup, deleteRemoteMeetup, saveMeetupPlaceDetail, fetchMeetupPlaceDetail, fetchMeetupCancellations, resolveMeetup, dateBucketOf, dateBucketLabel, createReport, createInquiry, fetchReports, fetchMyReports, fetchInquiries, updateReportStatus, updateInquiryStatus, adminFetchEventApplicants, adminSetEventAppStatus, adminNotifyRejected, adminOpenEventChat, fetchEventChatRoom, fetchEventAliases, fetchEventChat, sendEventChat, fetchMyEventApplication, applySanction, fetchActiveSanctions, liftSanction, fetchNoshowRecords, correctNoshow, checkActiveSanction, isAdminEmail, AdminBadge, AdminRemoveModal, adminNotify, adminCreateEvent, adminDeleteEvent, MeetupInquiryModal, getOrCreateInquiryThread, reopenInquiryThread, loadMyBlocks, isBlockedEmail, blockUser, unblockUser, fetchInquiryThreads, fetchInquiryMessages, sendInquiryMessage, closeInquiryThread, recordExists, ADMIN_REMOVE_REASONS, isAdminEmail, AdminBadge, fetchHostPublicData, fetchMyProfile, saveMyProfile, fetchChatMessages, sendChatMessage, subscribeChatMessages, fetchNotifications, markNotificationsRead, clearNotifications, Pagination, Tooltip, EmailField, PasswordField, CustomActivityInput, computeHomeData, HOME_TIMELINE, SchoolSelect, synthDetailFromCard, TimePicker, DatePicker, Brand, MapEmbed, PlaceSearchModal, geocodeKR, PROFILES, NotifBell, INTERESTS_POOL, LocationPrompt, MeetupBadge, CapacityBar, ATTENDEES, EVENTS, EventStats, ScrollCounter, formatWhen, timeAgo, renderNoticeBody, LEGAL_DOCS, LegalModal, ActIcon, meetupVisual, TONE_BG, TONE_FG, Reveal, ReportModal, HostCompleteModal, ReviewPopup, TrustSignals, TEST_IDENTITIES, isAvoiding, meetupVisibleTo, visibleMeetups, PrivacyAvoidBody, AvoidUserButton, AvoidUserConfirmSheet, WithdrawConfirmSheet, MeetupBlockedNotice, FirstVisitPopup, POPUP_ACTIVITIES, suggestActivityIcon, DEMO_MODE, DemoModeBanner});
