/* Denky — 앱 셸 / 라우터 / Tweaks */
const { useState: useS, useEffect: useE, useRef: useR } = React;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "heroStyle": "스플릿",
  "filterLayout": "좌측 사이드바",
  "gridCols": "4열",
  "cardInfo": true,
  "pointColor": "#6D28D9",
  "cardRadius": 16
}/*EDITMODE-END*/;

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);

  // 라우팅: { name, product, params }
  const [route, setRoute] = useS({ name: "home" });
  const [current, setCurrent] = useS(null); // active product
  const [query, setQuery] = useS("");
  const [cart, setCart] = useS([]);
  const cartRef = useR([]);   // 최신 장바구니 참조 (loadCart 가 낡은 값을 보지 않게)
  const [toasts, setToasts] = useS([]);
  // (가상피팅 겹쳐입기 세션 삭제 — 베이스는 항상 아바타, 기록 위 입기는 피팅 화면의 일회성 선택. 스마트앱과 동일 정책)
  const scrollRef = useR(null);
  // 카탈로그 '뒤로가기 복원'용 — 스마트앱처럼 재검색 없이 그 자리로 돌아가게 한다.
  // navSeqRef: catalog 진입(go)마다 새 번호를 매겨, catalogCacheRef 에 그 인스턴스의 결과/스크롤을 stash.
  // 뒤로가기(popstate)로 같은 navId 가 돌아오면 CatalogScreen 이 캐시를 읽어 재요청을 건너뛴다.
  const navSeqRef = useR(0);
  const catalogCacheRef = useR({});

  // 실제 백엔드 인증 상태 (api.js)
  const [auth, setAuth] = useS({ loggedIn: false, hasPhoto: false, photoUrl: null, daily: 0, credit: 0, name: null, email: null, phone: null, gender: null, height: null, weight: null, zipcode: null, address: null, addressDetail: null });
  const [dataReady, setDataReady] = useS(false); // 실제 상품 로드 완료 시 재렌더용
  // 결제 승인을 기다리는 동안 화면을 덮는다(결제창에서 돌아온 직후)
  const [payBusy, setPayBusy] = useS(false);

  // 내 정보를 새로고침해 로그인/사진 보유 상태를 갱신합니다.
  async function refreshMe() {
    try {
      const me = await API.me();
      // 성별 토글 기본값을 회원 성별로 — 추천·검색·목록이 회원 성별을 디폴트로 보이게.
      // (사용자가 이번 세션에 직접 바꿨으면 sessionStorage 플래그로 존중)
      try {
        const mapped = me.gender === "male" ? "남성" : (me.gender === "female" ? "여성" : null);
        if (mapped && sessionStorage.getItem("gender_explicit") !== "1") localStorage.setItem("denky-gender", mapped);
      } catch (e) {}
      setAuth({ loggedIn: true, isEmailVerified: me.is_email_verified === true, hasPhoto: !!me.front_photo_url, photoUrl: me.front_photo_url, sidePhotoUrl: me.side_photo_url, facePhotoUrl: me.face_photo_url, facePhoto2Url: me.face_photo2_url, daily: me.daily_tryon_count || 0, credit: me.credit_balance || 0, name: me.name, email: me.email, phone: me.phone, gender: me.gender, birthdate: me.birthdate, marketingConsent: me.marketing_consent === true, oauthProvider: me.oauth_provider || null, height: me.height, weight: me.weight, chest: me.chest, waist: me.waist, hip: me.hip, shoulder: me.shoulder, torsoLength: me.torso_length, legLength: me.leg_length, armLength: me.arm_length, usualTopSize: me.usual_top_size, usualBottomSize: me.usual_bottom_size, estChest: me.est_chest, estWaist: me.est_waist, estHip: me.est_hip, estTorsoLength: me.est_torso_length, estLegLength: me.est_leg_length, estArmLength: me.est_arm_length, estShoulder: me.est_shoulder, estUsualTop: me.est_usual_top, estUsualBottom: me.est_usual_bottom, zipcode: me.zipcode, address: me.address, addressDetail: me.address_detail, avatarUrl: me.avatar_url, fittingSource: me.fitting_source || "photo", avatarGenCount: me.avatar_gen_count || 0, avatarCandidateUrl: me.avatar_candidate_url || null, avatarStatus: me.avatar_status || "none", avatarFreeLimit: me.avatar_free_limit || 3, avatarExtraFitCost: me.avatar_extra_fit_cost || 1, fitCouponBalance: me.fit_coupon_balance || 0, welcomeCoupon: me.welcome_coupon_available === true, lifetimeTryonCount: me.lifetime_tryon_count || 0, lifetimeFreeLimit: me.lifetime_free_limit || 60, weeklyTryonLimit: me.weekly_tryon_limit || 10, freeFitBalance: me.free_fit_balance || 0, freeFitBalanceCap: me.free_fit_balance_cap || 40, lifetimeFreeGranted: me.lifetime_free_granted || 0 });
      API.loadWishIds(); // 하트(찜) 표시용 id 캐시 갱신(+게스트 찜 병합) — 기다릴 필요 없음
      loadCart();        // 서버에 저장해 둔 장바구니 복원(+게스트 줄 병합) — 기다릴 필요 없음
      API.mergeGuestFitRoom().then(() => refreshFitRoomCount()).catch(() => {}); // 게스트 피팅룸 병합
      return me;
    } catch (e) {
      API.logout();
      API.clearWishIds();
      setAuth({ loggedIn: false, hasPhoto: false, photoUrl: null, daily: 0, credit: 0, name: null, email: null, phone: null, gender: null, height: null, weight: null, zipcode: null, address: null, addressDetail: null });
      return null;
    }
  }
  // 화면들이 쓰는 auth 인터페이스. (가상피팅 게이트/로그인에 사용)
  // 가상피팅 베이스 = '아바타 전용'. 원본 사진은 아바타의 재료일 뿐, 사진 위에 직접 피팅하는
  // 경로는 없다 — 사진만 있고 아바타가 없으면 hasBase=false(게이트가 아바타 생성으로 안내).
  const baseImage = auth.avatarUrl || null;
  const authApi = {
    loggedIn: auth.loggedIn, hasPhoto: auth.hasPhoto, photoUrl: auth.photoUrl, sidePhotoUrl: auth.sidePhotoUrl, facePhotoUrl: auth.facePhotoUrl, facePhoto2Url: auth.facePhoto2Url, daily: auth.daily, credit: auth.credit,
    isEmailVerified: auth.isEmailVerified, // false 면 마이페이지에 인증 배너 표시 (미로그인·미확인은 undefined)
    name: auth.name, email: auth.email, phone: auth.phone, gender: auth.gender, birthdate: auth.birthdate, height: auth.height, weight: auth.weight,
    chest: auth.chest, waist: auth.waist, hip: auth.hip, shoulder: auth.shoulder,
    torsoLength: auth.torsoLength, legLength: auth.legLength, armLength: auth.armLength,
    usualTopSize: auth.usualTopSize, usualBottomSize: auth.usualBottomSize,
    estChest: auth.estChest, estWaist: auth.estWaist, estHip: auth.estHip,
    estTorsoLength: auth.estTorsoLength, estLegLength: auth.estLegLength, estArmLength: auth.estArmLength,
    estUsualTop: auth.estUsualTop, estUsualBottom: auth.estUsualBottom,
    zipcode: auth.zipcode, address: auth.address, addressDetail: auth.addressDetail,
    marketingConsent: auth.marketingConsent === true, // 마케팅 수신 동의 (마이페이지 토글)
    oauthProvider: auth.oauthProvider || null,        // 소셜 연결 (kakao/naver, 이메일 가입이면 null)
    avatarUrl: auth.avatarUrl, fittingSource: auth.fittingSource || "photo",
    // 아바타 생성 한도(무료 3회) 관련 — 서버 설정값과 누적 횟수, 교체 대기 중인 후보
    avatarGenCount: auth.avatarGenCount || 0, avatarCandidateUrl: auth.avatarCandidateUrl || null,
    avatarStatus: auth.avatarStatus || "none",
    avatarFreeLimit: auth.avatarFreeLimit || 3, avatarExtraFitCost: auth.avatarExtraFitCost || 1,
    // 피팅 횟수·쿠폰 (충전금 대체)
    // ★화면에 보여줄 '남은 횟수' = freeFitBalance + fitCouponBalance★
    //   lifetimeFreeLimit/lifetimeFreeGranted 는 "무료 충전이 언제까지 오는지" 안내용이고
    //   남은 횟수가 아닙니다. (무료 잔고는 매주 충전되고 안 쓰면 이월됩니다)
    fitCouponBalance: auth.fitCouponBalance || 0, lifetimeTryonCount: auth.lifetimeTryonCount || 0,
    lifetimeFreeLimit: auth.lifetimeFreeLimit || 60, weeklyTryonLimit: auth.weeklyTryonLimit || 10,
    freeFitBalance: auth.freeFitBalance || 0, freeFitBalanceCap: auth.freeFitBalanceCap || 40,
    lifetimeFreeGranted: auth.lifetimeFreeGranted || 0,
    // 구매확정 / 쿠폰함 / 쿠폰 전환 — 처리 후 내 정보를 새로고침해 잔고를 반영
    confirmOrder: async (orderId) => { const o = await API.confirmOrder(orderId); await refreshMe(); return o; },
    coupons: async () => API.coupons(),
    convertCoupon: async (couponId) => { const r = await API.convertCoupon(couponId); await refreshMe(); return r; },
    baseImage, hasBase: !!baseImage,   // 가상피팅 베이스(아바타 전용) + 보유 여부
    refresh: async () => { await refreshMe(); },
    createAvatar: async () => { const me = await API.createAvatar(); await refreshMe(); return me; },
    // 새 아바타(후보) 바꾸기/취소 — accept: true=바꾸기, false=기존 유지
    confirmAvatar: async (accept) => { await API.confirmAvatar(accept); await refreshMe(); },
    setFittingSource: async (src) => { await API.updateProfile({ fitting_source: src }); await refreshMe(); },
    login: async (email, password) => { await API.login(email, password); await refreshMe(); },
    // 비회원 주문 — 게스트 임시 계정으로 전환(담아둔 장바구니는 로그인 병합 흐름으로 옮겨진다)
    guestLogin: async () => { await API.guestLogin(); await refreshMe(); },
    // 가입 — 자동 로그인하지 않고 결과({verification_required, dev_verification_code})를 화면에 돌려준다.
    // (화면이 '이메일 인증' 단계를 띄우고, 인증하거나 '나중에 하기'로 로그인한다)
    signup: async (payload) => API.signup(payload),
    uploadPhoto: async (dataUrl) => { const me = await API.updateProfile({ front_photo: dataUrl }); await refreshMe(); return me && me.front_photo_url; },
    uploadSidePhoto: async (dataUrl) => { const me = await API.updateProfile({ side_photo: dataUrl }); await refreshMe(); return me && me.side_photo_url; },
    updateProfile: async (payload) => { await API.updateProfile(payload); await refreshMe(); },
    // 로그아웃 — 화면 장바구니만 비운다. 서버 장바구니는 그대로 남아 다시 로그인하면 되살아난다.
    logout: () => { API.logout(); API.clearWishIds(); setCart([]); setAuth({ loggedIn: false, hasPhoto: false, photoUrl: null, daily: 0, credit: 0, name: null, email: null, phone: null, gender: null, height: null, weight: null, zipcode: null, address: null, addressDetail: null }); go("home"); },
    // 회원 탈퇴 — 서버에서 계정·기록을 삭제한 뒤 로그아웃과 동일하게 상태를 정리하고 홈으로
    deleteAccount: async () => { await API.deleteMe(); authApi.logout(); },
  };

  // 공개 플래그 — 소프트 오픈 '결제 잠금' 여부 + 점검 예고 문구 (기본 열림, 서버가 최종 방어)
  const [flags, setFlags] = useS({ checkout_enabled: true, maintenance_notice: "" });

  // 시작: 실제 상품을 불러와 화면 데이터(DENKY.products)를 교체하고, 로그인 상태를 복원합니다.
  useE(() => {
    // 시작할 때 한 번 + 몇 분마다 다시 — 창을 열어둔 손님도 점검 예고를 받아볼 수 있게.
    const readFlags = () => API.flags().then((f) => { if (f) setFlags(f); }).catch(() => {});
    readFlags();
    const flagsTimer = setInterval(readFlags, 3 * 60 * 1000);
    (async () => {
      try {
        // 홈 '지금 인기' = 카탈로그 인기 화면과 같은 기준(도매매 순위 8일 이내, 순위순).
        // ※예전 'seller_first'는 백엔드에서 걷어낸 옛 정렬이라 id순 폴백 → 부활 복구된
        //   옛 id 상품들이 홈을 점령하는 사고가 있었다(2026-08-13).
        const items = await API.products({ limit: 100, sort: "popular" });
        if (items.length) DENKY.products = items;
      } catch (e) {
        toast("상품을 불러오지 못했어요. 백엔드(localhost:8000)가 켜져 있는지 확인해 주세요.");
      }
      // 저장된 토큰이 있으면 로그인 상태를 복원합니다 — 새로고침(Ctrl+Shift+R 포함)에도 로그인 유지.
      // (토큰이 만료·무효면 refreshMe 가 알아서 로그아웃 상태로 정리합니다)
      if (API.loggedIn) await refreshMe();
      setDataReady(true);
      // ★결제창에서 돌아온 경우 승인을 마무리합니다★ 모바일은 결제창이 페이지를 통째로
      // 이동시켜서, 결제가 끝나면 이 몰이 처음부터 다시 뜹니다. 그때 승인을 이어 부를
      // 화면이 이미 사라진 뒤라 여기서 대신 마무리합니다. (로그인 복원 뒤에 불러야 합니다)
      try {
        if (API.hasPendingPayment()) setPayBusy(true);
        const fin = await API.finishPendingPayment();
        setPayBusy(false);
        if (fin && fin.ok) {
          toast("결제가 완료됐어요!");
          loadCart();                       // 장바구니 결제였으면 비워진 것을 반영
          go("mypage", { tab: "orders" });
        } else if (fin) {
          toast(fin.message || "결제가 완료되지 않았어요.");
        }
      } catch (e) { setPayBusy(false); /* 결제 마무리 실패로 앱 시작이 막히면 안 됩니다 */ }
    })();
    return () => clearInterval(flagsTimer);
  }, []);

  function toast(msg) {
    const id = Math.random().toString(36).slice(2);
    setToasts((ts) => [...ts, { id, msg }]);
    setTimeout(() => setToasts((ts) => ts.filter((x) => x.id !== id)), 2400);
  }
  // 상품 카드(ProductCard)의 하트처럼 props 를 못 받는 곳에서 쓰는 전역 토스트
  window.denkyToast = toast;

  // 피팅룸에 담긴 벌 수 — 헤더 배지에 쓴다. (담기·빼기 후 갱신)
  const [fitRoomCount, setFitRoomCount] = useState(0);
  function refreshFitRoomCount() {
    // 게스트도 담을 수 있으므로 로그인 여부와 무관하게 센다(게스트는 브라우저 보관 줄 수).
    API.fitRoom().then((d) => setFitRoomCount(((d && d.items) || []).length)).catch(() => {});
  }
  useEffect(() => { refreshFitRoomCount(); }, [authApi.loggedIn]);
  // 상세 화면 등 다른 파일에서 담은 뒤에도 헤더 배지가 바로 따라오게 전역 훅으로 연다.
  window.denkyRefreshFitRoomCount = refreshFitRoomCount;

  // 찜하는 순간 '어디에 담을까요?' 를 묻는 창.
  // 나중에 정리하기는 실제로 아무도 안 해서, 찜하는 그 순간에 물어본다.
  // ★찜은 이미 끝난 상태★ — 이 창은 분류만 한다. 그냥 닫아도 찜은 남는다.
  // 라벨을 하나도 안 만든 손님에게는 띄우지 않는다(첫 경험을 방해하지 않기 위해).
  const [askLabelFor, setAskLabelFor] = useState(null);   // {productId, labels}
  window.denkyAskWishLabel = async function (productId) {
    if (!API.loggedIn) return;   // 라벨은 회원 기능 — 게스트 찜에는 묻지 않는다
    try {
      const labels = await API.wishLabels();
      if (labels && labels.length) setAskLabelFor({ productId, labels });
    } catch (e) { /* 라벨을 못 불러와도 찜 자체는 이미 됐다 */ }
  };

  // ── 화면에 주소(URL) 붙이기 ───────────────────────────────────────────────
  // 예전에는 어느 화면이든 주소가 늘 "/" 하나였다. 그래서 검색엔진이 상품 7만 개를 볼 방법이
  // 없었고(주소가 없으니 사이트맵에 넣을 것도 없다), 손님이 상품 링크를 공유할 수도 없었다.
  // 검색에 잡혀야 하는 화면(상품 상세·전체 상품)에만 진짜 주소를 준다. 나머지는 "/" 로 둔다 —
  // 마이페이지·결제 같은 화면은 주소로 되살릴 정보(주문번호 등)가 없어 새로고침 때 깨진다.
  function pathFor(routeObj, cur) {
    if (!routeObj) return "/";
    if (routeObj.name === "detail" && cur && cur.id) return "/p/" + cur.id;
    if (routeObj.name === "catalog") return "/c";
    return "/";
  }

  // 화면 전환의 공통 처리: 상태 반영 + 스크롤 맨 위로.
  function applyNav(routeObj, cur) {
    if (cur !== undefined) setCurrent(cur);
    setRoute(routeObj);
    if (scrollRef.current) scrollRef.current.scrollTo({ top: 0 });
  }
  function go(name, payload) {
    // 화면 이름/페이로드로 라우트 객체와 활성 상품을 정합니다.
    let routeObj, cur;
    if (name === "detail" && payload) { cur = payload; routeObj = { name: "detail" }; }
    else if (name === "detail") { routeObj = { name: "catalog", params: null, navId: ++navSeqRef.current }; }  // 상품 없이 상세 요청 → 유령 상세 대신 카탈로그로
    else if (name === "tryon") { if (payload) cur = payload; routeObj = { name: "tryon", key: Date.now() }; }
    else if (name === "catalog") { routeObj = { name: "catalog", params: payload || null, navId: ++navSeqRef.current }; }
    else if (name === "checkout") { routeObj = { name: "checkout", params: payload || null }; }
    else if (name === "mypage") { routeObj = { name: "mypage", params: payload || null }; }  // 탭 지정(예: {tab:"profile"})
    else if (name === "login" || name === "signup" || name === "reset") { routeObj = { name, params: payload || null }; }  // returnTo 등 (reset=비밀번호 재설정)
    else { routeObj = { name }; }
    applyNav(routeObj, cur);
    // 브라우저 '뒤로가기'가 동작하도록 history 에 현재 화면을 기록합니다. (주소도 함께 바꿉니다)
    const keep = cur !== undefined ? cur : current;
    window.history.pushState({ route: routeObj, current: keep }, "", pathFor(routeObj, keep));
  }
  const openProduct = (p) => go("detail", p);
  // '입어보기' — 베이스는 항상 '내 아바타'. 기록 위에 입기는 피팅 화면의 [아바타 바꾸기]에서
  // 그때그때 고르는 일회성 선택이라, 앱 차원의 겹쳐입기 세션은 없다. (스마트앱과 동일 정책)
  // 상품 카드의 ✨ 버튼 — 피팅룸에 담는다.
  // 예전에는 여기서 곧장 '이렇게 입어볼게요' 화면으로 갔지만, 이제 입어보기는 피팅룸에서
  // 여러 벌을 골라 한꺼번에 한다. (옵션이 있는 상품은 색을 골라야 하므로 상세로 보낸다)
  async function tryProduct(p, options) {
    // 로그인 없이도 담긴다(2026-08-12) — 게스트 줄은 브라우저에 보관, 로그인하면 계정으로 합쳐진다.
    // 가입 유도는 담기가 아니라 '입어보기(피팅)' 순간에 한다(피팅룸 화면).
    if (p.fitting_supported === false) {
      toast("이 상품은 상세 이미지에 온전한 옷 컷이 없어 가상피팅을 지원하지 않아요");
      return;
    }
    const axes = p.options && typeof p.options === "object" ? Object.keys(p.options) : [];
    if (axes.length && !(options && Object.keys(options).length)) {
      toast("색상·사이즈를 고른 뒤 피팅룸에 담아 주세요");
      openProduct(p);
      return;
    }
    try {
      await API.addToFitRoom(p.id, options, p);   // p: 게스트 담기에 필요한 카드 상품
      if (window.denkyRefreshFitRoom) window.denkyRefreshFitRoom();
      refreshFitRoomCount();
      toast("피팅룸에 담았습니다");
    } catch (e) { toast((e && e.message) || "피팅룸에 담지 못했어요"); }
  }

  // 옵션(색상·사이즈 등)이 있는 상품은 '전부' 골라야 담기/구매된다 — 어느 경로(상세·피팅·향후 카드)든
  // 옵션 미선택 add 를 막는 최종 방어선. 값 있는 옵션 축 중 안 고른 것들을 돌려준다(없으면 빈 배열).
  function missingOptionAxes(p, opts) {
    const o = p && p.options;
    if (!o || typeof o !== "object") return [];
    return Object.keys(o)
      .filter((k) => Array.isArray(o[k]) && o[k].length)   // 값 있는 축만
      .filter((axis) => !(opts && opts[axis]));            // 아직 안 고른 축
  }

  // 구매하기 — 모든 상품은 앱 내 직접 결제입니다. pending 주문을 만들어 결제 화면으로 보냅니다.
  async function startCheckout(product, opts) {
    const miss = missingOptionAxes(product, opts);
    if (miss.length) { toast(`${miss.join("·")} 옵션을 선택해 주세요`); return; }
    try {
      const intent = await API.checkoutIntent(product.id, opts);
      // 앱 내 직접 결제 — pending 주문을 결제 화면으로 (옵션·상품id도 넘겨 표기/수량변경에 사용)
      go("checkout", { payOrderId: intent.order_id, amount: intent.amount, discount: intent.discount || 0, productName: product.name, selectedOptions: opts || null, productId: product.id });
    } catch (e) {
      // 담기는 게스트도 되지만, 구매하는 순간 가입을 권한다(2026-08-12).
      if (e.status === 401) { toast("회원가입하고 구매를 완료해보세요"); go("signup"); }
      else toast(e.message || "구매를 시작할 수 없어요");
    }
  }

  // 주소만 보고 화면을 되살립니다 — 검색·공유 링크로 /p/123 에 바로 들어오거나, 그 주소에서
  // 새로고침했을 때 홈이 아니라 그 상품이 보여야 합니다. (돌려주는 값: 되살렸으면 true)
  function restoreFromPath(pathname) {
    const m = String(pathname || "").match(/^\/p\/(\d+)\/?$/);
    if (m) {
      API.product(m[1]).then((p) => {
        if (!p || !p.id) throw new Error("상품 없음");
        setCurrent(p);
        setRoute({ name: "detail" });
        window.history.replaceState({ route: { name: "detail" }, current: p }, "", "/p/" + p.id);
      }).catch(() => {
        // 없어지거나 숨겨진 상품 — 홈으로 돌리고 주소도 정리합니다(죽은 주소가 색인에 남지 않게).
        setCurrent(null);
        setRoute({ name: "home" });
        window.history.replaceState({ route: { name: "home" }, current: null }, "", "/");
      });
      return true;
    }
    if (/^\/c\/?$/.test(String(pathname || ""))) {
      const routeObj = { name: "catalog", params: null, navId: ++navSeqRef.current };
      setRoute(routeObj);
      setCurrent(null);
      window.history.replaceState({ route: routeObj, current: null }, "", "/c");
      return true;
    }
    return false;
  }

  // 브라우저 뒤로/앞으로 가기 → 저장해 둔 화면으로 복원합니다.
  useE(() => {
    // 첫 진입 — 주소에 화면이 실려 있으면 그 화면으로, 아니면 홈으로 시작합니다.
    // (쿼리는 그대로 둡니다 — utm 같은 유입 표시가 붙어 있을 수 있습니다.
    //  초대 토큰 ?ref_share 는 api.js 가 이보다 먼저 읽어 저장하고 주소에서 지웁니다)
    if (!restoreFromPath(location.pathname)) {
      const home = location.pathname === "/" ? location.pathname + location.search : "/" + location.search;
      window.history.replaceState({ route: { name: "home" }, current: null }, "", home);
    }
    // 체험 딥링크(?try=1) — QR·공유·광고 링크 착지. 비로그인만 체험 창으로(회원은 정식 피팅룸이 있다).
    try {
      const q = new URLSearchParams(location.search);
      if (q.get("try") === "1" && !localStorage.getItem("denky_token")) {
        setRoute({ name: "tryexp" });
      }
    } catch (e) { /* 파싱 실패는 무시 */ }
    const onPop = (e) => {
      const s = e.state;
      if (s && s.route) { setCurrent(s.current || null); setRoute(s.route); }
      else if (!restoreFromPath(location.pathname)) { setCurrent(null); setRoute({ name: "home" }); }
      if (scrollRef.current) scrollRef.current.scrollTo({ top: 0 });
    };
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);

  // opts: 선택한 옵션 {색상,사이즈}. 같은 상품도 옵션이 다르면 별도 줄로 담습니다.
  // ★로그인 회원의 장바구니는 서버에 저장된다★ — 새로고침·브라우저 종료·로그아웃 후 재로그인,
  //   폰↔PC 기기를 바꿔도 그대로 남는다. 비로그인(게스트)이면 이 목록은 브라우저 메모리에만 있고,
  //   로그인하는 순간 서버로 병합된다(loadCart). 화면은 예전처럼 cart 만 보면 되도록 왕복은 여기서 감춘다.
  // 서버 줄 id 를 찾는다 — 없으면(게스트 줄) null.
  const cartItemIdOf = (id, optKey) => {
    const line = cart.find((x) => x.id === id && (x._optKey || "") === (optKey || ""));
    return line ? (line._cartItemId || null) : null;
  };

  function addToCart(p, opts, buyNow) {
    // 옵션 미선택이면 담지 않는다 — 옵션 있는 상품은 색상·사이즈를 다 골라야 장바구니에 들어간다.
    const miss = missingOptionAxes(p, opts);
    if (miss.length) { toast(`${miss.join("·")} 옵션을 선택해 주세요`); return; }
    const optKey = opts ? JSON.stringify(opts) : "";
    setCart((c) => {
      const ex = c.find((x) => x.id === p.id && (x._optKey || "") === optKey);
      if (ex) return c.map((x) => x === ex ? { ...x, qty: x.qty + 1 } : x);
      return [...c, { ...p, qty: 1, selected_options: opts || null, _optKey: optKey }];
    });
    // 로그인 상태면 서버에도 담고, 응답으로 목록을 맞춰 서버 줄 id 를 받아온다.
    if (API.loggedIn) API.cartAdd(p.id, opts, 1).then(setCart).catch(() => {});
    if (buyNow) { go("cart"); }
    else toast(`장바구니에 담았습니다`);
  }
  // 옵션까지 같아야 같은 줄로 보고 수량/삭제를 처리합니다(_optKey).
  const setQty = (id, q, optKey) => {
    const itemId = cartItemIdOf(id, optKey);
    setCart((c) => c.map((x) => (x.id === id && (x._optKey || "") === (optKey || "")) ? { ...x, qty: Math.max(1, q) } : x));
    if (API.loggedIn && itemId) API.cartSetQty(itemId, Math.max(1, q)).then(setCart).catch(() => {});
  };
  const removeItem = (id, optKey) => {
    const itemId = cartItemIdOf(id, optKey);
    setCart((c) => c.filter((x) => !(x.id === id && (x._optKey || "") === (optKey || ""))));
    if (API.loggedIn && itemId) API.cartRemove(itemId).then(setCart).catch(() => {});
  };
  const clearCart = () => {
    setCart([]);
    if (API.loggedIn) API.cartClear().then(setCart).catch(() => {});
  };
  const cartCount = cart.reduce((s, x) => s + x.qty, 0);
  useE(() => { cartRef.current = cart; }, [cart]);

  // 내 장바구니 불러오기 — 로그인 직후·페이지 시작 시. 게스트로 담아둔 줄(_cartItemId 없음)이
  // 있으면 먼저 서버로 합친다(어느 로그인 경로로 들어와도 담아둔 옷이 사라지지 않게 한 곳에서 처리).
  // ★cartRef★ — refreshMe 가 만들어질 때의 낡은 cart 를 보지 않도록 항상 최신 목록을 참조한다.
  async function loadCart() {
    if (!API.loggedIn) return;
    const guests = (cartRef.current || []).filter((l) => !l._cartItemId);
    try {
      setCart(guests.length ? await API.cartMerge(guests) : await API.cart());
    } catch (e) {/* 연결 실패 — 다음 기회에 다시 불러온다 */}
  }

  function onSearch(q) {
    const term = (typeof q === "string" && q) ? q : query;
    setQuery(term);
    go("catalog", term ? { q: term } : null);
    if (term) toast(`"${term}" 검색했어요`);
  }

  // point color 적용
  useE(() => {
    const r = document.documentElement;
    r.style.setProperty("--primary", t.pointColor);
    // 살짝 어둡게 + soft 자동 보정
    r.style.setProperty("--primary-dark", shade(t.pointColor, -0.18));
    r.style.setProperty("--accent-soft", tint(t.pointColor, 0.86));
    r.style.setProperty("--r-card", t.cardRadius + "px");
  }, [t.pointColor, t.cardRadius]);

  const isAuthScreen = route.name === "login" || route.name === "signup" || route.name === "reset";

  let screen;
  switch (route.name) {
    case "home": screen = <HomeScreen go={go} openProduct={openProduct} tryProduct={tryProduct} t={t} auth={authApi} />; break;
    case "dailymood": screen = <DailyMoodScreen go={go} openProduct={openProduct} tryProduct={tryProduct} t={t} auth={authApi} />; break;
    case "tryexp": screen = <TryScreen go={go} toast={toast} />; break;
    case "catalog": screen = <CatalogScreen go={go} openProduct={openProduct} tryProduct={tryProduct} t={t} initial={route.params} navId={route.navId} cacheRef={catalogCacheRef} scrollRef={scrollRef} query={query} setQuery={setQuery} onSearch={onSearch} />; break;
    case "detail": screen = <DetailScreen p={current} go={go} openProduct={openProduct} tryProduct={tryProduct} addToCart={addToCart} startCheckout={startCheckout} t={t} auth={authApi} shippingPolicy={flags.shipping_policy} />; break;
    case "fitroom": screen = <FitRoomScreen go={go} openProduct={openProduct} auth={authApi} toast={toast} />; break;
    case "cart": screen = <CartScreen cart={cart} setQty={setQty} removeItem={removeItem} go={go} toast={toast} auth={authApi} />; break;
    case "checkout": screen = <CheckoutScreen cart={cart} setQty={setQty} go={go} toast={toast} clearCart={clearCart} intent={route.params} auth={authApi} checkoutEnabled={flags.checkout_enabled !== false} checkoutReview={flags.checkout_review === true} />; break;
    case "login": screen = <AuthScreen mode="login" go={go} auth={authApi} toast={toast} returnTo={route.params && route.params.returnTo} />; break;
    case "signup": screen = <AuthScreen mode="signup" go={go} auth={authApi} toast={toast} returnTo={route.params && route.params.returnTo} />; break;
    case "reset": screen = <PasswordResetScreen go={go} auth={authApi} toast={toast} returnTo={route.params && route.params.returnTo} />; break;
    case "mypage": screen = <MyPageScreen go={go} openProduct={openProduct} tryProduct={tryProduct} auth={authApi} toast={toast} initialTab={route.params && route.params.tab} />; break;
    default: screen = <HomeScreen go={go} openProduct={openProduct} tryProduct={tryProduct} t={t} auth={authApi} />;
  }

  return (
    <div ref={scrollRef} className="app-scroll" style={{ height: "100vh", overflowY: "auto" }}>
      {/* 점검 예고 — 운영자가 배포 직전에 켠다. 지금 당장 영향을 주는 일이라 맨 위에 둔다 */}
      {flags.maintenance_notice && (
        <div style={{ background: "#B45309", color: "#fff", textAlign: "center", padding: "9px 16px", fontSize: 13, fontWeight: 600 }}>
          {flags.maintenance_notice}
        </div>
      )}
      {/* 소프트 오픈 배너 — 결제가 잠긴 동안만. 방문자가 '왜 결제가 안 되지'를 헤매지 않게 먼저 알린다 */}
      {flags.checkout_enabled === false && (
        <div style={{ background: "var(--ink)", color: "#fff", textAlign: "center", padding: "9px 16px", fontSize: 13, fontWeight: 600 }}>
          지금은 시범 운영 중이에요 — 가상피팅은 자유롭게 즐기시고, 구매는 곧 열릴 예정이에요!
        </div>
      )}
      {/* 결제 심사중 배너 — 결제창은 열려 있지만 확정이 잠긴 기간(PG 심사). 헛결제 방지 안내 */}
      {flags.checkout_enabled !== false && flags.checkout_review === true && (
        <div style={{ background: "#B45309", color: "#fff", textAlign: "center", padding: "9px 16px", fontSize: 13, fontWeight: 600 }}>
          결제 심사 중이에요 — 지금은 주문이 완료되지 않아요. 심사가 끝나는 대로 열려요!
        </div>
      )}
      <Header route={route} go={go} cartCount={cartCount} fitRoomCount={fitRoomCount} auth={authApi} />
      <main>{screen}</main>
      {!isAuthScreen && <Footer go={go} />}
      <Toasts items={toasts} />
      {/* 결제 승인 중 덮개 — 결제창에서 돌아오면 몰이 처음부터 다시 뜬다. 그때 홈이 잠깐
          보였다가 완료 알림만 뜨면 결제가 된 건지 알 수 없다(사용자 신고). 승인이 끝날 때까지 덮는다. */}
      {payBusy && (
        <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.55)", display: "grid", placeItems: "center", zIndex: 9999 }}>
          <div style={{ background: "#fff", borderRadius: 16, padding: "26px 34px", textAlign: "center", minWidth: 220 }}>
            {/* 흰 카드 위라 어두운 색 스피너를 쓴다 — 기본 spinner 는 흰색이라 안 보인다 */}
            <div className="spinner spinner-dark" style={{ margin: "0 auto 14px" }} />
            <p style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>결제 처리 중이에요</p>
            <p className="t-caption t-sub" style={{ margin: "6px 0 0" }}>잠시만 기다려 주세요</p>
          </div>
        </div>
      )}
      {/* 디자인 탐색용 Tweaks 패널은 프로덕션에서 로드하지 않습니다.
          다시 보려면: index.html 에 <script type="text/babel" src="tweaks-panel.jsx"> 추가 후
          여기에 <TweaksPanel /> 렌더. (useTweaks 훅은 tweaks-hook.js 가 항상 로드) */}
      {askLabelFor && (
        <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.5)", display: "grid", placeItems: "center", zIndex: 1200, padding: 20 }}
          onClick={() => setAskLabelFor(null)}>
          <div onClick={(e) => e.stopPropagation()}
            style={{ background: "#fff", borderRadius: 16, padding: 20, width: "min(92vw, 360px)", maxHeight: "80vh", overflowY: "auto" }}>
            <h3 style={{ margin: "0 0 4px", fontSize: 16 }}>어디에 담을까요?</h3>
            <p className="t-caption t-sub" style={{ margin: "0 0 12px" }}>안 골라도 찜은 그대로 저장돼요</p>
            <form id="askLabelForm">
              {askLabelFor.labels.map((lab) => (
                <label key={lab.id} className="row" style={{ gap: 8, alignItems: "center", padding: "9px 2px", cursor: "pointer" }}>
                  <input type="checkbox" value={lab.id} />
                  <span className="t-small" style={{ color: "var(--ink)" }}>{lab.name}</span>
                </label>
              ))}
            </form>
            <div className="row" style={{ gap: 8, marginTop: 12 }}>
              <Btn variant="ghost" block onClick={() => setAskLabelFor(null)}>나중에</Btn>
              <Btn variant="primary" block onClick={async () => {
                const form = document.getElementById("askLabelForm");
                const picked = form ? Array.from(form.querySelectorAll("input[type=checkbox]:checked")).map((c) => Number(c.value)) : [];
                const pid = askLabelFor.productId;
                setAskLabelFor(null);
                if (!picked.length) return;
                try {
                  await API.setWishItemLabels(pid, picked);
                  toast("라벨에 담았어요");
                } catch (e) { toast(e.message || "라벨에 담지 못했어요"); }
              }}>담기</Btn>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/* color helpers */
function hexToRgb(h) { const n = parseInt(h.slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; }
function rgbToHex(r, g, b) { return "#" + [r, g, b].map((x) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, "0")).join(""); }
function shade(hex, amt) { const [r, g, b] = hexToRgb(hex); const f = 1 + amt; return rgbToHex(r * f, g * f, b * f); }
function tint(hex, amt) { const [r, g, b] = hexToRgb(hex); return rgbToHex(r + (255 - r) * amt, g + (255 - g) * amt, b + (255 - b) * amt); }

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
