[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 서비스 레이어 개선
  • 개발 문서 및 가이드 개선
This commit is contained in:
2025-11-22 21:21:32 +09:00
parent ce51902150
commit ec5829eebe
10 changed files with 2514 additions and 19 deletions

View File

@@ -0,0 +1,692 @@
# 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)
```javascript
// 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)
```javascript
// state.home.productData
{
themeInfo: [
{
curationId: "CURATION001",
curationNm: "Theater Theme",
patnrId: "PARTNER001",
patncNm: "Broadway Partners",
brndNm: "Broadway",
priceInfo: "299|199|Y|discount10|10%"
}
]
}
```
---
## 🎨 ShowProduct 컴포넌트 구조
### 렌더링 흐름
```jsx
ShowProduct
├── Container (Spotlight)
├── ThemeIndicator
├── 선택된 상품 이미지 표시 (메인)
├── 비디오 자동 재생 (옵션)
└── 이미지 썸네일 스크롤
└── IndicatorOptions
├── 상품명, 로고, 평점
├── 설명 버튼
├── SMS 버튼
└── QR 코드
└── optionContainer
├── ShowSingleOption (결제 가능)
└── ProductOption
└── SingleOption
└── ShowUnableOption (결제 불가)
└── ProductOption
└── UnableOption
```
---
## 🖼️ ThemeIndicator 컴포넌트
### 주요 기능
#### 1. 이미지 선택 관리
```javascript
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. 비디오 자동 재생
```javascript
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)
```jsx
<TVirtualGridList
// 썸네일 목록 표시
// 상하 스크롤로 네비게이션
itemSize={IMAGE_HEIGHT} // 152px (scaleH 적용)
items={/* 이미지 배열 */}
onScroll={handleScroll}
/>
```
---
## 📝 IndicatorOptions 컴포넌트
### 구성 요소
#### 1. 상단 정보 영역
```jsx
<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. 버튼 영역
```jsx
{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 코드
```jsx
<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. 설명 팝업
```jsx
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")
```jsx
<ShowSingleOption
productData={productData}
productInfo={productInfo}
selectedIndex={selectedIndex}
soldoutFlag={isSoldout}
logMenu={logMenu}
/>
```
**구성:**
```jsx
// 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"`
```jsx
<ShowUnableOption
productInfo={showProductInfo}
productData={productData}
soldoutFlag={isSoldout}
selectedCurationId={selectedCurationId}
selectedCurationNm={selectedCurationNm}
handleMobileSendPopupOpen={handleMobileSendPopupOpen}
logMenu={logMenu}
/>
```
**구성:**
```jsx
// 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" 버튼
---
## 📊 선택 인덱스 관리
```javascript
// 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 변경:**
```jsx
<ThemeIndicator
themeProductInfos={productInfo}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex} // 화살표로 변경
// ...
/>
```
---
## 🔍 데이터 매핑 상세
### priceInfo 파싱
```javascript
// 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%"
```
**로그 전송 시:**
```javascript
const params = {
befPrice: showProductInfo?.priceInfo?.split("|")[0],
lastPrice: showProductInfo?.priceInfo?.split("|")[1],
rewdAplyFlag: showProductInfo?.priceInfo?.split("|")[2],
// ...
};
```
---
## 📱 비디오 처리
### 자동 재생 조건
```javascript
const [autoPlaying, setAutoPlaying] = useState(
!launchedFromPlayer && themeProductInfo?.prdtMediaUrl
);
// - Player에서 오지 않았고 (launchedFromPlayer = false)
// - 비디오 URL이 있을 때만 자동 재생
```
### 이미지/비디오 순서
```javascript
// 비디오가 있는 경우 썸네일 구성:
// [0] = 비디오 플레이 버튼
// [1] = 첫 번째 이미지
// [2] = 두 번째 이미지
// ...
// imageSelectedIndex = 0 → 비디오 재생
// imageSelectedIndex = 1 → 첫 번째 이미지 표시
```
---
## 🔔 로깅 시스템
### 1. 상세 정보 조회 로그 (500ms 딜레이)
```javascript
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. 설명 버튼 클릭 로그
```javascript
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 버튼 클릭 로그
```javascript
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) 조건
### 결제 가능 상품 표시
```javascript
const isBillingProductVisible = useMemo(() => {
return (
productInfo &&
productInfo[selectedIndex]?.pmtSuptYn === "Y" &&
webOSVersion >= "6.0"
);
}, [productData, webOSVersion, selectedIndex]);
```
### 결제 불가능 상품 표시
```javascript
const isUnavailableProductVisible = useMemo(() => {
return (
showProductInfo &&
productInfo &&
(productInfo[selectedIndex]?.pmtSuptYn === "N" || webOSVersion < "6.0")
);
}, [showProductInfo, productInfo, webOSVersion, selectedIndex]);
```
---
## 📊 상품 매진 상태
```javascript
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으로 감싸짐:
```jsx
<ProductOption productInfo={productInfo[selectedIndex]}>
<SingleOption {...props} />
</ProductOption>
```
**ProductOption 역할:**
```jsx
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과의 통합
```javascript
// 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"`)