Files
shoptime/com.twin.app.shoptime/THEME_PRODUCT_UI_ANALYSIS.md
optrader ec5829eebe [251122] fix: DetailPanel Theme,Hotels - 1
🕐 커밋 시간: 2025. 11. 22. 21:21:31

📊 변경 통계:
  • 총 파일: 10개
  • 추가: +76줄
  • 삭제: -19줄

📁 추가된 파일:
  + com.twin.app.shoptime/HOTEL_UI_HANDLING_REPORT.md
  + com.twin.app.shoptime/HOTEL_UI_VISUAL_GUIDE.md
  + com.twin.app.shoptime/THEME_PRODUCT_UI_ANALYSIS.md
  + com.twin.app.shoptime/THEME_PRODUCT_VISUAL_GUIDE.md
  + com.twin.app.shoptime/THEME_VS_HOTEL_COMPARISON.md
  + com.twin.app.shoptime/docs/todo/251122-detailpanel-diff.md

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/api/TAxios.js
  ~ com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
     Added: extractProductMeta()
    🔄 Modified: SpotlightContainerDecorator()
     Deleted: SpotlightContainerDecorator()
  📄 com.twin.app.shoptime/HOTEL_UI_HANDLING_REPORT.md (md파일):
     Added: useEffect(), dispatch(), ThemeProduct(), setLabel(), Map(), forEach(), filter(), findIndex(), setAmenitiesInfos(), setSelectedIndex()
  📄 com.twin.app.shoptime/HOTEL_UI_VISUAL_GUIDE.md (md파일):
     Added: DetailPanel(), getThemeCurationDetailInfo(), getThemeHotelDetailInfo(), getMainCategoryDetail(), THeader(), TBody(), ThemeProduct(), Container(), rating(), TButton(), YouMayLike(), MobileSendPopUp(), selectedIndex(), setSelectedIndex(), ellipsis(), amenitiesBox(), c70850(), handleSMSClick(), dispatch(), clearThemeDetail()
  📄 com.twin.app.shoptime/THEME_PRODUCT_UI_ANALYSIS.md (md파일):
     Added: getThemeCurationDetailInfo(), productData(), Container(), ShowSingleOption(), ShowUnableOption(), useState(), useEffect(), setSelectedImage(), useMemo(), dispatch(), startVideoPlayer(), productDescription(), setTimeout(), useCallback(), isProductSoldOut(), ProductOption(), setSelectedIndex()
  📄 com.twin.app.shoptime/THEME_PRODUCT_VISUAL_GUIDE.md (md파일):
     Added: ShowProduct(), Container(), optionContainer(), ShowSingleOption(), ProductOption(), ShowUnableOption(), UnableOption(), selectedIndex(), setSelectedIndex(), descriptionClick(), setTabLabel(), setDescription(), dispatch(), handleIndicatorOptions(), handleSMSClick(), handleMobileSendPopupOpen(), setImageSelectedIndex(), StarRating(), ProductTag(), SingleOption()
  📄 com.twin.app.shoptime/THEME_VS_HOTEL_COMPARISON.md (md파일):
     Added: Product(), UnableOption(), StarRating(), dispatch(), getThemeCurationDetailInfo(), getThemeHotelDetailInfo()

🔧 주요 변경 내용:
  • API 서비스 레이어 개선
  • 개발 문서 및 가이드 개선
2025-11-22 21:21:32 +09:00

17 KiB

Theme Product UI 처리 상세 분석 보고서

📋 개요

Theme Product (쇼/공연 상품)는 DetailPanel에서 panelInfo.type === "theme"일 때 렌더링되며, ShowProduct 컴포넌트를 통해 UI를 구성합니다.


🔄 데이터 흐름

DetailPanel (type: "theme" 감지)
    ↓
getThemeCurationDetailInfo() 액션 디스패치
    ↓
Redux Reducer: GET_THEME_CURATION_DETAIL_INFO
    ↓
state.home.themeCurationDetailInfoData[] (상품 배열)
state.home.productData (테마 정보)
    ↓
ShowProduct 컴포넌트 렌더링

🏗️ Redux 상태 구조

A. 상품 목록 데이터 (themeCurationDetailInfoData)

// state.home.themeCurationDetailInfoData: array of products
[
  {
    // 기본 정보
    prdtId: "PROD001",
    prdtNm: "Opera Show",
    patncNm: "Broadway Partners",
    patnrId: "PARTNER001",
    patncLogoPath: "http://logo-url.png",

    // 이미지
    imgUrls600: ["url1", "url2", "url3"],
    thumbnailUrl960: "thumbnail-url",

    // 가격 정보 ("|" 구분자로 여러 정보 포함)
    priceInfo: "299|199|Y|discount10|10%",
    //           ↑   ↑   ↑    ↑       ↑
    //        원가 할인가 보상적용 할인코드 할인율

    // 상품 상태
    soldoutFlag: "N",  // "Y" or "N"
    pmtSuptYn: "Y",    // 결제 지원 여부

    // 분류
    catCd: "CATE001",
    catNm: "Performance",

    // 평점
    revwGrd: 4.5,

    // 비디오/미디어
    prdtMediaUrl: "http://video-url",
    prdtMediaSubtitlUrl: "http://subtitle-url",

    // 특수 정보
    todaySpclFlag: "Y",  // 오늘의 특가
    showId: "SHOW001",
    showNm: "Opera Night",
    orderPhnNo: "1-800-123-4567",
    disclaimer: "Disclaimer text",

    // QR 코드
    qrcodeUrl: "qr-data-string"
  },
  // ... 여러 쇼/상품
]

B. 테마 정보 (productData)

// state.home.productData
{
  themeInfo: [
    {
      curationId: "CURATION001",
      curationNm: "Theater Theme",
      patnrId: "PARTNER001",
      patncNm: "Broadway Partners",
      brndNm: "Broadway",
      priceInfo: "299|199|Y|discount10|10%"
    }
  ]
}

🎨 ShowProduct 컴포넌트 구조

렌더링 흐름

ShowProduct
├── Container (Spotlight)
   ├── ThemeIndicator
      ├── 선택된 상품 이미지 표시 (메인)
      ├── 비디오 자동 재생 (옵션)
      └── 이미지 썸네일 스크롤
   └── IndicatorOptions
       ├── 상품명, 로고, 평점
       ├── 설명 버튼
       ├── SMS 버튼
       └── QR 코드
└── optionContainer
    ├── ShowSingleOption (결제 가능)
       └── ProductOption
           └── SingleOption
    └── ShowUnableOption (결제 불가)
        └── ProductOption
            └── UnableOption

🖼️ ThemeIndicator 컴포넌트

주요 기능

1. 이미지 선택 관리

const [imageSelectedIndex, setImageSelectedIndex] = useState(0);
const [selectedImage, setSelectedImage] = useState(null);

useEffect(() => {
  if (thumbnailUrls) {
    // 비디오가 있으면 [0] = 비디오, [1]부터 이미지
    if (getProductMediaUrlStatus) {
      const image = thumbnailUrls[imageSelectedIndex - 1];
      return setSelectedImage(image);
    } else {
      // 비디오 없으면 [0]부터 이미지
      const image = thumbnailUrls[imageSelectedIndex];
      setSelectedImage(image);
    }
  }
}, [thumbnailUrls, getProductMediaUrlStatus, imageSelectedIndex]);

2. 비디오 자동 재생

const canPlayVideo = useMemo(() => {
  return themeProductInfo?.prdtMediaUrl && imageSelectedIndex === 0;
}, [themeProductInfo, imageSelectedIndex]);

useEffect(() => {
  if (!launchedFromPlayer && autoPlaying && themeProductInfo?.prdtMediaUrl) {
    dispatch(
      startVideoPlayer({
        showUrl: themeProductInfo?.prdtMediaUrl,
        showNm: themeProductInfo?.prdtNm,
        subtitle: themeProductInfo?.prdtMediaSubtitlUrl,
        thumbnailUrl: themeProductInfo?.thumbnailUrl960,
        // ... 더 많은 정보
      })
    );
  }
}, [dispatch, autoPlaying, imageSelectedIndex]);

3. 이미지 스크롤 (TVirtualGridList)

<TVirtualGridList
  // 썸네일 목록 표시
  // 상하 스크롤로 네비게이션
  itemSize={IMAGE_HEIGHT}  // 152px (scaleH 적용)
  items={/* 이미지 배열 */}
  onScroll={handleScroll}
/>

📝 IndicatorOptions 컴포넌트

구성 요소

1. 상단 정보 영역

<div className={css.topLayer}>
  <CustomImage
    src={productInfo?.patncLogoPath}
    fallbackSrc={defaultLogoImg}
  />
  {productInfo?.expsPrdtNo && (
    <div>ID: {productInfo?.expsPrdtNo}</div>
  )}
</div>

<div className={css.title}>
  {productInfo?.prdtNm}
</div>

<div className={css.bottomLayer}>
  <StarRating rating={productInfo?.revwGrd} />
  <ProductTag productInfo={productInfo} />
</div>

2. 버튼 영역

{isBillingProductVisible && (
  <TButtonScroller>
    <TButtonTab
      onClick={() => descriptionClick("DESCRIPTION", description)}
      spotlightId="description_Btn"
    >
      {$L("DESCRIPTION")}
    </TButtonTab>
    <TButtonTab
      onClick={() => descriptionClick("RETURNS & EXCHANGES", exchangeInfo)}
      spotlightId="return_Exchanges_Btn"
    >
      {$L("RETURNS & EXCHANGES")}
    </TButtonTab>
  </TButtonScroller>
)}

<TButtonTab
  onClick={handleSMSClick}
  spotlightId="shopbymobile_Btn"
>
  {$L("SHOP BY MOBILE")}
</TButtonTab>

3. QR 코드

<div className={css.qrcodeContainer}>
  <TQRCode
    text={qrCodeUrl}  // 결제가능 상품은 detailUrl 사용
    width="140"
    height="140"
  />
  <div className={css.tooltip}>
    <div className={css.tooltipBody}>
      Please check for more detailed information
    </div>
  </div>
</div>

4. 설명 팝업

const renderPopUp = () => {
  return (
    <TPopUp
      kind="descriptionPopup"
      open={popupVisible}
      onClose={handleSMSonClose}
    >
      <div className={css.popUpHeader}>
        <img src={thumbnailUrl} alt="" />
        <img src={productInfo?.patncLogoPath} alt="" />
        <h3>{productInfo?.prdtNm}</h3>
        <StarRating rating={productInfo?.revwGrd} />
      </div>
      <div
        className={css.popUpBody}
        dangerouslySetInnerHTML={productDescription()}
      />
    </TPopUp>
  );
};

💳 결제 여부에 따른 UI 분기

ShowSingleOption (pmtSuptYn === "Y" && webOSVersion >= "6.0")

<ShowSingleOption
  productData={productData}
  productInfo={productInfo}
  selectedIndex={selectedIndex}
  soldoutFlag={isSoldout}
  logMenu={logMenu}
/>

구성:

// ShowOptions/ShowSingleOption.jsx
<ProductOption productInfo={productInfo[selectedIndex]}>
  <SingleOption
    type="theme"
    selectedPatnrId={productData?.themeInfo[0]?.patnrId}
    selectedPrdtId={productInfo[selectedIndex]?.prdtId}
    productInfo={productInfo[selectedIndex]}
    patncNm={productData?.themeInfo[0]?.patncNm}
    soldoutFlag={soldoutFlag}
    // ...
  />
</ProductOption>

SingleOption 렌더링:

  • 상품 옵션 선택 (이용일자, 좌석 등)
  • 수량 선택
  • 가격 표시
  • "구매" 버튼

ShowUnableOption (결제 불가능)

조건:

  • pmtSuptYn === "N" OR
  • webOSVersion < "6.0"
<ShowUnableOption
  productInfo={showProductInfo}
  productData={productData}
  soldoutFlag={isSoldout}
  selectedCurationId={selectedCurationId}
  selectedCurationNm={selectedCurationNm}
  handleMobileSendPopupOpen={handleMobileSendPopupOpen}
  logMenu={logMenu}
/>

구성:

// ShowOptions/ShowUnableOption.jsx
<ProductOption productInfo={productInfo}>
  <UnableOption
    selectedPatnrId={productData?.themeInfo[0]?.patnrId}
    selectedPrdtId={productInfo?.prdtId}
    productInfo={productInfo}
    soldoutFlag={soldoutFlag}
    smsTpCd="APP00204"
    handleMobileSendPopupOpen={handleMobileSendPopupOpen}
    // ...
  />
</ProductOption>

UnableOption 렌더링:

  • 구매 불가 이유 표시
  • SMS로 상품 정보 공유 버튼
  • 로고, 상품명, 가격
  • "SEE MORE" 또는 "SHOP BY MOBILE" 버튼

📊 선택 인덱스 관리

// ShowProduct.jsx에서 selectedIndex 상태 관리
const showProductInfo = useMemo(() => {
  if (productData && productInfo) {
    const themeInfo = productData?.themeInfo[0];

    if (themeInfo) {
      return {
        ...productInfo[selectedIndex],  // ← selectedIndex로 배열 접근
        curationId: themeInfo.curationId,
        curationNm: themeInfo.curationNm,
        expsOrd: `${selectedIndex + 1}`,  // 순번 (1부터)
      };
    }
  }
  return {};
}, [productData, productInfo, selectedIndex]);

UI에서 selectedIndex 변경:

<ThemeIndicator
  themeProductInfos={productInfo}
  selectedIndex={selectedIndex}
  setSelectedIndex={setSelectedIndex}  //  화살표로 변경
  // ...
/>

🔍 데이터 매핑 상세

priceInfo 파싱

// priceInfo 형식: "원가|할인가|보상적용|할인코드|할인율"
const priceInfo = "299|199|Y|discount10|10%";

const befPrice = priceInfo.split("|")[0];      // "299" (원가)
const lastPrice = priceInfo.split("|")[1];     // "199" (할인가)
const rewdAplyFlag = priceInfo.split("|")[2];  // "Y" (보상 적용)
const discountCode = priceInfo.split("|")[3];  // "discount10"
const discountRate = priceInfo.split("|")[4];  // "10%"

로그 전송 시:

const params = {
  befPrice: showProductInfo?.priceInfo?.split("|")[0],
  lastPrice: showProductInfo?.priceInfo?.split("|")[1],
  rewdAplyFlag: showProductInfo?.priceInfo?.split("|")[2],
  // ...
};

📱 비디오 처리

자동 재생 조건

const [autoPlaying, setAutoPlaying] = useState(
  !launchedFromPlayer && themeProductInfo?.prdtMediaUrl
);
// - Player에서 오지 않았고 (launchedFromPlayer = false)
// - 비디오 URL이 있을 때만 자동 재생

이미지/비디오 순서

// 비디오가 있는 경우 썸네일 구성:
// [0] = 비디오 플레이 버튼
// [1] = 첫 번째 이미지
// [2] = 두 번째 이미지
// ...

// imageSelectedIndex = 0 → 비디오 재생
// imageSelectedIndex = 1 → 첫 번째 이미지 표시

🔔 로깅 시스템

1. 상세 정보 조회 로그 (500ms 딜레이)

useEffect(() => {
  if (showProductInfo && Object.keys(showProductInfo).length > 0) {
    const params = {
      befPrice: showProductInfo?.priceInfo?.split("|")[0],
      curationId: showProductInfo?.curationId,
      curationNm: showProductInfo?.curationNm,
      expsOrd: showProductInfo?.expsOrd,  // 1부터 시작하는 순번
      lgCatCd: showProductInfo?.catCd,
      lgCatNm: showProductInfo?.catNm,
      logTpNo: LOG_TP_NO.PRODUCT.PRODUCT_DETAIL_IMAGE,
      prdtId: showProductInfo?.prdtId,
      prdtNm: showProductInfo?.prdtNm,
      revwGrd: showProductInfo?.revwGrd,
      tsvFlag: showProductInfo?.todaySpclFlag,
    };

    timerRef.current = setTimeout(
      () => dispatch(sendLogProductDetail(params)),
      1000
    );
  }
}, [showProductInfo]);

2. 설명 버튼 클릭 로그

const handleIndicatorOptions = useCallback(() => {
  if (productData && Object.keys(productData).length > 0) {
    const params = {
      ...detailLogParamsRef.current,
      logTpNo: LOG_TP_NO.DETAIL.DETAIL_BUTTON_CLICK,  // 버튼 클릭
    };
    dispatch(sendLogDetail(params));
    dispatch(sendLogTotalRecommend({
      menu: LOG_MENU.DETAIL_PAGE_THEME_DETAIL,
      buttonTitle: "DESCRIPTION",
      messageId: LOG_MESSAGE_ID.BUTTONCLICK,
    }));
  }
}, [productData]);

3. SMS 버튼 클릭 로그

const handleMobileSendPopupOpen = useCallback(() => {
  // ... SMS 팝업 로그

  const params = {
    patncNm: showProductInfo?.patncNm,
    prdtId: showProductInfo?.prdtId,
    prdtNm: showProductInfo?.prdtNm,
    shopByMobileFlag: "Y",
    shopTpNm: "product",  // ← 테마 상품 구분
    showId: showProductInfo?.showId,
    showNm: showProductInfo?.showNm,
    // ...
  };
  dispatch(sendLogShopByMobile(params));
}, [showProductInfo]);

🎯 가시성(Visibility) 조건

결제 가능 상품 표시

const isBillingProductVisible = useMemo(() => {
  return (
    productInfo &&
    productInfo[selectedIndex]?.pmtSuptYn === "Y" &&
    webOSVersion >= "6.0"
  );
}, [productData, webOSVersion, selectedIndex]);

결제 불가능 상품 표시

const isUnavailableProductVisible = useMemo(() => {
  return (
    showProductInfo &&
    productInfo &&
    (productInfo[selectedIndex]?.pmtSuptYn === "N" || webOSVersion < "6.0")
  );
}, [showProductInfo, productInfo, webOSVersion, selectedIndex]);

📊 상품 매진 상태

const isProductSoldOut = () => {
  if (
    productInfo &&
    productInfo.length > selectedIndex &&
    selectedIndex >= 0
  ) {
    return productInfo[selectedIndex]?.soldoutFlag === "Y";
  }
  return false;
};

const isSoldout = isProductSoldOut();

// UI에서 사용
<ThemeIndicator
  soldoutFlag={isSoldout}
  // 매진 상태면 이미지에 매진 배지 표시
/>

🎬 ProductOption 래퍼

모든 옵션(SingleOption, UnableOption)은 ProductOption으로 감싸짐:

<ProductOption productInfo={productInfo[selectedIndex]}>
  <SingleOption {...props} />
</ProductOption>

ProductOption 역할:

export default function ProductOption({ children, productInfo }) {
  return (
    <Container className={css.optionContainer}>
      {productInfo && (
        <div className={css.contentHeader}>
          {/* 로고 */}
          <CustomImage src={productInfo?.patncLogoPath} />

          {/* 상품 ID */}
          {productInfo?.expsPrdtNo && (
            <div>ID: {productInfo?.expsPrdtNo}</div>
          )}

          {/* 상품명 */}
          <div className={css.title}>
            {productInfo?.prdtNm}
          </div>

          {/* 평점 + 태그 */}
          <StarRating rating={productInfo?.revwGrd} />
          <ProductTag productInfo={productInfo} />
        </div>
      )}
      {children}
    </Container>
  );
}

⚙️ DetailPanel과의 통합

// DetailPanel.backup.jsx에서 Theme Product 처리
{isTravelProductVisible && (
  <ThemeProduct
    themeType="theme"  // ← 테마 타입 지정
    selectedIndex={selectedIndex}
    setSelectedIndex={setSelectedIndex}
    panelInfo={panelInfo}
    selectedCurationId={panelInfo?.curationId}
    selectedCurationNm={panelInfo?.curationNm}
    selectedPatnrId={panelInfo?.patnrId}
    shopByMobileLogRef={shopByMobileLogRef}
    isYouMayLikeOpened={isYouMayLikeOpened}
  />
)}

const isTravelProductVisible = useMemo(() => {
  return panelInfo?.curationId && (hotelInfos || themeData);
}, [panelInfo?.curationId, hotelInfos, themeData]);

📈 상태 관리 흐름

사용자 액션
    ↓
화살표 키 (▲▼◄►) 입력
    ↓
setSelectedIndex() 호출
    ↓
selectedIndex 상태 변경
    ↓
showProductInfo 리메모이제이션
    ↓
useEffect: 이미지/가격/상품정보 업데이트
    ↓
UI 리렌더링 (이미지, 가격, 옵션)
    ↓
로그 전송 (500ms 후)

📝 요약표

항목 설명
데이터 소스 state.home.themeCurationDetailInfoData[]
테마 정보 state.home.productData.themeInfo[0]
선택 관리 selectedIndex (배열 인덱스)
이미지 배열 productInfo[i].imgUrls600[]
비디오 productInfo[i].prdtMediaUrl (선택사항)
가격 형식 "원가|할인가|보상|코드|할인율"
매진 여부 soldoutFlag: "Y"/"N"
결제 여부 pmtSuptYn: "Y"/"N"
평점 revwGrd: 0~5
QR 코드 qrcodeUrl (상품별) 또는 detailQRCodeUrl (결제용)
SMS 타입 "APP00204" (테마 상품)
로그 타입 shopTpNm: "product" (테마) vs "hotel" (호텔)

🔗 관련 컴포넌트

  • ThemeProduct.jsx : 타입 분기 (ShowProduct 호출)
  • ShowProduct.jsx : 메인 렌더링 컴포넌트
  • ThemeIndicator.jsx : 이미지 갤러리 + 비디오
  • IndicatorOptions.jsx : 상품 정보 + 버튼
  • ProductOption.jsx : 로고 + 상품명 래퍼
  • ShowSingleOption.jsx : 결제가능 상품
  • ShowUnableOption.jsx : 결제불가 상품
  • SingleOption : 실제 구매 UI
  • UnableOption : 구매불가 UI

🎓 핵심 포인트

  1. 다중 상품 선택: selectedIndex로 배열 내 상품 관리
  2. 비디오 통합: 첫 번째 이미지 위치에 비디오 플레이 버튼
  3. 가격 정보: 파이프(|)로 구분된 복합 정보 (원가, 할인가, 할인율)
  4. 조건부 렌더링: pmtSuptYn & webOSVersion으로 UI 분기
  5. 상세 로깅: 모든 사용자 상호작용 추적 (클릭, 선택, 노출)
  6. SMS 공유: 결제불가 상품도 SMS로 정보 공유 가능 (shopTpNm: "product")