/* global React, ReactDOM, GACHA_GAMES, GACHA_CATEGORIES, GACHA_EVENTS, FAN_EVENTS, FAN_BOOTHS, SITE_NOTICE, CHANGELOG, RELEASE_GAME */
const { useState, useMemo, useEffect, useRef } = React;

// ============== Helpers ==============
const TODAY = new Date();
const ymd = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const parseISO = (s) => {
  const [y, m, d] = s.split("-").map(Number);
  return new Date(y, m - 1, d);
};
const daysBetween = (a, b) => Math.round((parseISO(b) - parseISO(a)) / 86400000);
const fmtMonth = (m) => `${m + 1}월`;

// 팬 이벤트 기간 표시 (start / end, 구데이터 date 호환)
const fanDateRange = (fe) => {
  const s = fe.start || fe.date || "";
  return fe.end ? `${s} ~ ${fe.end}` : s;
};
// 사이드바용 축약 형식 (월.일 ~ 월.일)
const fanDateShort = (fe) => {
  const fmt = (d) => (d || "").slice(5).replace("-", ".");
  const s = fmt(fe.start || fe.date);
  return fe.end ? `${s}~${fmt(fe.end)}` : s;
};

// games + categories lookups — mutable so they update after API data loads
let GAME_BY_ID = {};
let CAT_BY_ID = GACHA_CATEGORIES;
let FAN_EVENT_BY_ID = {};

function recomputeLookups() {
  GAME_BY_ID = { [RELEASE_GAME.id]: RELEASE_GAME, ...Object.fromEntries(GACHA_GAMES.map((g) => [g.id, g])) };
  CAT_BY_ID = GACHA_CATEGORIES;
  FAN_EVENT_BY_ID = Object.fromEntries((FAN_EVENTS || []).map((e) => [e.id, e]));
}
recomputeLookups();
window.addEventListener('gachaplan:data-loaded', recomputeLookups);


// ============== Logo ==============
function Logo() {
  return (
    <div className="logo">
      <img className="logo-mark" src="logo.png" alt="logo" />
      <span>GachaPlan<span className="dot">.</span></span>
    </div>);

}

// ============== Nav ==============
// ============== Theme toggle ==============
function useTheme() {
  const [theme, setTheme] = useState(() => {
    try { return localStorage.getItem("gachaplan.theme") || "light"; }
    catch (_) { return "light"; }
  });
  useEffect(() => {
    document.documentElement.dataset.theme = theme;
    document.documentElement.style.colorScheme = theme;
    try { localStorage.setItem("gachaplan.theme", theme); } catch (_) {}
  }, [theme]);
  return [theme, setTheme];
}

function ThemeToggle({ theme, onToggle }) {
  const isDark = theme === "dark";
  return (
    <button
      className="theme-toggle"
      onClick={onToggle}
      aria-label={isDark ? "라이트 모드로 전환" : "다크 모드로 전환"}
      title={isDark ? "라이트 모드로 전환" : "다크 모드로 전환"}
    >
      <span className="tt-track" data-state={isDark ? "dark" : "light"}>
        <span className="tt-icon tt-moon">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
          </svg>
        </span>
        <span className="tt-icon tt-sun">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
            <circle cx="12" cy="12" r="3.6" fill="currentColor" stroke="none"/>
            <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
          </svg>
        </span>
        <span className="tt-thumb"></span>
      </span>
    </button>
  );
}

// ============== Nav ==============
const CHANGELOG_PER_PAGE = 5;

function ChangelogModal({ onClose }) {
  const log = (typeof CHANGELOG !== "undefined" && CHANGELOG) || [];
  const [page, setPage] = useState(1);
  const totalPages = Math.max(1, Math.ceil(log.length / CHANGELOG_PER_PAGE));
  const pageLog = log.slice((page - 1) * CHANGELOG_PER_PAGE, page * CHANGELOG_PER_PAGE);
  return (
    <div className="popover-mask" onClick={onClose}>
      <div className="changelog-modal" onClick={(e) => e.stopPropagation()}>
        <div className="changelog-head">
          <span className="changelog-title">업데이트 내역</span>
          <button className="popover-close" onClick={onClose}>✕</button>
        </div>
        <div className="changelog-body">
          {pageLog.map((entry) => (
            <div key={entry.date} className="changelog-entry">
              <div className="changelog-entry-head">
                <span className="changelog-date mono">{entry.date}</span>
                <span className="changelog-entry-title">{entry.title}</span>
              </div>
              <ul className="changelog-list">
                {entry.items.map((item, i) => (
                  <li key={i}>{item}</li>
                ))}
              </ul>
            </div>
          ))}
        </div>
        {totalPages > 1 && (
          <div className="changelog-pagination">
            <button className="pg-btn" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
              <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M10 3 5 8l5 5"/></svg>
            </button>
            {Array.from({ length: totalPages }, (_, i) => (
              <button key={i+1} className={"pg-btn" + (page === i+1 ? " active" : "")} onClick={() => setPage(i+1)}>{i+1}</button>
            ))}
            <button className="pg-btn" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
              <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M6 3l5 5-5 5"/></svg>
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

const SEARCH_PER_PAGE = 5;

function SearchModal({ onClose }) {
  const [inputValue, setInputValue] = useState("");
  const [query, setQuery] = useState("");
  const [page, setPage] = useState(1);
  const [calPick, setCalPick] = useState(null);
  const [gameFilter, setGameFilter] = useState(() => new Set());
  const inputRef = useRef(null);

  const handleItemClick = (item) => {
    if (item._type === "캘린더") {
      setCalPick(item);
    } else {
      const url = item.url;
      if (url) window.open(url, "_blank", "noreferrer");
    }
  };

  useEffect(() => {
    inputRef.current?.focus();
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  const handleKeyDown = (e) => {
    if (e.key === "Enter") { setQuery(inputValue); setPage(1); }
  };

  const selectGame = (id) => {
    setGameFilter((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
    setPage(1);
  };

  const results = useMemo(() => {
    const q = query.trim().toLowerCase();
    const byGame = (id) => gameFilter.size === 0 || gameFilter.has(id);
    if (!q && gameFilter.size > 0) {
      const evtHits = GACHA_EVENTS.filter((e) => gameFilter.has(e.game)).map((e) => ({ ...e, _type: e.cat === "offline" ? "오프라인" : "캘린더" }));
      return evtHits.sort((a, b) => (b.start || "").localeCompare(a.start || ""));
    }
    if (!q) return [];
    const calendarHits = GACHA_EVENTS
      .filter((e) => byGame(e.game) && e.cat !== "offline" && (e.title.toLowerCase().includes(q) || (e.desc && e.desc.toLowerCase().includes(q))))
      .map((e) => ({ ...e, _type: "캘린더" }));
    const offlineHits = GACHA_EVENTS
      .filter((e) => byGame(e.game) && e.cat === "offline" && (e.title.toLowerCase().includes(q) || (e.desc && e.desc.toLowerCase().includes(q))))
      .map((e) => ({ ...e, _type: "오프라인" }));
    return [...calendarHits, ...offlineHits]
      .sort((a, b) => (b.start || "").localeCompare(a.start || ""));
  }, [query, gameFilter]);

  const searched = query.trim().length > 0 || gameFilter.size > 0;
  const totalPages = Math.max(1, Math.ceil(results.length / SEARCH_PER_PAGE));
  const pageResults = results.slice((page - 1) * SEARCH_PER_PAGE, page * SEARCH_PER_PAGE);
  const fillerCount = SEARCH_PER_PAGE - pageResults.length;

  return (
    <>
    <div className="popover-mask" onClick={onClose}>
      <div className="search-modal" onClick={(e) => e.stopPropagation()}>

        {/* ── 좌측: 검색 영역 ── */}
        <div className="search-left">
          <div className="search-input-wrap">
            <svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" className="search-icon">
              <circle cx="7" cy="7" r="5" /><path d="M11 11l3 3" />
            </svg>
            <input
              ref={inputRef}
              className="search-input"
              type="text"
              placeholder="이벤트 검색 후 Enter..."
              value={inputValue}
              onChange={(e) => setInputValue(e.target.value)}
              onKeyDown={handleKeyDown}
            />
            <button className="popover-close search-close" onClick={onClose}>✕</button>
          </div>
          <div className="search-results-wrap">
            {!searched ? (
              <>
                <div className="search-result-count search-result-count--mute">게임을 선택하거나 검색어를 입력하세요.</div>
                {Array.from({ length: SEARCH_PER_PAGE }).map((_, i) => <div key={i} className="search-result-filler search-result-filler--blank" />)}
              </>
            ) : results.length === 0 ? (
              <>
                <div className="search-result-count search-result-count--mute">검색 결과가 없습니다.</div>
                {Array.from({ length: SEARCH_PER_PAGE }).map((_, i) => <div key={i} className="search-result-filler search-result-filler--blank" />)}
              </>
            ) : (
              <>
                <div className="search-result-count">{results.length}개 결과</div>
                <div className="search-results">
                  {pageResults.map((item) => {
                    const game = GAME_BY_ID[item.game];
                    return (
                      <div key={item.id}
                        className={"search-result-item" + ((item._type !== "캘린더" && !item.url) ? " sri--no-link" : "")}
                        onClick={() => handleItemClick(item)}>
                        <div className="sri-left">
                          {game && (
                            <span className="gico sri-gico" style={{ background: game.color }}>
                              {game.noImg ? <span style={{ fontSize: 8, fontWeight: 800, fontFamily: "Oxanium,monospace", color: "#163d2b" }}>{game.short}</span> : <img src={"images/games/" + game.id + ".webp"} alt={game.name} />}
                            </span>
                          )}
                        </div>
                        <div className="sri-body">
                          <div className="sri-title">{item.title}</div>
                          <div className="sri-meta">
                            <span className="sri-type">{item._type}</span>
                            {item.tag && <span className="sri-tag">{item.tag}</span>}
                            <span className="sri-date mono">{item.date || item.start}</span>
                          </div>
                        </div>
                      </div>
                    );
                  })}
                  {Array.from({ length: fillerCount }).map((_, i) => <div key={"f"+i} className="search-result-filler" />)}
                </div>
              </>
            )}
          </div>
          <div className={"search-pagination" + (totalPages > 1 ? " search-pagination--visible" : "")}>
            {totalPages > 1 && <>
              <button className="pg-btn" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
                <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M10 3 5 8l5 5"/></svg>
              </button>
              {Array.from({ length: totalPages }, (_, i) => (
                <button key={i+1} className={"pg-btn" + (page === i+1 ? " active" : "")} onClick={() => setPage(i+1)}>{i+1}</button>
              ))}
              <button className="pg-btn" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
                <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M6 3l5 5-5 5"/></svg>
              </button>
            </>}
          </div>
        </div>

        {/* ── 우측: 게임 필터 사이드바 ── */}
        <div className="search-game-sidebar">
          <div className="sgs-heading">게임</div>
          <button className={"sgame-chip" + (gameFilter.has(RELEASE_GAME.id) ? " active" : "")}
            onClick={() => selectGame(RELEASE_GAME.id)}
            style={{ "--gc": RELEASE_GAME.color }}>
            <div className="gico sgs-gico" style={{ background: "#163d2b", color: RELEASE_GAME.color }}>
              <span style={{ fontSize: 7, fontWeight: 800, fontFamily: "Oxanium,monospace" }}>NEW</span>
            </div>
            <span className="sgs-name">{RELEASE_GAME.name}</span>
          </button>
          {GACHA_GAMES.map((g) => (
            <button key={g.id} className={"sgame-chip" + (gameFilter === g.id ? " active" : "")} onClick={() => selectGame(g.id)}
              style={{ "--gc": g.color }}>
              <div className="gico sgs-gico" style={{ background: g.color }}>
                <img src={"images/games/" + g.id + ".webp"} alt={g.name} />
              </div>
              <span className="sgs-name">{g.name}</span>
            </button>
          ))}
        </div>

      </div>
    </div>
    {calPick && <Popover pick={calPick} onClose={() => setCalPick(null)} />}
    </>
  );
}

function NoticeBanner() {
  const [open, setOpen] = useState(false);
  if (!SITE_NOTICE) return null;
  return (
    <>
      <div className="notice-bar" onClick={() => setOpen(true)}>{SITE_NOTICE}</div>
      {open && <ChangelogModal onClose={() => setOpen(false)} />}
    </>
  );
}

// config.json에서 로드. 서버 없을 때 폴백
const getConfig = () => window.SITE_CONFIG ?? {};
const GOOGLE_CLIENT_ID = () => getConfig().google_client_id ?? "YOUR_CLIENT_ID.apps.googleusercontent.com";

function parseJwt(token) {
  try { return JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); }
  catch (_) { return null; }
}

const USER_KEY = "gachaplan_user";

function saveUser(user, persist) {
  const data = JSON.stringify(user);
  if (persist) { localStorage.setItem(USER_KEY, data); sessionStorage.removeItem(USER_KEY); }
  else          { sessionStorage.setItem(USER_KEY, data); localStorage.removeItem(USER_KEY); }
}

function loadUser() {
  try {
    const s = localStorage.getItem(USER_KEY) || sessionStorage.getItem(USER_KEY);
    return s ? JSON.parse(s) : null;
  } catch { return null; }
}

function clearUser() {
  localStorage.removeItem(USER_KEY);
  sessionStorage.removeItem(USER_KEY);
}

const UserCtx = React.createContext({ user: null, openLogin: () => {}, logout: () => {} });

function LoginModal({ onClose, onLogin }) {
  const btnRef  = useRef(null);
  const autoRef = useRef(false);

  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  useEffect(() => {
    const init = () => {
      if (!window.google || !btnRef.current) return;
      window.google.accounts.id.initialize({
        client_id: GOOGLE_CLIENT_ID(),
        callback: (res) => {
          const p = parseJwt(res.credential);
          if (p) { onLogin({ name: p.name, email: p.email, picture: p.picture, credential: res.credential }, autoRef.current); onClose(); }
        },
      });
      window.google.accounts.id.renderButton(btnRef.current, {
        theme: "outline", size: "large", text: "signin_with",
        shape: "rectangular", width: 202,
      });
    };
    if (window.google) init();
    else {
      const el = document.querySelector('script[src*="gsi/client"]');
      if (el) el.addEventListener("load", init, { once: true });
    }
  }, []);

  return (
    <div className="fm-mask" onClick={onClose}>
      <div className="fm fm--narrow" onClick={(e) => e.stopPropagation()}>
        <div className="fm-head">
          <span className="fm-head-icon">
            <svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6"/></svg>
          </span>
          <span className="fm-head-title">로그인</span>
          <button className="fm-close" onClick={onClose}>
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M3 3l10 10M13 3L3 13"/></svg>
          </button>
        </div>
        <div className="login-body">
          <p className="login-desc">
            로그인하면 아래 기능을 이용할 수 있습니다.
          </p>
          <ul className="login-feature-list">
            <li>❤️ 좋아요</li>
            <li>📅 일정 제보</li>
            <li>💬 건의 사항</li>
            <li>🏪 부스 등록</li>
          </ul>
          <div ref={btnRef} className="login-google-btn" />
          <label className="login-auto">
            <input type="checkbox" onChange={(e) => { autoRef.current = e.target.checked; }} />
            자동 로그인
          </label>
        </div>
      </div>
    </div>
  );
}

function Nav({ theme, onToggleTheme, page, onNav }) {
  const go = (key, e) => { e.preventDefault(); onNav(key); };
  const [searchOpen, setSearchOpen] = useState(false);
  const { user, openLogin, logout } = React.useContext(UserCtx);

  return (
    <>
    <div className="nav">
      <div className="nav-left">
        <Logo />
        <nav className="nav-menu">
          <a href="#" className={page === "calendar" ? "active" : ""} onClick={(e) => go("calendar", e)}>캘린더</a>
<a href="#" className={page === "booth" ? "active" : ""} onClick={(e) => go("booth", e)}>오프라인 행사</a>
        </nav>
      </div>
      <div className="nav-right">
        <button className="btn ghost" aria-label="검색" onClick={() => setSearchOpen(true)}>
          <svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
            <circle cx="7" cy="7" r="5" /><path d="M11 11l3 3" />
          </svg>
          검색
        </button>
        {user ? (
          <>
            <div className="nav-user" title={user.name}>
              {user.picture
                ? <img className="nav-avatar nav-avatar--img" src={user.picture} alt={user.name} referrerPolicy="no-referrer" />
                : <span className="nav-avatar">{user.name[0].toUpperCase()}</span>
              }
            </div>
            <button className="btn primary nav-logout-btn" onClick={logout}>로그아웃</button>
          </>
        ) : (
          <button className="btn primary" onClick={openLogin}>로그인</button>
        )}
        <ThemeToggle theme={theme} onToggle={onToggleTheme} />
      </div>
    </div>
    {searchOpen && <SearchModal onClose={() => setSearchOpen(false)} />}
    </>
  );
}

// ============== Year / Month controls ==============
function YearMonth({ year, month, setYear, setMonth, eventCountsByMonth }) {
  return (
    <section className="controls">
      <div className="year-row">
        <div className="year-nav">
          <button onClick={() => setYear(year - 1)} aria-label="이전 연도">
            <svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M10 3 5 8l5 5" /></svg>
          </button>
          <div className="year-label">{year}년</div>
          <button onClick={() => setYear(year + 1)} aria-label="다음 연도">
            <svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M6 3l5 5-5 5" /></svg>
          </button>
        </div>
        <div className="today-pill" onClick={() => {setYear(TODAY.getFullYear());setMonth(TODAY.getMonth());}} style={{ cursor: "pointer" }}>
          <span className="pill-dot"></span>
          오늘로 이동 · {TODAY.getFullYear()}.{String(TODAY.getMonth() + 1).padStart(2, "0")}.{String(TODAY.getDate()).padStart(2, "0")}
        </div>
      </div>
      <div className="month-tabs" role="tablist">
        {Array.from({ length: 12 }).map((_, m) =>
        <button
          key={m}
          className={"month-tab " + (m === month ? "active" : "")}
          onClick={() => setMonth(m)}
          role="tab"
          aria-selected={m === month}>
          
            {fmtMonth(m)}
            {eventCountsByMonth[m] > 0 && <span className="has-event"></span>}
          </button>
        )}
      </div>
    </section>);

}

// ============== Game filter banner ==============
function GameFilter({ selected, toggle, selectAll, allSelected, rightSlot, showRelease, onToggleRelease }) {
  const scrollRef = React.useRef(null);
  const [canLeft, setCanLeft] = React.useState(false);
  const [canRight, setCanRight] = React.useState(true);

  const updateScrollState = React.useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;
    setCanLeft(el.scrollLeft > 4);
    setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
  }, []);

  React.useEffect(() => {
    updateScrollState();
    const el = scrollRef.current;
    if (!el) return;
    el.addEventListener("scroll", updateScrollState, { passive: true });
    window.addEventListener("resize", updateScrollState);
    return () => {
      el.removeEventListener("scroll", updateScrollState);
      window.removeEventListener("resize", updateScrollState);
    };
  }, [updateScrollState]);

  const scrollBy = (dir) => {
    const el = scrollRef.current;
    if (!el) return;
    // 7 chips fit in the viewport — jump by the same so the next "page" lands cleanly
    el.scrollBy({ left: dir * (76 + 4) * 8, behavior: "smooth" });
  };

  // Mouse drag-to-scroll. Touch / trackpad already work natively via
  // overflow-x:auto; this just adds click-and-drag for desktop mouse users.
  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    let down = false,startX = 0,startLeft = 0,moved = 0,pid = 0;

    const onDown = (e) => {
      // skip touch (native flick), and skip middle/right buttons
      if (e.pointerType === "touch" || e.button !== 0) return;
      down = true;moved = 0;
      startX = e.clientX;startLeft = el.scrollLeft;pid = e.pointerId;
      el.style.scrollSnapType = "none";
    };
    const onMove = (e) => {
      if (!down) return;
      const dx = e.clientX - startX;
      moved = Math.max(moved, Math.abs(dx));
      // Only enter "dragging" mode (which disables chip pointer-events) once we
      // know the user is actually dragging, not just pressing down to click.
      if (moved > 4) {
        el.classList.add("dragging");
        if (el.hasPointerCapture && !el.hasPointerCapture(pid)) {
          try {el.setPointerCapture(pid);} catch (_) {}
        }
      }
      el.scrollLeft = startLeft - dx;
    };
    const onUp = () => {
      if (!down) return;
      down = false;
      el.classList.remove("dragging");
      el.style.scrollSnapType = "";
      try {el.releasePointerCapture(pid);} catch (_) {}
    };
    // Suppress chip clicks if the pointer actually moved.
    const onClickCapture = (e) => {
      if (moved > 5) {e.stopPropagation();e.preventDefault();moved = 0;}
    };

    el.addEventListener("pointerdown", onDown);
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerup", onUp);
    el.addEventListener("pointercancel", onUp);
    el.addEventListener("click", onClickCapture, true);
    return () => {
      el.removeEventListener("pointerdown", onDown);
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerup", onUp);
      el.removeEventListener("pointercancel", onUp);
      el.removeEventListener("click", onClickCapture, true);
    };
  }, []);

  return (
    <section className="filter-row filter-row--wide">
      {/* 전체 — pinned */}
      <button
        className={"fchip pinned " + (allSelected ? "active" : "")}
        onClick={selectAll}>
        
        <div className="gico all">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <rect x="3" y="3" width="7" height="7" rx="1.5" />
            <rect x="14" y="3" width="7" height="7" rx="1.5" />
            <rect x="3" y="14" width="7" height="7" rx="1.5" />
            <rect x="14" y="14" width="7" height="7" rx="1.5" />
          </svg>
        </div>
        <span className="fname">전체</span>
      </button>

      <div className="filter-divider"></div>

      <button
        className="filter-nav"
        onClick={() => scrollBy(-1)}
        disabled={!canLeft}
        aria-label="이전 게임">
        
        <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M11 4 6 9l5 5" /></svg>
      </button>

      <div className="filter-viewport">
        <div className="filter-scroll" ref={scrollRef}>
          {onToggleRelease && (
            <button className={"fchip fchip--release " + (showRelease ? "active" : "")} onClick={onToggleRelease} title="신작/CBT 모아보기">
              <div className="gico fchip-new-gico" style={{ background: "#163d2b", color: "#6af7a0" }}>NEW</div>
              <span className="fname">신작</span>
            </button>
          )}
          {GACHA_GAMES.map((g) =>
          <button
            key={g.id}
            className={"fchip " + (!allSelected && selected.has(g.id) ? "active" : "")}
            onClick={() => toggle(g.id)}
            title={g.name}>

              <div className="gico" style={{ "--gc": g.color, background: g.color }}><img src={"images/games/" + g.id + ".webp"} alt={g.name} /></div>
              <span className="fname">{g.name}</span>
            </button>
          )}
        </div>
      </div>

      <button
        className="filter-nav"
        onClick={() => scrollBy(1)}
        disabled={!canRight}
        aria-label="다음 게임">
        
        <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M7 4l5 5-5 5" /></svg>
      </button>

      <div className="filter-active-count">
        {allSelected ? `${GACHA_GAMES.length}개 모두` : `${selected.size + (showRelease ? 1 : 0)}개 선택`}
      </div>

      {rightSlot && <><div className="filter-divider"></div>{rightSlot}</>}
    </section>
  );
}

// ============== Calendar grid ==============
function buildMonthCells(year, month, weekStart = 0) {
  const first = new Date(year, month, 1);
  const startDow = (first.getDay() - weekStart + 7) % 7;
  const lastDate = new Date(year, month + 1, 0).getDate();
  // start from previous Sunday/Monday
  const cells = [];
  for (let i = 0; i < startDow; i++) {
    const date = new Date(year, month, 1 - (startDow - i));
    cells.push({ date, dim: true });
  }
  for (let d = 1; d <= lastDate; d++) {
    cells.push({ date: new Date(year, month, d), dim: false });
  }
  while (cells.length % 7 !== 0 || cells.length < 35) {
    const last = cells[cells.length - 1].date;
    const next = new Date(last);next.setDate(last.getDate() + 1);
    cells.push({ date: next, dim: true });
    if (cells.length >= 42) break;
  }
  return cells;
}

function Calendar({ year, month, events, onPick, selectedKey, weekStart }) {
  const cells = useMemo(() => buildMonthCells(year, month, weekStart), [year, month, weekStart]);
  const headLabels = weekStart === 1 ?
  ["월", "화", "수", "목", "금", "토", "일"] :
  ["일", "월", "화", "수", "목", "금", "토"];
  const headColor = (i) => {
    if (weekStart === 1) return i === 5 ? "#6ab4f7" : i === 6 ? "#e25c5c" : null;
    return i === 0 ? "#e25c5c" : i === 6 ? "#6ab4f7" : null;
  };
  const byDay = useMemo(() => {
    const m = {};
    events.forEach((e) => {
      (m[e.start] = m[e.start] || []).push(e);
    });
    return m;
  }, [events]);
  const todayKey = ymd(TODAY);

  return (
    <div className="cal-wrap">
      <div className="cal-head">
        {headLabels.map((l, i) =>
        <div key={l} style={headColor(i) ? { color: headColor(i) } : null}>{l}</div>
        )}
      </div>
      <div className="cal-grid">
        {cells.map((c, i) => {
          const key = ymd(c.date);
          const dow = c.date.getDay();
          const evts = byDay[key] || [];
          const visible = evts.slice(0, 3);
          const overflow = evts.length - visible.length;
          const classes = ["cell"];
          if (c.dim) classes.push("dim");
          if (dow === 0) classes.push("sun");
          if (dow === 6) classes.push("sat");
          if (key === todayKey) classes.push("today");
          if (key === selectedKey) classes.push("selected");
          return (
            <div key={i} className={classes.join(" ")}>
              <div className="cnum-wrap">
                {key === todayKey ?
                <span className="cnum-bubble"><span className="cnum">{c.date.getDate()}</span></span> :

                <span className="cnum">{c.date.getDate()}</span>
                }
              </div>
              <div className="evt-list">
                {visible.map((e) => {
                  const cat = CAT_BY_ID[e.cat];
                  const game = GAME_BY_ID[e.game];
                  const isOffline = e.cat === "offline";
                  return (
                    <button
                      key={e.id}
                      className={"evt" + (isOffline ? " evt--offline" : "")}
                      style={{ "--c": game.color, "--gc": game.color }}
                      onClick={() => onPick(e)}
                      title={`[${cat.tag}] ${game.name}${e.loc ? " @" + e.loc : ""} — ${e.title}`}>

                      <span className="evt-dot" style={{ background: game.color }}>
                        {game.noImg
                          ? <span style={{ fontSize: 6, fontWeight: 800, fontFamily: "Oxanium,monospace", color: "#163d2b", lineHeight: 1 }}>{game.short}</span>
                          : <img src={"images/games/" + game.id + ".webp"} alt={game.name} />}
                      </span>
                      <span className="evt-title">{e.title}</span>
                    </button>);

                })}
                {overflow > 0 &&
                  <button className="evt-more" onClick={() => onPick({ _multi: key, list: evts })}>
                    ···
                  </button>
                }
              </div>
            </div>);

        })}
      </div>
    </div>);

}

// ============== Anniversary Sidebar ==============
function AnniversarySidebar() {
  const todayStr = ymd(TODAY);

  const items = GACHA_GAMES
    .filter((g) => g.launch)
    .flatMap((g) => {
      const launch = parseISO(g.launch);
      const halfBase = new Date(launch.getFullYear(), launch.getMonth() + 6, launch.getDate());
      const slots = [
        { month: launch.getMonth(), day: launch.getDate() },
        { month: halfBase.getMonth(), day: halfBase.getDate() },
      ];
      return slots.flatMap(({ month, day }) => {
        const yr = TODAY.getFullYear();
        let anniv = new Date(yr, month, day);
        if (anniv < TODAY) anniv = new Date(yr + 1, month, day);
        const dd = daysBetween(todayStr, ymd(anniv));
        if (dd > 213) return [];
        const months = (anniv.getFullYear() - launch.getFullYear()) * 12 + (anniv.getMonth() - launch.getMonth());
        const nth = months / 12;
        return [{ game: g, anniv, nth, dd }];
      });
    })
    .sort((a, b) => a.anniv - b.anniv);

  return (
    <aside className="upcoming">
      <h3><span>게임 주년</span></h3>
      <div className="up-list">
        {items.length === 0 && (
          <div style={{ padding: "16px 14px", color: "var(--text-mute)", fontSize: 13 }}>6개월 내 주년 없음</div>
        )}
        {items.map(({ game, anniv, nth, dd }) => (
          <div className="anniv-item" key={`${game.id}-${nth}`} style={{ "--c": game.color }}>
            <div className="anniv-ico">
              <div className="gico" style={{ width: 30, height: 30, borderRadius: 8, fontSize: 9, background: game.color, flex: "0 0 auto" }}>
                <img src={"images/games/" + game.id + ".webp"} alt={game.name} />
              </div>
            </div>
            <div className="up-bar" style={{ height: 30 }}></div>
            <div className="up-body">
              <div className="up-title">{game.name}</div>
              <div className="up-meta">
                <span className="up-tag">{nth}주년</span>
                <span className="up-game">{anniv.getMonth() + 1}/{anniv.getDate()}</span>
                <span className="up-dday">D-{dd === 0 ? "DAY" : dd}</span>
              </div>
            </div>
          </div>
        ))}
      </div>
    </aside>
  );
}


// ============== New Releases Sidebar ==============
function NewReleasesSidebar() {
  const releases = GACHA_EVENTS
    .filter((e) => e.cat === "release" || e.cat === "cbt")
    .sort((a, b) => parseISO(a.start) - parseISO(b.start));
  return (
    <div className="releases-sidebar">
      <div className="sidebar-heading">신작 · CBT</div>

      <div className="releases-list">
        {releases.map((e) => {
          const game = GAME_BY_ID[e.game];
          const isPast = parseISO(e.start) < TODAY;
          const dd = daysBetween(ymd(TODAY), e.start);
          return (
            <div key={e.id} className={"release-item" + (isPast ? " past" : "")} style={{ "--gc": game.color }}>
              <div className="gico" style={{ width: 32, height: 32, borderRadius: 9, fontSize: 10, background: game.color, flex: "0 0 auto" }}>{game.noImg ? <span style={{ fontSize: 8, fontWeight: 800, fontFamily: "Oxanium,monospace", color: "#163d2b" }}>{game.short}</span> : <img src={"images/games/" + game.id + ".webp"} alt={game.name} />}</div>
              <div className="release-body">
                <div className="release-title">{e.title}</div>
                <div className="release-meta">
                  <span className="mono">{e.start}</span>
                  {!isPast && <span className="release-dday">D-{dd === 0 ? "DAY" : dd}</span>}
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ============== Sidebar Right ==============
// ============== 제보/건의/부스 모달 ==============
const EVENT_TYPES  = () => getConfig().form_event_types  ?? ["픽업/가챠", "일반 이벤트", "오프라인 행사", "뉴스/업데이트", "기타"];
const SUGGEST_CATS = () => getConfig().form_suggest_cats ?? ["기능 개선", "버그 신고", "게임 추가 요청", "디자인", "기타"];

function FormModal({ title, icon, onClose, children, onSubmit, submitted, sidebar, submitting, error, doneTitle, doneDesc, doneImg, headNote }) {
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  // 제출 완료 → 별도 작은 카드 (마스크 클릭 닫기 없음 — 확인 버튼으로만 닫힘)
  if (submitted) {
    return (
      <div className="fm-mask">
        <div className="fm-done">
          {doneImg
            ? <img src={doneImg} alt="" className="fm-done-img" />
            : <svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="var(--cat-release)" strokeWidth="1.8" strokeLinecap="round">
                <circle cx="12" cy="12" r="10"/>
                <path d="M7.5 12l3 3 5.5-5.5"/>
              </svg>
          }
          <p className="fm-done-title">{doneTitle ?? "제출 완료!"}</p>
          <p className="fm-done-desc" dangerouslySetInnerHTML={{__html: doneDesc ?? "감사합니다 😊<br/>검토 후 반영됩니다."}} />
          <button className="fm-btn fm-btn--submit fm-done-btn" onClick={onClose}>확인</button>
        </div>
      </div>
    );
  }

  return (
    <div className="fm-mask" onClick={onClose}>
      <div className={"fm" + (sidebar ? " fm--split" : "")} onClick={(e) => e.stopPropagation()}>
        <div className="fm-head">
          <span className="fm-head-icon">{icon}</span>
          <span className="fm-head-title-wrap">
            <span className="fm-head-title">{title}</span>
            {error && <span className="fm-head-err">{error}</span>}
          </span>
          {headNote && <span className="fm-head-note">{headNote}</span>}
          <button className="fm-close" onClick={onClose}>
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M3 3l10 10M13 3L3 13"/></svg>
          </button>
        </div>
        <div className="fm-split-wrap">
          <form className="fm-body" onSubmit={onSubmit}>
            {children}
            <div className="fm-actions">
              <button type="button" className="fm-btn fm-btn--cancel" onClick={onClose}>취소</button>
              <button type="submit" className="fm-btn fm-btn--submit" disabled={submitting}>
                {submitting ? "업로드 중…" : "제출"}
              </button>
            </div>
          </form>
          {sidebar && <div className="fm-sidebar">{sidebar}</div>}
        </div>
      </div>
    </div>
  );
}

function ScheduleReportModal({ onClose }) {
  const [form, setForm] = React.useState({ game: "", type: EVENT_TYPES()[1] ?? "일반 이벤트", title: "", start: "", end: "", url: "", memo: "" });
  const [submitted, setSubmitted] = React.useState(false);
  const [gameErr, setGameErr] = React.useState(false);
  const set = (k, v) => setForm((p) => ({ ...p, [k]: v }));
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!form.game) { setGameErr(true); return; }
    if (window.KV) KV.post('reports', form).catch((err) => console.warn('[일정 제보]', err));
    setSubmitted(true);
  };
  return (
    <FormModal title="일정 제보" icon={<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="1.5" y="2.5" width="13" height="12" rx="1.5"/><path d="M1.5 6.5h13M5.5 1v3M10.5 1v3"/></svg>} onClose={onClose} onSubmit={handleSubmit} submitted={submitted}
      doneTitle="제보 감사합니다! 📅"
      doneDesc="제보해주신 일정을 확인한 뒤<br/>반영 가능한 내용은 업데이트하겠습니다."
      doneImg="images/emoji/thank_herta.png">

      {/* ── 필수 입력 ── */}
      <div className="fm-section">필수 입력</div>

      <div className="fm-field">
        <label className="fm-label">게임 <span className="fm-req">*</span></label>
        <div className={"fm-chip-row" + (gameErr ? " fm-chip-row--err" : "")}>
          {GACHA_GAMES.map((g) => (
            <button key={g.id} type="button"
              className={"fm-chip fm-chip--icon" + (form.game === g.id ? " active" : "")}
              style={{ "--fc": g.color }}
              title={g.name}
              onClick={() => { set("game", form.game === g.id ? "" : g.id); setGameErr(false); }}>
              <span className="gico fm-chip-ico" style={{ background: g.color }}><img src={"images/games/" + g.id + ".webp"} alt={g.name} /></span>
            </button>
          ))}
        </div>
        {gameErr && <p className="fm-err-msg">게임을 선택해주세요.</p>}
      </div>

      <div className="fm-row">
        <div className="fm-field fm-field--grow">
          <label className="fm-label">이벤트 종류 <span className="fm-req">*</span></label>
          <select className="fm-select" value={form.type} onChange={(e) => set("type", e.target.value)}>
            {EVENT_TYPES().map((t) => <option key={t}>{t}</option>)}
          </select>
        </div>
      </div>

      <div className="fm-field">
        <label className="fm-label">제목 <span className="fm-req">*</span></label>
        <input className="fm-input" type="text" placeholder="이벤트 이름을 입력하세요" value={form.title} onChange={(e) => set("title", e.target.value)} required />
      </div>

      <div className="fm-row">
        <div className="fm-field fm-field--grow">
          <label className="fm-label">시작일 <span className="fm-req">*</span></label>
          <input className="fm-input" type="date" value={form.start} onChange={(e) => set("start", e.target.value)} required />
        </div>
        <div className="fm-field fm-field--grow">
          <label className="fm-label">종료일</label>
          <input className="fm-input" type="date" value={form.end} onChange={(e) => set("end", e.target.value)} />
        </div>
      </div>

      {/* ── 선택 입력 ── */}
      <div className="fm-section fm-section--opt">선택 입력</div>

      <div className="fm-field">
        <label className="fm-label">관련 URL</label>
        <input className="fm-input" type="url" placeholder="https://" value={form.url} onChange={(e) => set("url", e.target.value)} />
      </div>
      <div className="fm-field">
        <label className="fm-label">메모</label>
        <textarea className="fm-textarea" rows="2" placeholder="출처 링크가 있으면 빠르게 등록 가능합니다." value={form.memo} onChange={(e) => set("memo", e.target.value)} />
      </div>
    </FormModal>
  );
}

function SuggestionModal({ onClose }) {
  const [form, setForm] = React.useState({ cat: SUGGEST_CATS()[0] ?? "기능 개선", title: "", content: "" });
  const [submitted, setSubmitted] = React.useState(false);
  const set = (k, v) => setForm((p) => ({ ...p, [k]: v }));
  const handleSubmit = (e) => {
    e.preventDefault();
    if (window.KV) KV.post('suggestions', form).catch((err) => console.warn('[건의 사항]', err));
    setSubmitted(true);
  };
  return (
    <FormModal title="건의 사항" icon={<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M14 2H2a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h3l3 3 3-3h3a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 14 2z"/></svg>} onClose={onClose} onSubmit={handleSubmit} submitted={submitted}
      doneTitle="의견 감사합니다! 💬"
      doneDesc="남겨주신 의견은 꼼꼼히 확인하고<br/>더 나은 서비스가 되도록 참고하겠습니다."
      doneImg="images/emoji/good_herta.png">

      {/* 카테고리 + 제목 + 내용 모두 필수 */}
      <div className="fm-field">
        <label className="fm-label">카테고리 <span className="fm-req">*</span></label>
        <div className="fm-seg">
          {SUGGEST_CATS().map((c) => (
            <button key={c} type="button" className={"fm-seg-btn" + (form.cat === c ? " active" : "")} onClick={() => set("cat", c)}>{c}</button>
          ))}
        </div>
      </div>
      <div className="fm-field">
        <label className="fm-label">제목 <span className="fm-req">*</span></label>
        <input className="fm-input" type="text" placeholder="건의 제목을 입력하세요" value={form.title} onChange={(e) => set("title", e.target.value)} required />
      </div>
      <div className="fm-field">
        <label className="fm-label">내용 <span className="fm-req">*</span></label>
        <textarea className="fm-textarea" rows="5" placeholder="자세한 내용을 적어주세요" value={form.content} onChange={(e) => set("content", e.target.value)} required />
      </div>
    </FormModal>
  );
}

function ImgUploadField({ label, hint, value, onChange }) {
  const inputRef = React.useRef();
  const handleFile = (file) => {
    if (!file || !file.type.startsWith("image/")) return;
    const reader = new FileReader();
    reader.onload = (e) => onChange({ file, preview: e.target.result });
    reader.readAsDataURL(file);
  };
  const onDrop = (e) => { e.preventDefault(); handleFile(e.dataTransfer.files[0]); };
  return (
    <div className="fm-field">
      <label className="fm-label">
        {label}
        {hint && <span className="fm-hint-inline">[ 권장 : {hint} ]</span>}
      </label>
      <div className={"fm-img-drop" + (value ? " fm-img-drop--filled" : "")}
        onClick={() => inputRef.current.click()}
        onDragOver={(e) => e.preventDefault()}
        onDrop={onDrop}>
        {value
          ? <><img className="fm-img-preview" src={value.preview} alt="preview" /><span className="fm-img-change">클릭하여 변경</span></>
          : <><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15l5-5 4 4 3-3 6 6"/><circle cx="8.5" cy="8.5" r="1.5"/></svg><span>클릭하거나 이미지를 드래그하세요</span></>
        }
      </div>
      <input ref={inputRef} type="file" accept="image/*" style={{ display: "none" }}
        onChange={(e) => handleFile(e.target.files[0])} />
    </div>
  );
}

function BoothRegisterModal({ onClose }) {
  const { user } = React.useContext(UserCtx);
  const [form, setForm] = React.useState({ event: FAN_EVENTS[0]?.id ?? "", circle: "", artist: "", games: new Set(), boothNo: "", prepayUrl: "", desc: "", note: "", twitter: "", pixivUrl: "", pixivName: "" });
  const [cardImg, setCardImg] = React.useState(null);
  const [popImg, setPopImg] = React.useState(null);
  const [submitted, setSubmitted] = React.useState(false);
  const [storageFull, setStorageFull] = React.useState(false);
  const [submitting, setSubmitting] = React.useState(false);
  const [submitErr, setSubmitErr] = React.useState("");
  const [gameErr, setGameErr] = React.useState(false);
  const set = (k, v) => setForm((p) => ({ ...p, [k]: v }));
  const toggleGame = (id) => {
    setGameErr(false);
    setForm((p) => { const next = new Set(p.games); next.has(id) ? next.delete(id) : next.add(id); return { ...p, games: next }; });
  };

  const uploadImg = async (imgData, label) => {
    if (!imgData?.file) return null;
    if (!window.KV || !user?.credential) return null;
    const ext = (imgData.file.type || 'image/jpeg').split('/')[1]?.replace('jpeg', 'jpg') || 'jpg';
    const circle = (form.circle || 'booth').replace(/[\/\\:*?"<>|]/g, '_');
    const filename = `${circle}_${label}.${ext}`;
    const res = await KV.upload(imgData.file, user.credential, filename);
    return res.url ?? null;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (form.games.size === 0) { setGameErr(true); return; }
    if (!cardImg) { setSubmitErr('썸네일 이미지를 등록해주세요.'); return; }
    if (!popImg)  { setSubmitErr('상세 이미지를 등록해주세요.'); return; }
    if (!form.note.trim()) { setSubmitErr('상세 설명을 입력해주세요.'); return; }
    setSubmitting(true);
    setSubmitErr("");
    try {
      // 순차 업로드: 동시 실행 시 r2_used_bytes race condition 발생
      const cardUrl = await uploadImg(cardImg, 'Thumbnail');
      const popUrl  = await uploadImg(popImg,  'Popup');
      const payload = { ...form, games: [...form.games], cardImg: cardUrl, popImg: popUrl };
      if (window.KV) await KV.register(payload, user.credential);
      setSubmitted(true);
    } catch (err) {
      console.warn('[부스 등록]', err);
      const msg = err.message || '';
      const isFull = msg.includes('저장 공간') || msg.includes('413');
      if (isFull) setStorageFull(true);
      else setSubmitErr('업로드에 실패했습니다. 다시 시도해주세요.');
    } finally {
      setSubmitting(false);
    }
  };
  if (storageFull) {
    return (
      <div className="fm-mask">
        <div className="fm-done">
          <img src="images/emoji/sad_herta.png" alt="" className="fm-done-img" />
          <p className="fm-done-title">부스 등록에 실패하였어요 😔</p>
          <p className="fm-done-desc">현재 요청이 많아 부스 등록을<br/>잠시 중단하고 있습니다.</p>
          <button className="fm-btn fm-btn--submit fm-done-btn" onClick={onClose}>확인</button>
        </div>
      </div>
    );
  }

  return (
    <FormModal
      title="부스 등록"
      icon={<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="1" y="7" width="14" height="8" rx="1"/><path d="M1 7l2-5h10l2 5"/><path d="M8 11v-2m-2 2v-2m4 2v-2"/></svg>}
      onClose={onClose} onSubmit={handleSubmit} submitted={submitted} submitting={submitting} error={submitErr}
      doneTitle="신청이 접수됐어요! 🎉"
      doneDesc="신청 내용을 검토한 뒤<br/>로그인 하신 이메일로 안내해 드릴게요."
      doneImg="images/emoji/hamburger_herta.png"
      headNote="등록 결과는 이메일로 발송됩니다."
      sidebar={
        <div className="fm-game-sidebar">
          <div className={"fm-game-sidebar-title" + (gameErr ? " fm-game-sidebar-title--err" : "")}>
            취급 게임 <span className="fm-req">*</span>
            {gameErr && <span className="fm-err-msg" style={{display:"block",fontWeight:400,marginTop:4}}>1개 이상 선택해주세요.</span>}
          </div>
          <div className="fm-game-sidebar-list">
            {GACHA_GAMES.map((g) => (
              <button key={g.id} type="button"
                className={"fm-game-sidebar-btn" + (form.games.has(g.id) ? " active" : "")}
                style={{ "--fc": g.color }}
                title={g.name}
                onClick={() => toggleGame(g.id)}>
                <span className="gico" style={{ width: 30, height: 30, borderRadius: 8, background: g.color, flex: "0 0 auto" }}>
                  <img src={"images/games/" + g.id + ".webp"} alt={g.name} />
                </span>
                <span className="fm-game-sidebar-name">{g.name}</span>
              </button>
            ))}
          </div>
        </div>
      }>

      {/* ── 필수 입력 ── */}
      <div className="fm-section">필수 입력</div>

      <div className="fm-field">
        <label className="fm-label">행사 <span className="fm-req">*</span></label>
        <select className="fm-select" value={form.event} onChange={(e) => set("event", e.target.value)} required>
          {FAN_EVENTS.map((fe) => <option key={fe.id} value={fe.id}>{fe.name} ({fanDateRange(fe)})</option>)}
        </select>
      </div>
      <div className="fm-row">
        <div className="fm-field fm-field--grow">
          <label className="fm-label">서클명 <span className="fm-req">*</span></label>
          <input className="fm-input" type="text" placeholder="서클 이름" value={form.circle} onChange={(e) => set("circle", e.target.value)} required />
        </div>
        <div className="fm-field fm-field--grow">
          <label className="fm-label">작가명</label>
          <input className="fm-input" type="text" placeholder="활동명 (없으면 비워두세요)" value={form.artist} onChange={(e) => set("artist", e.target.value)} />
        </div>
      </div>
      <div className="fm-field">
        <label className="fm-label" style={{ display: "flex", justifyContent: "space-between" }}>
          <span>부스 설명 <span className="fm-req">*</span> <span className="fm-hint-inline">[ 최대 40자 · 목록에 표시 ]</span></span>
          <span className={"fm-char-count" + (form.desc.length >= 40 ? " fm-char-count--over" : "")}>{form.desc.length}/40</span>
        </label>
        <input className="fm-input" type="text" maxLength={40} placeholder="취급 굿즈, 특이사항 등 한 줄 요약" value={form.desc} onChange={(e) => set("desc", e.target.value)} required />
      </div>
      <div className="fm-row">
        <ImgUploadField label="썸네일 *" hint="800×600 (4:3)" value={cardImg} onChange={setCardImg} />
        <ImgUploadField label="상세 이미지 *" hint="1280×720 (16:9)" value={popImg} onChange={setPopImg} />
      </div>
      <div className="fm-field">
        <label className="fm-label">상세 설명 <span className="fm-req">*</span></label>
        <textarea className="fm-textarea" rows="3" placeholder="굿즈 목록, 선입금 안내, 현장 이벤트 등" value={form.note} onChange={(e) => set("note", e.target.value)} required />
      </div>

      {/* ── 선택 입력 ── */}
      <div className="fm-section fm-section--opt">선택 입력</div>

      <div className="fm-row">
        <div className="fm-field fm-field--grow">
          <label className="fm-label">부스 번호</label>
          <input className="fm-input" type="text" placeholder="예) A-01" value={form.boothNo} onChange={(e) => set("boothNo", e.target.value)} />
        </div>
        <div className="fm-field fm-field--grow">
          <label className="fm-label">Twitter / X</label>
          <input className="fm-input" type="text" placeholder="@없이 아이디만" value={form.twitter} onChange={(e) => set("twitter", e.target.value)} />
        </div>
        <div className="fm-field fm-field--grow">
          <label className="fm-label">Pixiv 표시명</label>
          <input className="fm-input" type="text" placeholder="표시될 이름" value={form.pixivName} onChange={(e) => set("pixivName", e.target.value)} />
        </div>
      </div>
      <div className="fm-field">
        <label className="fm-label">Pixiv URL</label>
        <input className="fm-input" type="url" placeholder="https://www.pixiv.net/users/12345678" value={form.pixivUrl} onChange={(e) => set("pixivUrl", e.target.value)} />
      </div>
      <div className="fm-field">
        <label className="fm-label">선입금 폼 링크</label>
        <input className="fm-input" type="url" placeholder="https://" value={form.prepayUrl} onChange={(e) => set("prepayUrl", e.target.value)} />
      </div>
    </FormModal>
  );
}

const KAKAO_ACCOUNT = () => getConfig().kakao?.account ?? "3333-36-6584967";
const KAKAO_NAME    = () => getConfig().kakao?.name    ?? "이재현";

function DonationBanner() {
  const [open, setOpen] = React.useState(false);
  const [copied, setCopied] = React.useState(false);

  const copyAccount = (e) => {
    e.stopPropagation();
    navigator.clipboard.writeText(KAKAO_ACCOUNT()).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    });
  };

  return (
    <div className={"side-card" + (open ? " side-card--open" : "")} onClick={() => setOpen(o => !o)} title={open ? "닫기" : "계좌 보기"}>
      <img className="side-card-img side-card-img--light" src="images/banner/side-card-light.webp" alt="후원" />
      <img className="side-card-img side-card-img--dark" src="images/banner/side-card-dark.webp" alt="후원" />
      <div className="side-card-account">
        <span className="sca-bank">카카오뱅크</span>
        <button className={"sca-number" + (copied ? " sca-number--copied" : "")} onClick={copyAccount} title="클릭하여 복사">
          {copied ? "복사됨 ✓" : KAKAO_ACCOUNT()}
        </button>
        <span className="sca-name">{KAKAO_NAME()}</span>
      </div>
    </div>
  );
}

function SidebarRight() {
  const [modal, setModal] = React.useState(null); // null | "schedule" | "suggestion" | "booth"
  const { user, openLogin } = React.useContext(UserCtx);
  const open = (key) => { if (!user) { openLogin(); return; } setModal(key); };
  return (
    <aside className="sidebar-right">
      <DonationBanner />
      <div className="sidebar-actions">
        <button className="sidebar-action-btn" onClick={() => open("schedule")}>
          <svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="1.5" y="2.5" width="13" height="12" rx="1.5"/><path d="M1.5 6.5h13M5.5 1v3M10.5 1v3"/></svg>
          일정 제보
        </button>
        <button className="sidebar-action-btn" onClick={() => open("suggestion")}>
          <svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M14 2H2a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h3l3 3 3-3h3a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 14 2z"/></svg>
          건의 사항
        </button>
        <button className="sidebar-action-btn" onClick={() => open("booth")}>
          <svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="1" y="7" width="14" height="8" rx="1"/><path d="M1 7l2-5h10l2 5"/><path d="M8 11v-2m-2 2v-2m4 2v-2"/></svg>
          부스 등록
        </button>
      </div>
      {modal === "schedule"   && <ScheduleReportModal onClose={() => setModal(null)} />}
      {modal === "suggestion" && <SuggestionModal     onClose={() => setModal(null)} />}
      {modal === "booth"      && <BoothRegisterModal  onClose={() => setModal(null)} />}
    </aside>
  );
}

// ============== Fan Booth Card ==============
function BoothCard({ booth, onPick, isBookmarked, onBookmark, likesOverride }) {
  const gameObjs = booth.games.map((gid) => GAME_BY_ID[gid]).filter(Boolean);
  const accentColor = gameObjs[0]?.color ?? "#7c6af7";
  const fanEvent = FAN_EVENT_BY_ID[booth.event];
  const gradStyle = {
    background: `linear-gradient(135deg, ${accentColor}33 0%, ${accentColor}11 100%)`,
  };
  return (
    <div className="fbc" onClick={() => onPick && onPick(booth)}>
      <div className="fbc-img" style={booth.img ? undefined : gradStyle}>
        {booth.img
          ? <img src={booth.img} alt={booth.circle} />
          : <div className="fbc-placeholder">
              {gameObjs.map((g) => (
                <span key={g.id} className="gico fbc-ph-icon" style={{ background: g.color }}>
                  <img src={"images/games/" + g.id + ".webp"} alt={g.name} />
                </span>
              ))}
            </div>
        }
      </div>
      <div className="fbc-body">
        <div className="fbc-head">
          <span className="fbc-circle">{booth.circle}</span>
          <span className="fbc-artist">{booth.artist}</span>
          <button className={"fbc-like-btn" + (isBookmarked ? " active" : "")}
            onClick={(e) => { e.stopPropagation(); onBookmark && onBookmark(booth.id); }}
            title={isBookmarked ? "좋아요 취소" : "좋아요"}>
            <svg width="12" height="12" viewBox="0 0 16 16" fill={isBookmarked ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M8 13.5S2 9.5 2 5.5a3.5 3.5 0 0 1 6-2.45A3.5 3.5 0 0 1 14 5.5c0 4-6 8-6 8z"/></svg>
            <span className="fbc-like-count">{likesOverride ?? booth.likes ?? 0}</span>
          </button>
        </div>
        {fanEvent && (
          <div className="fbc-event-date mono">{fanDateRange(fanEvent)} · {fanEvent.name}</div>
        )}
        {booth.desc && (
          <div className="fbc-games">
            <span className="fbc-desc-inline">{booth.desc}</span>
          </div>
        )}
        <div className="fbc-links">
          {booth.prepayUrl && (
            <a className="fbc-prepay" href={booth.prepayUrl} target="_blank" rel="noreferrer"
              onClick={(e) => e.stopPropagation()}>
              <svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="1" y="3" width="14" height="10" rx="1.5"/><path d="M1 6.5h14"/><path d="M4 10h2"/></svg>
              선입금 폼
            </a>
          )}
          {booth.twitter && (
            <span className="booth-link">
              <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.259 5.631L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
              @{booth.twitter}
            </span>
          )}
          {(booth.pixivName || booth.pixivUrl) && (
            <span className="booth-link">
              <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M4.935 0A4.924 4.924 0 0 0 0 4.935v14.13A4.924 4.924 0 0 0 4.935 24H19.06A4.924 4.924 0 0 0 24 19.065V4.935A4.924 4.924 0 0 0 19.065 0zm7.81 4.547c2.181 0 4.058.676 5.399 1.847a6.118 6.118 0 0 1 2.116 4.66c.005 4.418-3.318 6.714-7.343 6.747h-2.07c-.427 0-.721.255-.787.683l-.525 3.305c-.072.43-.349.633-.735.633H6.546a.44.44 0 0 1-.427-.544l2.619-16.553c.066-.38.306-.578.658-.578z"/></svg>
              {booth.pixivName || booth.pixivUrl}
            </span>
          )}
          <div className="fbc-game-icons">
            {gameObjs.map((g) => (
              <span key={g.id} className="gico" style={{ width: 18, height: 18, borderRadius: 4, background: g.color, flex: "0 0 auto" }}>
                <img src={"images/games/" + g.id + ".webp"} alt={g.name} />
              </span>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

function BoothList({ booths, onPick, bookmarks, onBookmark, likeOverrides }) {
  if (booths.length === 0) return <div className="booth-empty">조건에 맞는 부스가 없습니다.</div>;
  return (
    <div className="fan-booth-grid">
      {booths.map((booth) => (
        <BoothCard key={booth.id} booth={booth} onPick={onPick}
          isBookmarked={bookmarks?.has(booth.id)} onBookmark={onBookmark}
          likesOverride={likeOverrides?.[booth.id]} />
      ))}
    </div>
  );
}

// ============== Event Booth Page ==============
const getBoothType = (title) => {
  if (/팝업/.test(title)) return "팝업스토어";
  if (/카페/.test(title)) return "콜라보 카페";
  if (/콘서트|OST/.test(title)) return "콘서트";
  if (/팬미팅/.test(title)) return "팬미팅";
  if (/체험/.test(title)) return "체험존";
  if (/상영/.test(title)) return "상영회";
  return "기타";
};

const BOOTH_SORTS = ["등록순", "최신순", "인기순"];

function EventBoothPage({ selected, toggle, selectAll, showRelease, onToggleRelease, dataVer }) {
  const allSelected = !showRelease && (selected.size === 0 || selected.size >= GACHA_GAMES.length);
  const [activeType, setActiveType] = useState("전체");
  const [activeFanEvent, setActiveFanEvent] = useState(null);
  const [pick, setPick] = useState(null);
  const [fanPick, setFanPick] = useState(null);
  const [boothSort, setBoothSort] = useState("등록순");
  const { user, openLogin } = React.useContext(UserCtx);
  const [bookmarks, setBookmarks] = useState(new Set());
  const [bookmarkOnly, setBookmarkOnly] = useState(false);
  // 낙관적 좋아요 카운트 오버라이드 { boothId: count }
  const [likeOverrides, setLikeOverrides] = useState({});

  // 로그인 시 users KV에서 북마크 복원, 로그아웃 시 초기화
  useEffect(() => {
    if (!user) { setBookmarks(new Set()); setBookmarkOnly(false); setLikeOverrides({}); return; }
    if (!window.KV) return;
    KV.get('users')
      .then(data => {
        const saved = data?.[user.email]?.bookmarks ?? [];
        setBookmarks(new Set(saved));
      })
      .catch(() => setBookmarks(new Set()));
  }, [user]);

  const toggleBookmark = (id) => {
    if (!user) { openLogin(); return; }
    const wasLiked = bookmarks.has(id);
    const action = wasLiked ? 'unlike' : 'like';

    // UI 즉시 반영
    setBookmarks((prev) => {
      const next = new Set(prev);
      wasLiked ? next.delete(id) : next.add(id);
      return next;
    });

    // 낙관적 카운트 업데이트
    setLikeOverrides((prev) => {
      const cur = prev[id] ?? FAN_BOOTHS.find(b => b.id === id)?.likes ?? 0;
      return { ...prev, [id]: Math.max(0, cur + (wasLiked ? -1 : 1)) };
    });

    // 서버 반영 — booth.likes + users[email].bookmarks 동시 업데이트
    if (window.KV && user.credential) {
      KV.like(id, action, user.credential)
        .then(res => {
          if (res?.ok && res.likes != null) {
            // FAN_BOOTHS 글로벌도 업데이트 → 로그아웃 후 likeOverrides가
            // 초기화되어도 booth.likes가 이미 서버 확정값을 가리킴
            const b = FAN_BOOTHS.find(b => b.id === id);
            if (b) b.likes = res.likes;
            setLikeOverrides(prev => ({ ...prev, [id]: res.likes }));
          }
        })
        .catch(err => console.warn('[like]', err));
    }
  };

  const allBooths = useMemo(() =>
    GACHA_EVENTS
      .filter((e) => e.cat === "offline")
      .map((e) => ({ ...e, boothType: getBoothType(e.title) }))
      .sort((a, b) => parseISO(a.start) - parseISO(b.start)),
  [dataVer]);
  const types = useMemo(() => ["전체", ...new Set(allBooths.map((e) => e.boothType))], [allBooths]);
  const typeCounts = useMemo(() => {
    const counts = { "전체": allBooths.length };
    allBooths.forEach((e) => { counts[e.boothType] = (counts[e.boothType] || 0) + 1; });
    return counts;
  }, [allBooths]);

  const filteredOfficial = useMemo(() => {
    let list = activeType === "전체" ? allBooths : allBooths.filter((e) => e.boothType === activeType);
    if (!allSelected) list = list.filter((e) => selected.has(e.game));
    return list;
  }, [allBooths, activeType, selected, allSelected]);

  const filteredFanBooths = useMemo(() => {
    if (!activeFanEvent) return [];
    let list = FAN_BOOTHS.filter((b) => b.event === activeFanEvent);
    if (!allSelected) list = list.filter((b) => b.games.some((g) => selected.has(g)));
    if (bookmarkOnly) list = list.filter((b) => bookmarks.has(b.id));
    if (boothSort === "최신순") return [...list].reverse();
    if (boothSort === "인기순") return [...list].sort((a, b) => {
      const la = (a.likes || 0) + (bookmarks.has(a.id) ? 1 : 0);
      const lb = (b.likes || 0) + (bookmarks.has(b.id) ? 1 : 0);
      return lb - la;
    });
    return list;
  }, [activeFanEvent, selected, allSelected, boothSort, bookmarkOnly, bookmarks, dataVer]);

  const fanEventInfo = activeFanEvent ? FAN_EVENTS.find((e) => e.id === activeFanEvent) : null;
  const selectOfficialType = (type) => { setActiveType(type); setActiveFanEvent(null); };
  const selectFanEvent = (id) => { setActiveFanEvent(id); setActiveType(null); };
  const selectSort = (s) => setBoothSort(s);


  return (
    <>
      <div className="main-body">
        <aside className="sidebar-left">
          <div className="booth-type-sidebar">
            <div className="sidebar-heading">공식 행사</div>
            <div className="booth-type-list">
              {types.map((type) => (
                <button key={type}
                  className={"booth-type-item" + (!activeFanEvent && activeType === type ? " active" : "")}
                  onClick={() => selectOfficialType(type)}>
                  <span className="booth-type-name">{type}</span>
                  <span className="booth-type-count">{typeCounts[type] || 0}</span>
                </button>
              ))}
            </div>
            <div className="sidebar-heading" style={{ marginTop: 8 }}>동인 행사 (2차 창작 부스)</div>
            <div className="booth-type-list">
              {FAN_EVENTS.map((fe) => (
                <button key={fe.id}
                  className={"booth-type-item" + (activeFanEvent === fe.id ? " active" : "")}
                  onClick={() => selectFanEvent(fe.id)}>
                  <span className="booth-type-name">{fe.name}</span>
                  <span className="fan-event-date mono">{fanDateShort(fe)}</span>
                </button>
              ))}
            </div>
          </div>
        </aside>
        <div className="main-center">
          <GameFilter selected={selected} toggle={toggle} selectAll={selectAll} allSelected={allSelected} showRelease={showRelease} onToggleRelease={onToggleRelease} />
          {activeFanEvent ? (
            <>
              {fanEventInfo && (
                <div className="fan-event-header">
                  <span className="fan-event-name">{fanEventInfo.name}</span>
                  <span className="fan-event-venue mono">{fanEventInfo.venue && `${fanEventInfo.venue} · `}{fanDateRange(fanEventInfo)}</span>
                  <div className="fan-event-controls">
                    <span className="fan-event-count">{filteredFanBooths.length}개 부스</span>
                    <div className="sort-selector">
                      {BOOTH_SORTS.map((s) => (
                        <button key={s} className={"sort-btn" + (boothSort === s ? " active" : "")} onClick={() => selectSort(s)}>{s}</button>
                      ))}
                      <button className={"sort-btn sort-btn--bookmark" + (bookmarkOnly ? " active" : "")} onClick={() => setBookmarkOnly(o => !o)}>
                        <svg width="12" height="12" viewBox="0 0 16 16" fill={bookmarkOnly ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M8 13.5S2 9.5 2 5.5a3.5 3.5 0 0 1 6-2.45A3.5 3.5 0 0 1 14 5.5c0 4-6 8-6 8z"/></svg>
                        좋아요
                      </button>
                    </div>
                  </div>
                </div>
              )}
              <BoothList booths={filteredFanBooths} onPick={setFanPick} bookmarks={bookmarks} onBookmark={toggleBookmark} likeOverrides={likeOverrides} />
            </>
          ) : (
            <>
            <div className="booth-grid">
              {filteredOfficial.map((event) => {
                const game = GAME_BY_ID[event.game];
                const isOngoing = parseISO(event.start) <= TODAY && (!event.end || parseISO(event.end) >= TODAY);
                const dd = event.end ? Math.max(0, daysBetween(ymd(TODAY), event.end)) : null;
                return (
                  <div key={event.id} className={"booth-card" + (event.url ? " booth-card--link" : "")} style={{ "--gc": game.color }} onClick={() => event.url ? window.open(event.url, "_blank", "noreferrer") : setPick(event)}>
                    <div className="booth-card-accent" />
                    <div className="booth-card-body">
                      <div className="booth-top">
                        <div className="gico" style={{ width: 40, height: 40, borderRadius: 12, background: game.color }}>
                          <img src={"images/games/" + game.id + ".webp"} alt={game.name} />
                        </div>
                        <div className="booth-info">
                          <div className="booth-game">{game.name}</div>
                          {isOngoing && <span className="booth-live">진행중</span>}
                        </div>
                        {dd !== null && <span className="booth-dday mono">D-{dd === 0 ? "DAY" : dd}</span>}
                        {event.url && <svg className="booth-ext-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>}
                      </div>
                      <div className="booth-title">{event.title}</div>
                      <div className="booth-meta">
                        {event.loc && (
                          <div className="booth-meta-row">
                            <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M8 1.5C5.5 1.5 3.5 3.5 3.5 6c0 3.5 4.5 8.5 4.5 8.5s4.5-5 4.5-8.5c0-2.5-2-4.5-4.5-4.5z"/><circle cx="8" cy="6" r="1.5"/></svg>
                            <span>{event.loc}</span>
                          </div>
                        )}
                        <div className="booth-meta-row">
                          <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="1.5" y="2.5" width="13" height="12" rx="1.5"/><path d="M1.5 6.5h13M5.5 1v3M10.5 1v3"/></svg>
                          <span className="mono">{event.start}{event.end ? ` → ${event.end}` : ""}</span>
                        </div>
                      </div>
                    </div>
                  </div>
                );
              })}
            </div>
            </>
          )}
        </div>
        <SidebarRight />
      </div>
      <Popover pick={pick} onClose={() => setPick(null)} onPick={setPick} />
      <FanBoothModal booth={fanPick} onClose={() => setFanPick(null)} />
    </>
  );
}

// ============== Image Lightbox ==============
function ImageLightbox({ src, onClose }) {
  if (!src) return null;
  return (
    <div className="img-lightbox" onClick={onClose}>
      <img src={src} alt="" onClick={(e) => e.stopPropagation()} />
      <button className="img-lightbox-close" onClick={onClose}>✕</button>
    </div>
  );
}

// YouTube video ID 추출 (full URL, short URL, bare ID 모두 지원)
function ytId(src) {
  if (!src) return null;
  const m = src.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/))([A-Za-z0-9_-]{11})/);
  if (m) return m[1];
  if (/^[A-Za-z0-9_-]{11}$/.test(src)) return src;
  return null;
}

// ============== Popover ==============
function Popover({ pick, onClose, onPick }) {
  if (!pick) return null;
  if (pick._multi) {
    return (
      <div className="popover-mask" onClick={onClose}>
        <div className="popover" onClick={(e) => e.stopPropagation()}>
          <button className="popover-close" onClick={onClose}>✕</button>
          <div className="popover-head" style={{ "--c": GAME_BY_ID[pick.list[0]?.game]?.color ?? "var(--accent)" }}>
            <div className="popover-tagrow" style={{ color: "var(--text-dim)" }}>{pick._multi}</div>
            <h4>이 날의 모든 일정 · {pick.list.length}개</h4>
          </div>
          <div style={{ maxHeight: 340, overflowY: "auto" }}>
            {pick.list.map((e) => {
              const cat = CAT_BY_ID[e.cat];
              const game = GAME_BY_ID[e.game];
              return (
                <div key={e.id} className="up-item" style={{ "--c": cat.color, borderBottom: "1px solid var(--border-soft)", cursor: "pointer" }}
                  onClick={() => onPick && onPick(e)}>
                  <div className="up-date"><div className="d" style={{ fontSize: 14 }}><span className="gico" style={{ width: 24, height: 24, borderRadius: 7, fontSize: 10, background: game.color }}>{game.noImg ? <span style={{ fontSize: 7, fontWeight: 800, fontFamily: "Oxanium,monospace", color: "#163d2b" }}>{game.short}</span> : <img src={"images/games/" + game.id + ".webp"} alt={game.name} />}</span></div></div>
                  <div className="up-bar"></div>
                  <div className="up-body">
                    <div className="up-title">{e.title}</div>
                    <div className="up-meta">
                      <span className="up-tag">[{cat.tag}]</span>
                      <span className="up-game">{game.name}</span>
                    </div>
                  </div>
                </div>);

            })}
          </div>
        </div>
      </div>);

  }

  const cat = CAT_BY_ID[pick.cat];
  const game = GAME_BY_ID[pick.game];
  const vid = ytId(pick.youtube);
  const dday = pick.end ? Math.max(0, daysBetween(ymd(TODAY), pick.end)) : null;
  return (
    <div className="popover-mask" onClick={onClose}>
      <div className="popover" onClick={(e) => e.stopPropagation()} style={{ "--c": game.color }}>
        <button className="popover-close" onClick={onClose}>✕</button>
        {vid && (
          <div className="pop-yt-embed">
            <iframe
              src={"https://www.youtube.com/embed/" + vid}
              title="YouTube video"
              style={{ border: "none" }}
              allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
              allowFullScreen
            />
          </div>
        )}
        <div className="popover-head">
          <div className="popover-tagrow">
            <span className="hcard-tag" style={{ "--c": cat.color }}>[{cat.tag}]</span>
            <span className="hcard-game" style={{ color: "var(--text-dim)" }}>
              <span className="gico" style={{ width: 18, height: 18, borderRadius: 5, display: "inline-grid", verticalAlign: "-4px", marginRight: 6, boxShadow: "none", background: game.color }}>
                {game.noImg ? <span style={{ fontSize: 7, fontWeight: 800, fontFamily: "Oxanium,monospace", color: "#163d2b" }}>{game.short}</span> : <img src={"images/games/" + game.id + ".webp"} alt={game.name} />}
              </span>
              {game.name}
            </span>
          </div>
          <h4>{pick.title}</h4>
        </div>
        <div className="popover-meta">
          <div className="row">
            <div className="k">기간</div>
            <div className="v mono">
              {pick.start}{pick.end ? " ~ " + pick.end : ""}
              {dday !== null && <span className="pop-dday">D-{dday}</span>}
            </div>
          </div>
          {pick.loc && <div className="row"><div className="k">장소</div><div className="v">{pick.loc}</div></div>}
          {pick.desc && <div className="pop-desc">{pick.desc}</div>}
        </div>
        {pick.cat === "offline" && pick.url && (
          <div className="pop-offline-btn-wrap">
            <a className="pop-offline-btn" href={pick.url} target="_blank" rel="noreferrer">
              행사 공식 페이지
              <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 3H3a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-3"/><path d="M10 1h5v5"/><path d="M7.5 8.5 15 1"/></svg>
            </a>
          </div>
        )}
        {((!pick.url || pick.cat !== "offline") && pick.url || pick.twitter) && (
          <div className="pop-foot">
            {pick.url && pick.cat !== "offline" && (
              <a className="pop-url-btn" href={pick.url} target="_blank" rel="noreferrer">
                <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M6 3H3a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-3"/><path d="M10 1h5v5"/><path d="M7.5 8.5 15 1"/></svg>
                공식 페이지
              </a>
            )}
            {pick.twitter && (
              <a className="pop-yt-btn" style={{ background: "#000" }} href={pick.twitter} target="_blank" rel="noreferrer">
                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.259 5.631L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
                공식 트위터 공지
              </a>
            )}
          </div>
        )}
      </div>
    </div>);

}

// ============== Fan Booth Modal ==============
function FanBoothModal({ booth, onClose }) {
  const [lightbox, setLightbox] = useState(null);
  if (!booth) return null;
  const gameObjs = booth.games.map((gid) => GAME_BY_ID[gid]).filter(Boolean);
  const accentColor = gameObjs[0]?.color ?? "#7c6af7";
  const fanEvent = FAN_EVENT_BY_ID[booth.event];
  return (
    <div className="popover-mask" onClick={onClose}>
      <div className="popover" onClick={(e) => e.stopPropagation()} style={{ "--c": accentColor }}>
        <button className="popover-close" onClick={onClose}>✕</button>
        {booth.img && (
          <div className="pop-img" onClick={() => setLightbox(booth.img)}>
            <img src={booth.img} alt={booth.circle} />
          </div>
        )}
        <div className="popover-head">
          <div className="popover-tagrow">
            <span className="fbc-no" style={{ position: "static", fontSize: 11 }}>{booth.no}</span>
            {fanEvent && <span style={{ fontSize: 12, color: "var(--text-mute)" }}>{fanEvent.name}</span>}
          </div>
          <h4>{booth.circle} <span style={{ fontSize: 14, fontWeight: 500, color: "var(--text-dim)" }}>· {booth.artist}</span></h4>
        </div>
        <div className="popover-meta">
          {fanEvent && <>
            <div className="row">
              <div className="k">기간</div>
              <div className="v mono">{fanDateRange(fanEvent)}</div>
            </div>
            {fanEvent.venue && (
              <div className="row">
                <div className="k">장소</div>
                <div className="v">{fanEvent.venue}</div>
              </div>
            )}
          </>}
          <div className="row">
            <div className="k">게임</div>
            <div className="v" style={{ display: "flex", gap: 5, alignItems: "center" }}>
              {gameObjs.map((g) => (
                <span key={g.id} className="gico" style={{ width: 20, height: 20, borderRadius: 5, background: g.color }}>
                  <img src={"images/games/" + g.id + ".webp"} alt={g.name} />
                </span>
              ))}
            </div>
          </div>
          {booth.desc && <div className="pop-desc">{booth.desc}</div>}
          {booth.note && <div className="pop-note">{booth.note}</div>}
        </div>
        {(booth.twitter || booth.pixivUrl || booth.pixivName || booth.prepayUrl) && (
          <div className="pop-foot" style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
            {booth.prepayUrl && (
              <a className="pop-yt-btn pop-prepay-btn"
                 href={booth.prepayUrl} target="_blank" rel="noreferrer">
                <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="1" y="3" width="14" height="10" rx="1.5"/><path d="M1 6.5h14"/><path d="M4 10h2"/></svg>
                선입금 폼
              </a>
            )}
            {booth.twitter && (
              <a className="pop-yt-btn" style={{ background: "#000" }}
                 href={"https://x.com/" + booth.twitter} target="_blank" rel="noreferrer">
                <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.259 5.631L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
                @{booth.twitter}
              </a>
            )}
            {(booth.pixivUrl || booth.pixivName) && (
              booth.pixivUrl
                ? <a className="pop-yt-btn" style={{ background: "#0096fa" }}
                     href={booth.pixivUrl} target="_blank" rel="noreferrer">
                    <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M4.935 0A4.924 4.924 0 0 0 0 4.935v14.13A4.924 4.924 0 0 0 4.935 24H19.06A4.924 4.924 0 0 0 24 19.065V4.935A4.924 4.924 0 0 0 19.065 0zm7.81 4.547c2.181 0 4.058.676 5.399 1.847a6.118 6.118 0 0 1 2.116 4.66c.005 4.418-3.318 6.714-7.343 6.747h-2.07c-.427 0-.721.255-.787.683l-.525 3.305c-.072.43-.349.633-.735.633H6.546a.44.44 0 0 1-.427-.544l2.619-16.553c.066-.38.306-.578.658-.578z"/></svg>
                    {booth.pixivName || "pixiv"}
                  </a>
                : <span className="pop-yt-btn" style={{ background: "#0096fa", cursor: "default" }}>
                    <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M4.935 0A4.924 4.924 0 0 0 0 4.935v14.13A4.924 4.924 0 0 0 4.935 24H19.06A4.924 4.924 0 0 0 24 19.065V4.935A4.924 4.924 0 0 0 19.065 0zm7.81 4.547c2.181 0 4.058.676 5.399 1.847a6.118 6.118 0 0 1 2.116 4.66c.005 4.418-3.318 6.714-7.343 6.747h-2.07c-.427 0-.721.255-.787.683l-.525 3.305c-.072.43-.349.633-.735.633H6.546a.44.44 0 0 1-.427-.544l2.619-16.553c.066-.38.306-.578.658-.578z"/></svg>
                    {booth.pixivName}
                  </span>
            )}
          </div>
        )}
        <ImageLightbox src={lightbox} onClose={() => setLightbox(null)} />
      </div>
    </div>
  );
}

// ============== Main App ==============
const DEFAULTS = /*EDITMODE-BEGIN*/{
  "density": "comfortable",
  "accent": "#7c6af7",
  "weekStart": "sun"
} /*EDITMODE-END*/;

function App() {
  const [user, setUser] = useState(() => loadUser());
  const [loginOpen, setLoginOpen] = useState(false);
  const handleLogin = (u, persist) => { saveUser(u, persist); setUser(u); };
  const logout = () => { clearUser(); setUser(null); if (window.google) window.google.accounts.id.disableAutoSelect(); };
  const userCtxVal = React.useMemo(() => ({ user, openLogin: () => setLoginOpen(true), logout }), [user]);

  const [year, setYear] = useState(TODAY.getFullYear());
  const [month, setMonth] = useState(TODAY.getMonth());
  const [theme, setTheme] = useTheme();
  // API에서 데이터 로드 완료 시 전체 리렌더 트리거
  const [dataVer, setDataVer] = useState(0);
  useEffect(() => {
    const handler = () => setDataVer(v => v + 1);
    window.addEventListener('gachaplan:data-loaded', handler);
    return () => window.removeEventListener('gachaplan:data-loaded', handler);
  }, []);
  const [selected, setSelected] = useState(() => {
    try {
      const raw = localStorage.getItem("gachaplan.filter.selected");
      return raw ? new Set(JSON.parse(raw)) : new Set();
    } catch (_) { return new Set(); }
  });
  const [showRelease, setShowRelease] = useState(() => {
    try { return localStorage.getItem("gachaplan.filter.showRelease") === "1"; }
    catch (_) { return false; }
  });

  useEffect(() => {
    try { localStorage.setItem("gachaplan.filter.selected", JSON.stringify([...selected])); }
    catch (_) {}
  }, [selected]);
  useEffect(() => {
    try { localStorage.setItem("gachaplan.filter.showRelease", showRelease ? "1" : "0"); }
    catch (_) {}
  }, [showRelease]);
  const [pick, setPick] = useState(null);
  const [page, setPage] = useState("calendar");

  const tweaks = window.useTweaks ? window.useTweaks(DEFAULTS) : [DEFAULTS, () => {}];
  const t = tweaks[0];const setTweak = tweaks[1];

  useEffect(() => {
    document.documentElement.style.setProperty("--accent", t.accent);
  }, [t.accent]);

  const allSelected = !showRelease && (selected.size === 0 || selected.size >= GACHA_GAMES.length);
  const toggle = (id) => setSelected((prev) => {
    const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next;
  });
  const selectAll = () => { setSelected(new Set()); setShowRelease(false); };
  const toggleRelease = () => setShowRelease((v) => !v);

  const filtered = useMemo(() => {
    if (allSelected) return GACHA_EVENTS;
    return GACHA_EVENTS.filter((e) => selected.has(e.game) || (showRelease && (e.cat === "release" || e.cat === "cbt")));
  }, [selected, allSelected, showRelease, dataVer]);

  const eventCountsByMonth = useMemo(() => {
    const m = {};
    filtered.forEach((e) => {
      const d = parseISO(e.start);
      if (d.getFullYear() === year) m[d.getMonth()] = (m[d.getMonth()] || 0) + 1;
    });
    return m;
  }, [filtered, year]);

  return (
    <UserCtx.Provider value={userCtxVal}>
    <div className={"app " + (t.density === "dense" ? "dense" : "")}>
      <Nav theme={theme} onToggleTheme={() => setTheme(theme === "dark" ? "light" : "dark")} page={page} onNav={setPage} />
      <NoticeBanner />
      {page === "calendar" && <>
        <div className="main-body">
          <aside className="sidebar-left">
            <AnniversarySidebar />
          </aside>
          <div className="main-center">
            <YearMonth year={year} month={month} setYear={setYear} setMonth={setMonth} eventCountsByMonth={eventCountsByMonth} />
            <GameFilter
              selected={selected}
              toggle={toggle}
              selectAll={selectAll}
              allSelected={allSelected}
              showRelease={showRelease}
              onToggleRelease={toggleRelease} />
            <Calendar
              year={year}
              month={month}
              events={filtered}
              onPick={setPick}
              weekStart={t.weekStart === "mon" ? 1 : 0} />
          </div>
          <SidebarRight />
        </div>
        <Popover pick={pick} onClose={() => setPick(null)} onPick={setPick} />
      </>}
{page === "booth" && <EventBoothPage key={dataVer} selected={selected} toggle={toggle} selectAll={selectAll} showRelease={showRelease} onToggleRelease={toggleRelease} dataVer={dataVer} />}

      <footer className="site-footer">
        <div className="footer-inner">
          <div className="footer-row">
            <span className="footer-logo">gachaplan</span>
            <span className="footer-divider">·</span>
            <span>© 2026 gachaplan. All rights reserved.</span>
          </div>
          <div className="footer-row footer-disclaimer">
            <span>비공식 팬사이트</span>
            <span className="footer-divider">·</span>
            <span>게임 내 콘텐츠 저작권은 각 게임사에 귀속됩니다</span>
            <span className="footer-divider">·</span>
            <span>일정은 변경될 수 있습니다</span>
          </div>
        </div>
      </footer>

      {window.TweaksPanel &&
      <window.TweaksPanel title="Tweaks">
          <window.TweakSection label="레이아웃">
            <window.TweakRadio
            label="셀 밀도"
            value={t.density}
            onChange={(v) => setTweak("density", v)}
            options={[{ value: "comfortable", label: "넉넉" }, { value: "dense", label: "빽빽" }]} />
          
            <window.TweakRadio
            label="주 시작일"
            value={t.weekStart}
            onChange={(v) => setTweak("weekStart", v)}
            options={[{ value: "sun", label: "일" }, { value: "mon", label: "월" }]} />
          
          </window.TweakSection>
          <window.TweakSection label="컬러">
            <window.TweakColor
            label="액센트"
            value={t.accent}
            onChange={(v) => setTweak("accent", v)}
            options={["#7c6af7", "#f7886a", "#6ab4f7", "#6af7a0", "#ec5e8e"]} />
          
          </window.TweakSection>
        </window.TweaksPanel>
      }
    </div>
    {loginOpen && <LoginModal onClose={() => setLoginOpen(false)} onLogin={handleLogin} />}
    </UserCtx.Provider>
  );

}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);