[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,389 @@
# DetailPanel 컴포넌트의 Hotels/여행상품(Theme) UI 처리 분석 보고서
## 📋 개요
DetailPanel 컴포넌트는 **3가지 상품 타입**을 지원합니다:
1. **Single Product** (결제가능 상품)
2. **Group Product** (그룹상품)
3. **Travel/Theme Product** (여행상품/테마)
- `type === "theme"` : ShowProduct (테마상품)
- `type === "hotel"` : HotelProduct (호텔상품)
---
## 🏗️ 아키텍처 흐름
### 1. 데이터 로딩 흐름 (DetailPanel.backup.jsx)
```javascript
// panelInfo.type에 따른 데이터 로딩
useEffect(() => {
if (panelInfo?.type === "hotel") {
// 호텔 상세정보 요청
dispatch(getThemeHotelDetailInfo({
patnrId: panelInfo?.patnrId,
curationId: panelInfo?.curationId,
}));
}
if (panelInfo?.type === "theme") {
// 테마 상세정보 요청
dispatch(getThemeCurationDetailInfo({
patnrId: panelInfo?.patnrId,
curationId: panelInfo?.curationId,
bgImgNo: panelInfo?.bgImgNo,
}));
}
}, [panelInfo.type, panelInfo.curationId, ...]);
```
**Redux State 구조:**
```javascript
// homeReducer.js
const initialState = {
themeCurationDetailInfoData: [], // 테마 상품 데이터
themeCurationHotelDetailData: [], // 호텔 상세 데이터
hotelData: {}, // 호텔 통합 정보
productData: {}, // 테마 정보
};
```
---
## 🎨 UI 렌더링 로직
### 2. ThemeProduct 컴포넌트 (라우팅)
```jsx
// ThemeProduct.jsx - 타입에 따른 조건부 렌더링
export default function ThemeProduct({
themeType, // "theme" 또는 "hotel"
selectedIndex,
setSelectedIndex,
...
}) {
return (
<div className={css.container}>
{themeType === "theme" && (
<ShowProduct {...props} />
)}
{themeType === "hotel" && (
<HotelProduct {...props} />
)}
</div>
);
}
```
---
## 🏨 호텔 상품 UI 처리 (HotelProduct.jsx)
### 3. HotelProduct 컴포넌트 구조
#### A. 이미지 갤러리 영역
```jsx
<ThemeIndicator
themeProductInfos={hotelInfos}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
thumbnailUrls={hotelInfos[selectedIndex]?.hotelDetailInfo.imgUrls}
/>
```
- 호텔 이미지들을 인디케이터로 표시
- 선택된 인덱스에 따라 이미지 변경
#### B. 주소/위치 정보
```jsx
<IndicatorOptions
address={hotelInfos[selectedIndex]?.hotelDetailInfo.hotelAddr}
/>
```
#### C. 호텔 정보 카드 영역
```jsx
<div className={css.optionContainer}>
{/* 1. 상단 레이어: 로고 + 별점 + 등급 */}
<div className={css.topLayer}>
<img src={hotelData?.hotelInfo.patncLogoPath} alt="" />
<div className={css.rating}>
<StarRating
rating={hotelInfos[selectedIndex]?.hotelDetailInfo.revwGrd}
/>
<span className={css.line} />
<div>{label}</div>
</div>
</div>
{/* 2. 제목: [호텔타입] 호텔명 */}
<div className={css.title}>
[{hotelInfos[selectedIndex]?.hotelDetailInfo.hotelType}]&nbsp;
{hotelInfos[selectedIndex]?.hotelNm}
</div>
{/* 3. 편의시설 그리드 (최대 10개) */}
<div className={css.amenitiesCotainer}>
{amenitiesInfos && amenitiesInfos.map((item) => (
<div className={css.amenitiesBox} key={item.amntId}>
<img src={item.lgAmntImgUrl} alt="" />
<p>{item.lgAmntNm}</p>
</div>
))}
</div>
{/* 4. 하단 레이어: 예약 정보 + 가격 + QR코드 */}
<div className={css.bottomLayer}>
<div>
<div className={css.today}>
{nights}Nights {adultsCount}Adults
</div>
<div className={css.roomType}>
{hotelInfos[selectedIndex]?.hotelDetailInfo.roomType}
</div>
<div className={css.price}>
<div>From</div>
<p>
{hotelInfos[selectedIndex]?.hotelDetailInfo.currencySign}
{hotelInfos[selectedIndex]?.hotelDetailInfo.price}
</p>
</div>
</div>
<div className={css.qrcodeContainer}>
<TQRCode
text={hotelInfos[selectedIndex]?.qrcodeUrl}
width="160"
height="160"
/>
</div>
</div>
{/* 5. CTA 버튼: "SEE MORE" */}
<TButton
className={css.tbutton}
size="extra"
onClick={handleSMSClick}
spotlightId="shopbymobile_Btn"
>
{$L("SEE MORE")}
</TButton>
</div>
```
---
## 🎯 데이터 매핑 상세 설명
### 4. 호텔 데이터 구조 (Redux에서)
```javascript
hotelInfos: [
{
hotelId: "string",
hotelNm: "Hotel Name",
hotelImgUrl: "url",
imgUrls600: ["url1", "url2", ...],
qrcodeUrl: "qr-code-data",
hotelDetailInfo: {
hotelAddr: "Address",
hotelType: "Luxury/Budget/etc",
price: "299",
currencySign: "$",
revwGrd: 4.5, // 평점
nights: 2,
adultsCount: 2,
roomType: "Double Room",
amenities: ["amntId1", "amntId2", ...],
imgUrls: ["url1", "url2", ...]
}
},
// ... 여러 호텔
]
hotelData: {
hotelInfo: {
curationId: "string",
curationNm: "Theme Name",
patncNm: "Partner Name",
patnrId: "string",
patncLogoPath: "url"
},
amenities: [
{
amntId: "1",
lgAmntNm: "Free WiFi",
lgAmntImgUrl: "icon-url"
},
// ... 편의시설 목록
]
}
```
---
## ⭐ 별점 등급 매핑
```javascript
useEffect(() => {
let label = "";
let rating = hotelInfos[selectedIndex]?.hotelDetailInfo.revwGrd;
if (rating !== undefined) {
if (rating <= 2.4) label = $L("Fair");
else if (rating >= 2.5 && rating <= 3.4) label = $L("Good");
else if (rating >= 3.5 && rating <= 4.4) label = $L("Very Good");
else if (rating >= 4.5 && rating <= 5) label = $L("Excellent");
}
setLabel(label);
}, [selectedIndex, hotelInfos]);
```
---
## 🛏️ 편의시설 처리 로직
```javascript
const getAmenitiesInfo = () => {
const matchedData = new Set();
const amenitiesMap = new Map();
// 1. 전체 편의시설을 맵으로 변환
hotelData.amenities.forEach((item) => {
amenitiesMap.set(item.amntId, item);
});
// 2. 현재 호텔에 포함된 편의시설 필터링 (최대 10개)
hotelInfos[selectedIndex]?.hotelDetailInfo.amenities.forEach((amntId) => {
if (amenitiesMap.has(amntId) && matchedData.size < 10) {
matchedData.add(amenitiesMap.get(amntId));
}
});
// 3. 같은 카테고리의 중복 제거
let amenitiesArr = Array.from(matchedData);
amenitiesArr = amenitiesArr.filter((item, index, self) => {
return index === self.findIndex((t) => t.lgAmntNm === item.lgAmntNm);
});
setAmenitiesInfos(amenitiesArr);
};
```
---
## 📊 스타일링 (HotelProduct.module.less)
### 레이아웃 구성:
| 영역 | 크기 | 설명 |
|------|------|------|
| **themeContainer** | 774px × 930px | 이미지 갤러리 컨테이너 |
| **optionContainer** | 1026px × 990px | 호텔 정보 카드 |
| **topLayer** | 100% × auto | 로고 + 별점 섹션 |
| **title** | 100% × 84px | 호텔명 (2줄 말줄임) |
| **amenitiesCotainer** | 846px × 344px | 편의시설 그리드 |
| **amenitiesBox** | 138px × 138px | 개별 편의시설 |
| **bottomLayer** | auto × auto | 예약정보 + 가격 + QR코드 |
| **price** | auto × auto | 가격 표시 (큰 크기, 분홍색) |
| **qrcodeContainer** | 192px × 192px | QR코드 영역 |
---
## 🔄 선택 인덱스 관리
```javascript
useEffect(() => {
// URL 파라미터로 특정 호텔이 지정된 경우
if (hotelInfos && hotelInfos.length > 0 && panelInfo?.themeHotelId) {
for (let i = 0; i < hotelInfos.length; i++) {
if (hotelInfos[i].hotelId === panelInfo?.themeHotelId) {
setSelectedIndex(i);
}
}
}
}, [hotelInfos, panelInfo?.themeHotelId]);
```
---
## 📱 이미지 길이 업데이트
```javascript
useEffect(() => {
if ((hotelInfos && hotelInfos.length > 0) && selectedIndex !== undefined) {
if (panelInfo?.type === "hotel") {
const imgUrls600 = hotelInfos[selectedIndex]?.imgUrls600 || [];
dispatch(getProductImageLength({ imageLength: imgUrls600.length }));
}
}
}, [dispatch, selectedIndex, hotelInfos]);
```
---
## 🔔 로깅 및 추적
### A. 상세정보 조회 로그
```javascript
const params = {
befPrice: selectedHotelInfo?.hotelInfo?.hotelDetailInfo?.price,
curationId: selectedHotelInfo?.curationId,
curationNm: selectedHotelInfo?.curationNm,
expsOrd: selectedIndex + 1,
logTpNo: LOG_TP_NO.PRODUCT.PRODUCT_DETAIL_IMAGE,
prdtId: selectedHotelInfo?.hotelInfo?.hotelId,
prdtNm: selectedHotelInfo?.hotelInfo?.hotelNm,
revwGrd: selectedHotelInfo?.hotelInfo?.hotelDetailInfo?.revwGrd,
// ... 더 많은 필드
};
dispatch(sendLogProductDetail(params));
```
### B. SMS 보내기 버튼 로그
```javascript
const params = {
patncNm: selectedHotelInfo.patncNm,
prdtId: selectedHotelInfo.hotelInfo?.hotelDetailInfo?.hotelId,
prdtNm: selectedHotelInfo.hotelInfo?.hotelDetailInfo?.hotelNm,
shopTpNm: "hotel", // 호텔 타입 마킹
shopByMobileFlag: "Y",
price: selectedHotelInfo.hotelInfo?.hotelDetailInfo?.price,
// ...
};
dispatch(sendLogShopByMobile(params));
```
---
## 💡 주요 특징
| 기능 | 구현 |
|------|------|
| **동적 인덱스 선택** | 화살표 키로 호텔 선택 변경 가능 |
| **이미지 갤러리** | ThemeIndicator 컴포넌트로 여러 이미지 표시 |
| **평점 표시** | StarRating 컴포넌트로 시각화 |
| **편의시설 필터링** | 최대 10개, 중복 제거 |
| **가격 표시** | 통화 기호 + 숫자 (분홍색 강조) |
| **예약 정보** | 숙박일 수 + 성인 수 자동 포맷팅 |
| **QR코드** | 호텔 상세정보 링크 제공 |
| **SMS 기능** | "SEE MORE" 버튼으로 SMS 팝업 오픈 |
| **Spotlight** | 키보드 네비게이션 지원 |
---
## 🔗 관련 컴포넌트
- **ThemeProduct.jsx** : 라우팅 (theme vs hotel)
- **HotelProduct.jsx** : 호텔 UI 렌더링
- **ShowProduct.jsx** : 테마 상품 UI 렌더링
- **ThemeIndicator** : 이미지 갤러리
- **IndicatorOptions** : 주소/위치 표시
- **StarRating** : 별점 표시
- **TQRCode** : QR코드 생성
- **TButton** : 호텔/테마 액션 버튼
---
## 📝 요약
DetailPanel의 호텔 UI 처리는 **Redux 상태 기반의 선언적 렌더링**으로, 선택된 인덱스(`selectedIndex`)에 따라 해당 호텔의 이미지, 정보, 가격, 편의시설을 동적으로 표시합니다. 모든 상호작용(이미지 변경, 버튼 클릭)은 상세한 로깅을 통해 추적되며, SMS 연동으로 호텔 정보를 공유할 수 있습니다.

View File

@@ -0,0 +1,409 @@
# DetailPanel 호텔/여행상품 UI 처리 - 시각적 가이드
## 🔄 데이터 흐름 다이어그램
```
┌─────────────────────────────────────────────────────────────────┐
│ DetailPanel (Main) │
└────────────────┬────────────────────────────────────────────────┘
┌────────┴─────────┐
│ panelInfo.type │
└────────┬─────────┘
┌───────────┼───────────┐
│ │ │
"theme" "hotel" "product"
│ │ │
▼ ▼ ▼
┌─────────┐ ┌────────┐ ┌─────────┐
│ShowPrdt │ │HotelPdt│ │SinglePdt│
└────┬────┘ └────┬───┘ └────┬────┘
│ │ │
▼ ▼ ▼
Redux Actions Redux Actions Redux Actions
┌─────────────────────────────────────────┐
│ getThemeCurationDetailInfo() [THEME] │
│ getThemeHotelDetailInfo() [HOTEL] │
│ getMainCategoryDetail() [PRODUCT] │
└──────────────┬──────────────────────────┘
┌────────────────────┐
│ Redux Reducer │
│ │
│ - productData │
│ - hotelData │
│ - hotelInfos[] │
│ - themeInfos[] │
└────────────────────┘
┌────────────────────┐
│ UI Components │
│ │
│ - ThemeIndicator │
│ - HotelProduct │
│ - ShowProduct │
└────────────────────┘
```
---
## 🏨 HotelProduct UI 구조
```
┌──────────────────────────────────────────────────────────────────┐
│ HotelProduct Component │
└──────────────────┬───────────────────────────────────────────────┘
┌──────────┴──────────┐
│ │
▼ ▼
┌─────────────┐ ┌──────────────────┐
│ThemeIndicator │ │OptionContainer │
├─────────────┤ ├──────────────────┤
│ 이미지 갤러리 │ │ 호텔 정보 카드 │
│ 774×930px │ │ 1026×990px │
│ │ │ │
│ ┌─────────┐ │ │ ┌────────────┐ │
│ │ 이미지1 │ │ │ │ 로고 + 별점 │ │
│ │ │ │ │ ├────────────┤ │
│ │ ▲ ▼ │ │ │ │ 호텔명 │ │
│ │ [•••] │ │ │ │ [타입]호텔 │ │
│ │ │ │ │ ├────────────┤ │
│ │ ◀ ───► │ │ │ │ 편의시설 │ │
│ │ 이미지2 │ │ │ │ [아이콘] │ │
│ │ 이미지3 │ │ │ │ [아이콘] │ │
│ └─────────┘ │ │ ├────────────┤ │
│ │ │ │ 예약정보 │ │
└─────────────┘ │ │ N Nights │ │
│ │ M Adults │ │
│ │ Room Type │ │
│ ├────────────┤ │
│ │ From │ │
│ │ $299 ◀─────┼──┼→ QR Code
│ │ (분홍색) │ │ 160×160px
│ ├────────────┤ │
│ │[SEE MORE]btn│ │
│ └────────────┘ │
└──────────────────┘
```
---
## 📊 컴포넌트 계층도 (DetailPanel)
```
DetailPanel
├── TPanel
│ ├── THeader (제목: 호텔명/테마명)
│ └── TBody (스크롤 가능 영역)
│ └── ThemeProduct (조건부 렌더링)
│ ├── ShowProduct
│ │ ├── Container (Spotlight)
│ │ │ ├── ThemeIndicator
│ │ │ └── IndicatorOptions
│ │ └── optionContainer
│ │ ├── ShowSingleOption
│ │ └── ShowUnableOption
│ │
│ └── HotelProduct ◀── 호텔 전용
│ ├── Container (Spotlight)
│ │ ├── ThemeIndicator
│ │ └── IndicatorOptions
│ └── optionContainer
│ ├── topLayer
│ │ ├── img (로고)
│ │ └── rating (별점)
│ ├── title
│ ├── amenitiesCotainer
│ │ └── amenitiesBox[] (반복)
│ ├── bottomLayer
│ │ ├── 예약정보
│ │ ├── 가격
│ │ └── QR코드
│ └── TButton (SEE MORE)
├── YouMayLike (조건부)
└── MobileSendPopUp (조건부)
```
---
## 🎯 선택 인덱스 상태 관리
```javascript
HotelProduct
├─ selectedIndex (props)
└─ 현재 선택된 호텔의 배열 인덱스
├─ hotelInfos[selectedIndex] 호텔 데이터
├─ hotelInfos[selectedIndex].hotelNm 호텔명
├─ hotelInfos[selectedIndex].hotelDetailInfo.price 가격
└─ hotelInfos[selectedIndex].hotelDetailInfo.amenities 편의시설 ID 배열
└─ setSelectedIndex (callback)
└─ ThemeIndicator 화살표 클릭 호출
├─ UP / LEFT selectedIndex - 1
└─ DOWN / RIGHT selectedIndex + 1
```
---
## 💾 Redux 상태 구조 (호텔 데이터)
```javascript
state.home = {
// 호텔 목록 (배열)
themeCurationHotelDetailData: [
{
hotelId: "HOTEL001",
hotelNm: "Luxury Hotel",
hotelImgUrl: "http://...",
imgUrls600: ["url1", "url2", "url3"],
qrcodeUrl: "qrcode-data",
hotelDetailInfo: {
hotelAddr: "123 Main St, NYC",
hotelType: "5-star",
price: "299.99",
currencySign: "$",
revwGrd: 4.8, // 평점
nights: 2, // 숙박일 수
adultsCount: 2, // 성인 수
roomType: "Deluxe Double Room",
amenities: ["AMN001", "AMN002", "AMN005"], // 편의시설 ID
imgUrls: ["url1", "url2", "url3"]
}
},
{ /* 다음 호텔 */ }
],
// 호텔 통합 정보
hotelData: {
hotelInfo: {
curationId: "CURATION001",
curationNm: "Dubai Vacation",
patncNm: "Travel Partner",
patnrId: "PARTNER001",
patncLogoPath: "http://logo-url"
},
amenities: [
{
amntId: "AMN001",
lgAmntNm: "Free WiFi",
lgAmntImgUrl: "http://icon-wifi.png"
},
{
amntId: "AMN002",
lgAmntNm: "Swimming Pool",
lgAmntImgUrl: "http://icon-pool.png"
},
{
amntId: "AMN005",
lgAmntNm: "Spa",
lgAmntImgUrl: "http://icon-spa.png"
}
// ... 더 많은 편의시설
]
}
}
```
---
## 🎨 스타일 적용 흐름
```
HotelProduct.module.less
├─ .themeContainer
│ └─ ThemeIndicator 감싸기
│ ├─ width: 774px
│ └─ height: 930px
├─ .optionContainer
│ └─ 호텔 정보 카드
│ ├─ width: 1026px
│ ├─ height: 990px
│ ├─ padding: 30px 120px 60px 60px
│ └─ background: #f9f9f9
├─ .topLayer
│ └─ display: flex
│ ├─ justify-content: space-between
│ └─ img (로고 42×42px)
│ rating (별점 + 텍스트)
├─ .title
│ ├─ font-size: 36px
│ ├─ font-weight: bold
│ ├─ overflow: ellipsis (2줄 말줄임)
│ └─ color: #333
├─ .amenitiesCotainer
│ ├─ width: 846px
│ ├─ height: 344px
│ ├─ display: flex
│ ├─ flex-wrap: wrap
│ ├─ background: #f2f2f2
│ └─ border: 1px solid #dadada
│ │
│ └─ .amenitiesBox (반복)
│ ├─ width: 138px
│ ├─ height: 138px
│ ├─ background: white
│ ├─ border-radius: 6px
│ ├─ display: flex (column)
│ └─ img: 36×32px, p: 18px font
├─ .bottomLayer
│ ├─ display: flex
│ ├─ justify-content: space-between
│ │
│ ├─ 좌측 (예약정보 + 가격)
│ │ ├─ .today: 24px, bold, #222
│ │ ├─ .roomType: 24px, #808080
│ │ └─ .price
│ │ ├─ title: 36px, #222
│ │ └─ value: 44px, bold, #c70850 (분홍색)
│ │
│ └─ 우측 (QR코드)
│ └─ .qrcodeContainer
│ ├─ width: 192px
│ ├─ height: 192px
│ ├─ box-shadow: 0px 3px 6px rgba(0,0,0,0.1)
│ └─ border: 1px solid #dadada
└─ .tbutton
├─ size: extra
├─ background: 테마 색상
└─ margin-top: auto
```
---
## 🔄 이벤트 흐름
```
사용자 입력
├─ 화살표 키 (▲▼◄►)
│ └─ Spotlight 포커스 변경
│ └─ setSelectedIndex() 호출
│ └─ hotelInfos[newIndex] 데이터 로드
│ └─ UI 업데이트 (이미지, 가격, 편의시설)
└─ [SEE MORE] 버튼 클릭
└─ handleSMSClick()
├─ 로깅 (sendLogTotalRecommend, sendLogShopByMobile)
└─ MobileSendPopUp 오픈
└─ SMS로 호텔 정보 전송
```
---
## 📱 반응형 고려사항
### 이미지 크기
- **thumbnailUrls**: `imgUrls600[]` (600px 기준)
- **갤러리**: 774×930px 고정
- **카드**: 1026×990px 고정
- **QR코드**: 160×160px 표시용
### 텍스트 보호
```javascript
// 호텔명 2줄 말줄임
.title {
.elip(@clamp:2); // max 2 lines
}
// 방 타입 3줄 말줄임
.roomType {
.elip(@clamp:3); // max 3 lines
}
```
---
## ⚠️ 주의사항
1. **QR 코드 이슈**: 호텔 QR 코드가 서버에서 다르게 내려와 에러 발생
```javascript
// TODO: 해결되면 주석제거
<TQRCode
text={hotelInfos[selectedIndex]?.qrcodeUrl}
width="160"
height="160"
/>
```
2. **편의시설 필터링**:
- 최대 10개로 제한
- 같은 카테고리명 중복 제거
3. **평점 없음 처리**:
```javascript
if (rating !== undefined) {
// 평점 등급 처리
}
```
4. **이미지 없음 처리**:
```javascript
const imgUrls600 = hotelInfos[selectedIndex]?.imgUrls600 || [];
```
---
## 📊 상태 관리 체크리스트
- ✅ 호텔 데이터 로드 (useEffect with panelInfo.type)
- ✅ 선택 인덱스 초기화 (panelInfo?.themeHotelId 기반)
- ✅ 이미지 길이 업데이트 (이미지 스와이프 관련)
- ✅ 편의시설 정렬 (최대 10개, 중복 제거)
- ✅ 평점 등급 계산
- ✅ SMS 로깅 (shopByMobileFlag: "Y", shopTpNm: "hotel")
- ✅ 상세 정보 로깅 (logTpNo: PRODUCT.PRODUCT_DETAIL_IMAGE)
- ✅ cleanup (clearThemeDetail, clearProductDetail)
---
## 🔗 관련 액션 (Redux Actions)
```javascript
// src/actions/homeActions.js
// 호텔 데이터 조회
getThemeHotelDetailInfo({ patnrId, curationId })
→ dispatch(GET_THEME_HOTEL_DETAIL_INFO, hotelData)
// 테마 데이터 조회
getThemeCurationDetailInfo({ patnrId, curationId, bgImgNo })
→ dispatch(GET_THEME_CURATION_DETAIL_INFO, themeData)
// 상세 정보 초기화
clearThemeDetail()
→ 호텔/테마 데이터 리셋
```
---
## 📝 필드 매핑 요약
| UI 요소 | 데이터 경로 | 설명 |
|---------|-----------|------|
| 로고 | `hotelData.hotelInfo.patncLogoPath` | 파트너 로고 |
| 별점 | `hotelInfos[i].hotelDetailInfo.revwGrd` | 0~5 평점 |
| 별점 등급 | 계산됨 (2.5~3.4=Good 등) | 동적 매핑 |
| 호텔명 | `hotelInfos[i].hotelNm` | 호텔 이름 |
| 호텔 타입 | `hotelInfos[i].hotelDetailInfo.hotelType` | 5-Star, Luxury 등 |
| 이미지 | `hotelInfos[i].hotelDetailInfo.imgUrls[]` | 갤러리 이미지 |
| 주소 | `hotelInfos[i].hotelDetailInfo.hotelAddr` | 지도 위치 |
| 편의시설 | `hotelInfos[i].hotelDetailInfo.amenities[]` | ID 배열 → 매핑 |
| 숙박일 | `hotelInfos[i].hotelDetailInfo.nights` | N Nights |
| 성인 수 | `hotelInfos[i].hotelDetailInfo.adultsCount` | M Adults |
| 방 타입 | `hotelInfos[i].hotelDetailInfo.roomType` | Deluxe Double Room |
| 가격 | `hotelInfos[i].hotelDetailInfo.price` | 숫자 (문자열) |
| 통화 | `hotelInfos[i].hotelDetailInfo.currencySign` | $ / € / ¥ |
| QR 코드 | `hotelInfos[i].qrcodeUrl` | QR 데이터 |

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"`)

View File

@@ -0,0 +1,437 @@
# Theme Product UI - 시각적 구조 가이드
## 📊 ShowProduct 렌더링 구조
```
ShowProduct (Main Component)
├─ Container (Spotlight)
│ └─ spotlight-IndicatorContainer
│ │
│ ├─ ThemeIndicator
│ │ ├─ [메인 이미지 영역] 834×930px
│ │ │ ├─ 비디오 플레이 버튼 (선택사항)
│ │ │ ├─ 메인 이미지
│ │ │ └─ [매진] 배지 (soldoutFlag="Y"일 때)
│ │ │
│ │ └─ [썸네일 스크롤] (TVirtualGridList)
│ │ ├─ 이미지 높이: 152px 고정
│ │ ├─ 상하 스크롤 네비게이션
│ │ └─ 현재 선택 강조 표시
│ │
│ └─ IndicatorOptions
│ ├─ 로고 + ID (상단)
│ ├─ 상품명 (제목)
│ ├─ 평점 + 태그
│ ├─ 버튼 영역
│ │ ├─ [DESCRIPTION] 버튼
│ │ ├─ [RETURNS & EXCHANGES] 버튼
│ │ └─ [SHOP BY MOBILE] 버튼
│ ├─ QR 코드 (160×160px)
│ └─ 설명 팝업 (조건부)
└─ optionContainer (하단)
├─ ShowSingleOption (결제가능 상품)
│ └─ ProductOption (래퍼)
│ └─ SingleOption
│ ├─ 옵션 선택 (좌석, 이용일 등)
│ ├─ 수량 선택
│ ├─ 가격 표시
│ └─ [구매] 버튼
└─ ShowUnableOption (결제불가 상품)
└─ ProductOption (래퍼)
└─ UnableOption
├─ 구매 불가 메시지
├─ SMS 공유 옵션
└─ [SHOP BY MOBILE] 버튼
```
---
## 🖼️ ThemeIndicator 상세 구조
```
ThemeIndicator Container
├─ [메인 디스플레이 영역]
│ │
│ ├─ 비디오 지원 여부 감지
│ │ ├─ YES → [비디오 플레이 버튼]
│ │ │ (아래 이미지들이 [1]부터 시작)
│ │ │
│ │ └─ NO → [이미지 [0]부터 표시]
│ │
│ └─ 선택된 이미지/비디오
│ └─ imageSelectedIndex 기반 렌더링
├─ [썸네일 스크롤 영역]
│ │
│ └─ TVirtualGridList
│ ├─ 항목 높이: 152px (scaleH 적용)
│ ├─ 아이템:
│ │ ├─ [0] - 비디오 (있을 경우)
│ │ ├─ [1] - 이미지 1
│ │ ├─ [2] - 이미지 2
│ │ └─ [n] - 이미지 n
│ │
│ ├─ 상하 화살표 네비게이션
│ └─ 현재 선택 (selectedIndex) 강조
└─ [화살표 버튼] (선택사항)
├─ UP/LEFT → selectedIndex - 1
└─ DOWN/RIGHT → selectedIndex + 1
```
---
## 📐 레이아웃 치수
```
┌────────────────────────────────────────────────┐
│ DetailPanel 레이아웃 │
├────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ │ │ │ │
│ │ ThemeIndicator │ │ IndicatorOptions │ │
│ │ 834 × 930px │ │ 정보 + 버튼 │ │
│ │ │ │ │ │
│ │ ┌─────────────┐ │ ├──────────────────┤ │
│ │ │메인 이미지 │ │ │ 로고 + ID │ │
│ │ │ 600×600px │ │ │ 상품명 │ │
│ │ │ │ │ │ 평점 + 태그 │ │
│ │ └─────────────┘ │ │ │ │
│ │ │ │ [설명][교환] │ │
│ │ ┌─────────────┐ │ │ [SMS] │ │
│ │ │ 썸네일 │ │ │ │ │
│ │ │ 150×150px │ │ │ QR코드 160×160 │ │
│ │ │ (반복) │ │ │ │ │
│ │ └─────────────┘ │ └──────────────────┘ │
│ │ │ │
│ └─────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ ShowSingleOption / ShowUnableOption │ │
│ │ (ProductOption으로 감싸짐) │ │
│ │ │ │
│ │ ProductOption (헤더) │ │
│ │ ├─ 로고 아이콘 │ │
│ │ ├─ 상품명 │ │
│ │ ├─ 평점 + 태그 │ │
│ │ └───────────────────────────────── │ │
│ │ │ │
│ │ SingleOption / UnableOption (본체) │ │
│ │ ├─ [옵션 선택 / 구매불가 메시지] │ │
│ │ ├─ 수량 / SMS 공유 │ │
│ │ ├─ 가격 │ │
│ │ └─ [구매 / SEE MORE] 버튼 │ │
│ │ │ │
│ └──────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────┘
```
---
## 🔄 데이터 흐름 (Theme Product)
```
Redux State
├─ state.home.themeCurationDetailInfoData
│ └─ Array of Products
│ ├─ [0] { prdtId, prdtNm, imgUrls600[], priceInfo, ... }
│ ├─ [1] { ... }
│ └─ [n] { ... }
├─ state.home.productData
│ └─ { themeInfo[0] { curationId, curationNm, ... } }
└─ selectedIndex (상태 변수)
└─ 0, 1, 2, ... n
└─ productInfo[selectedIndex] ← 현재 상품
```
**선택된 상품 데이터 조립:**
```javascript
showProductInfo = {
...productInfo[selectedIndex], // 모든 상품 정보
curationId: themeInfo.curationId, // 테마 ID 추가
curationNm: themeInfo.curationNm, // 테마 이름 추가
expsOrd: `${selectedIndex + 1}`, // 순번 (1부터)
}
```
---
## 📋 priceInfo 구조
```javascript
// priceInfo = "299|199|Y|discount10|10%"
┌──────────────────────────────────────────────────────┐
원가 할인가 보상적용 할인코드 할인율
$299 $199 Y discount10 10%
└──────────────────────────────────────────────────────┘
[0] [1] [2] [3] [4]
// 파싱:
split("|")[0] befPrice (원가)
split("|")[1] lastPrice (할인가)
split("|")[2] rewdAplyFlag (보상적용여부)
split("|")[3] 할인코드 (일반적으로 로그에는 미사용)
split("|")[4] discountRate (할인율%)
```
---
## 🎬 비디오/이미지 선택 로직
### Scenario A: 비디오 O + 이미지 3개
```
서버 응답:
imgUrls600 = [
"play-button.png", // [0] 비디오 플레이 아이콘
"image1.png", // [1] 첫 번째 이미지
"image2.png", // [2] 두 번째 이미지
"image3.png" // [3] 세 번째 이미지
]
UI 표시:
- imageSelectedIndex = 0 → [비디오 플레이] 표시 + 자동 재생
- imageSelectedIndex = 1 → imgUrls600[0] = image1 표시
- imageSelectedIndex = 2 → imgUrls600[1] = image2 표시
- imageSelectedIndex = 3 → imgUrls600[2] = image3 표시
```
### Scenario B: 비디오 X + 이미지 3개
```
서버 응답:
imgUrls600 = [
"image1.png",
"image2.png",
"image3.png"
]
UI 표시:
- imageSelectedIndex = 0 → imgUrls600[0] = image1 표시
- imageSelectedIndex = 1 → imgUrls600[1] = image2 표시
- imageSelectedIndex = 2 → imgUrls600[2] = image3 표시
```
---
## 🎛️ 조건부 렌더링 조건
```
결제 가능 상품 (ShowSingleOption)
├─ pmtSuptYn === "Y"
└─ webOSVersion >= "6.0"
└─ YES → 구매 옵션 + 수량 + 버튼
└─ NO → 다음 조건 확인
결제 불가능 상품 (ShowUnableOption)
├─ pmtSuptYn === "N" OR
└─ webOSVersion < "6.0"
└─ YES → "구매불가" 메시지 + SMS 공유
```
---
## 🔔 이벤트 흐름
```
사용자 입력
├─ 화살표 키 (▲▼)
│ └─ setSelectedIndex(newIndex) 호출
│ └─ showProductInfo 리메모이제이션
│ └─ useEffect 발동
│ ├─ 이미지 변경 (imageSelectedIndex 업데이트)
│ ├─ 가격 업데이트
│ ├─ 옵션 업데이트
│ └─ 로그 전송 (1초 후)
├─ [설명] 버튼
│ └─ descriptionClick(label, description)
│ ├─ setTabLabel([label])
│ ├─ setDescription(description)
│ ├─ dispatch(setShowPopup(descriptionPopup))
│ ├─ handleIndicatorOptions() 호출
│ └─ 로그 전송
├─ [SMS] 버튼
│ └─ handleSMSClick()
│ ├─ dispatch(setShowPopup(smsPopup))
│ ├─ handleMobileSendPopupOpen() 호출
│ └─ 로그 전송
└─ 이미지 클릭 (썸네일)
└─ setImageSelectedIndex(index)
└─ 해당 이미지 확대 표시
```
---
## 📍 ProductOption 래퍼 역할
```
ProductOption (공통 헤더)
├─ contentHeader
│ │
│ ├─ topLayer
│ │ ├─ 파트너 로고 (CustomImage)
│ │ └─ 상품 ID (expsPrdtNo)
│ │
│ ├─ title
│ │ └─ 상품명 (prdtNm)
│ │ (HTML 태그 제거 후 표시)
│ │
│ └─ bottomLayer
│ ├─ StarRating (revwGrd)
│ └─ ProductTag (상품 태그)
└─ children
└─ SingleOption 또는 UnableOption
(본체 컴포넌트)
```
---
## 🏪 SingleOption vs UnableOption
### SingleOption (결제 가능)
```
┌─────────────────────────────────┐
│ ProductOption (공통 헤더) │
├─────────────────────────────────┤
│ │
│ ┌─ 옵션 선택 영역 │
│ │ ├─ 이용일자 선택 │
│ │ ├─ 좌석/등급 선택 │
│ │ └─ 기타 옵션 │
│ │ │
│ ├─ 수량 선택 │
│ │ ├─ [] 1 [+] │
│ │ └─ 수량: 1 │
│ │ │
│ ├─ 가격 영역 │
│ │ ├─ 원가: $299 │
│ │ └─ 할인가: $199 │
│ │ │
│ └─ [구매] 버튼 │
│ └─ spotlight: "buy_Btn" │
│ │
└─────────────────────────────────┘
```
### UnableOption (결제 불가)
```
┌─────────────────────────────────┐
│ ProductOption (공통 헤더) │
├─────────────────────────────────┤
│ │
│ ┌─ 상태 메시지 │
│ │ └─ "결제 불가능합니다" │
│ │ (또는 버전 미지원) │
│ │ │
│ ├─ 대체 옵션 │
│ │ ├─ 가격 표시 │
│ │ └─ SMS 공유 옵션 │
│ │ │
│ └─ [SHOP BY MOBILE] 버튼 │
│ └─ SMS로 정보 전송 │
│ smsTpCd: "APP00204" │
│ │
└─────────────────────────────────┘
```
---
## 📊 로깅 데이터 매핑
### 상품 상세 로그 (productDetailImage)
```javascript
{
expsOrd: "1", // 상품 순번 (1부터)
prdtId: "PROD001",
prdtNm: "Opera Show",
catCd: "CATE001",
catNm: "Performance",
befPrice: "299", // 원가
lastPrice: "199", // 할인가
rewdAplyFlag: "Y", // 보상 적용
revwGrd: 4.5, // 평점
tsvFlag: "Y", // 오늘의 특가
logTpNo: 2301, // PRODUCT.PRODUCT_DETAIL_IMAGE
}
```
### SMS 전송 로그 (shopByMobile)
```javascript
{
shopTpNm: "product", // 상품 타입
shopByMobileFlag: "Y", // SMS 전송 플래그
prdtId: "PROD001",
prdtNm: "Opera Show",
showId: "SHOW001", // 쇼 ID (테마 전용)
showNm: "Opera Night", // 쇼 이름 (테마 전용)
patncNm: "Broadway Partners",
price: "199",
logTpNo: 1401, // SHOP_BY_MOBILE.SHOP_BY_MOBILE
}
```
---
## 🔗 상태 관리 체크리스트
- ✅ 상품 목록 로드 (getThemeCurationDetailInfo)
- ✅ 선택 인덱스 관리 (selectedIndex)
- ✅ 이미지 선택 관리 (imageSelectedIndex)
- ✅ 비디오 자동 재생 (autoPlaying)
- ✅ 매진 상태 확인 (soldoutFlag)
- ✅ 결제 가능 여부 (pmtSuptYn + webOSVersion)
- ✅ 설명 팝업 상태 (popupVisible, activePopup)
- ✅ 로그 전송 (1초 딜레이)
- ✅ Spotlight 포커스 관리
---
## 📝 필드 매핑 테이블
| UI 요소 | 데이터 경로 | 타입 | 설명 |
|---------|-----------|------|------|
| 메인 이미지 | `imgUrls600[imageSelectedIndex]` | URL | 선택된 이미지 표시 |
| 썸네일 | `imgUrls600[]` | 배열 | 스크롤 갤러리 |
| 상품명 | `prdtNm` | 문자열 | HTML 태그 제거 후 표시 |
| 파트너 로고 | `patncLogoPath` | URL | 폴백: 기본 로고 |
| 평점 | `revwGrd` | 숫자 | 0~5 |
| 상품 ID | `expsPrdtNo` | 문자열 | 선택사항 |
| 원가 | `priceInfo.split(\"|\"[0]` | 숫자 | 할인 전 가격 |
| 할인가 | `priceInfo.split(\"|\"[1]` | 숫자 | 최종 가격 |
| 할인율 | `priceInfo.split(\"|\"[4]` | 문자열 | "10%" 형식 |
| 매진 여부 | `soldoutFlag` | "Y"/"N" | UI 배지 표시 |
| 결제 가능 | `pmtSuptYn` | "Y"/"N" | 조건부 렌더링 |
| 비디오 URL | `prdtMediaUrl` | URL | 선택사항 |
| 비디오 자막 | `prdtMediaSubtitlUrl` | URL | 선택사항 |
| QR 코드 | `qrcodeUrl` | 데이터 | 상품 공유용 |
| 쇼 ID | `showId` | 문자열 | 테마 상품 고유 |
| 카테고리 | `catCd` / `catNm` | 문자열 | 분류 정보 |
---
## 🎓 주요 특징
1. **멀티 이미지 갤러리**: 비디오 + 이미지 혼합 지원
2. **자동 비디오 재생**: 조건 만족 시 자동으로 재생 (launchedFromPlayer 제외)
3. **스마트 인덱싱**: 비디오 유무에 따라 이미지 인덱스 자동 조정
4. **조건부 구매 UI**: 결제 여부 + OS 버전에 따른 분기
5. **상세 로깅**: 노출, 선택, 클릭 모두 추적 (매장 분석용)
6. **SMS 공유**: 구매불가 상품도 정보 공유 가능
7. **Spotlight 지원**: 전자제품 원격 제어 네비게이션 완벽 지원

View File

@@ -0,0 +1,498 @@
# DetailPanel - Theme Product vs Hotel Product 비교 분석
## 📊 전체 개요
| 구분 | Theme Product (쇼/공연) | Hotel Product (숙박) |
|------|------------------------|----------------------|
| **데이터 소스** | `themeCurationDetailInfoData[]` | `themeCurationHotelDetailData[]` |
| **메인 컴포넌트** | ShowProduct | HotelProduct |
| **패널 타입** | `panelInfo.type === "theme"` | `panelInfo.type === "hotel"` |
| **서버 API** | getThemeCurationDetailInfo | getThemeHotelDetailInfo |
| **Redux 액션** | GET_THEME_CURATION_DETAIL_INFO | GET_THEME_HOTEL_DETAIL_INFO |
---
## 🎯 핵심 차이점
### 1. 데이터 구조
#### Theme Product
```javascript
themeCurationDetailInfoData: [
{
prdtId, prdtNm, // 상품 ID, 이름
imgUrls600: [], // 이미지 배열
priceInfo: "299|199|Y|...", // 가격 정보 (파이프 구분)
prdtMediaUrl, // 비디오 URL
showId, showNm, // 쇼 정보
catCd, catNm, // 카테고리
soldoutFlag, // 매진 여부
pmtSuptYn, // 결제 지원
revwGrd, // 평점
}
]
```
#### Hotel Product
```javascript
themeCurationHotelDetailData: [
{
hotelId, hotelNm, // 호텔 ID, 이름
hotelImgUrl, // 호텔 썸네일
imgUrls600: [], // 이미지 배열
qrcodeUrl, // QR 코드
hotelDetailInfo: {
price, // 가격 (단순 숫자)
currencySign, // 통화 기호
revwGrd, // 평점
hotelType, // 호텔 타입
hotelAddr, // 주소
nights, adultsCount, // 숙박일, 성인 수
roomType, // 방 타입
amenities: [], // 편의시설 ID 배열
imgUrls: [] // 이미지
}
}
]
hotelData: {
hotelInfo: { curationId, curationNm, ... },
amenities: [
{ amntId, lgAmntNm, lgAmntImgUrl },
// ...
]
}
```
---
### 2. UI 구조
#### Theme Product
```
ShowProduct
├─ ThemeIndicator
│ ├─ [메인 이미지] 또는 [비디오]
│ └─ [썸네일 스크롤]
├─ IndicatorOptions
│ ├─ [설명] [교환정책] [SMS]
│ └─ QR 코드
└─ ShowSingleOption / ShowUnableOption
└─ 구매 옵션 또는 구매불가 메시지
```
#### Hotel Product
```
HotelProduct
├─ ThemeIndicator
│ ├─ [메인 이미지]
│ └─ [썸네일 스크롤]
├─ IndicatorOptions
│ └─ [주소 정보]
└─ optionContainer
├─ [로고 + 별점]
├─ [호텔명 + 타입]
├─ [편의시설 그리드] (최대 10개)
├─ [예약정보 + 가격 + QR]
└─ [SEE MORE] 버튼
```
---
### 3. 가격 처리
#### Theme Product
```javascript
// priceInfo = "299|199|Y|discount10|10%"
const befPrice = priceInfo.split("|")[0]; // 원가
const lastPrice = priceInfo.split("|")[1]; // 할인가
const rewdAplyFlag = priceInfo.split("|")[2]; // 보상 적용
const discountRate = priceInfo.split("|")[4]; // 할인율
// 로그 전송
params.befPrice = befPrice;
params.lastPrice = lastPrice;
```
#### Hotel Product
```javascript
// price = "299.99" (단순 숫자)
// currencySign = "$"
// UI 표시
<div>
{hotelInfos[selectedIndex]?.hotelDetailInfo.currencySign}
{hotelInfos[selectedIndex]?.hotelDetailInfo.price}
</div>
// 로그 전송
params.price = selectedHotelInfo.hotelInfo?.hotelDetailInfo?.price;
```
---
### 4. 특수 기능
#### Theme Product
**비디오 자동 재생**
- `prdtMediaUrl` 존재 시 첫 이미지 위치에 비디오
- `launchedFromPlayer = false`일 때만 자동 재생
- 비디오 자막 지원 (`prdtMediaSubtitlUrl`)
**상세 정보 팝업**
- [DESCRIPTION] 버튼: 상품 설명
- [RETURNS & EXCHANGES] 버튼: 반품/교환 정책
- HTML 마크업 지원 (`dangerouslySetInnerHTML`)
**결제 옵션 선택**
- 옵션 선택 (이용일자, 좌석 등)
- 수량 선택
- 옵션별 가격 계산
**편의시설 표시 없음**
---
#### Hotel Product
**편의시설 그리드 표시**
- 최대 10개 편의시설 아이콘 + 텍스트
- 같은 카테고리 중복 제거
- 편의시설별 이미지 및 설명
**예약 정보 표시**
- 숙박 기간 (nights)
- 성인 수 (adultsCount)
- 방 타입 (roomType)
- 자동 포맷팅 (예: "2 Nights 2 Adults")
**별점 등급 시스템**
- 평점 수치 → 문자 등급 변환
- Fair (≤2.4) / Good (2.5~3.4) / Very Good (3.5~4.4) / Excellent (≥4.5)
**비디오 지원 없음**
**옵션 선택 없음** (숙박 정보는 미리 정해짐)
---
### 5. 결제 여부 판단
#### 공통점
```javascript
// 결제 OS 버전 체크
webOSVersion >= "6.0" // TRUE일 때만 결제 UI 표시
```
#### Theme Product
```javascript
const isBillingProductVisible = (
productInfo[selectedIndex]?.pmtSuptYn === "Y" &&
webOSVersion >= "6.0"
);
// YES → ShowSingleOption (구매 옵션)
// NO → ShowUnableOption (구매불가)
```
#### Hotel Product
```javascript
// 호텔은 pmtSuptYn 체크 없음 (모두 구매불가)
// SMS "SEE MORE" 버튼만 제공
// 모든 호텔 상품이 UnableOption 구조
```
---
### 6. SMS 타입 코드
#### Theme Product
```javascript
smsTpCd = "APP00204" // 테마/쇼 상품
```
#### Hotel Product
```javascript
smsTpCd = "APP00205" // 호텔 상품
```
---
### 7. 로그 필드
#### 공통 필드
```javascript
{
curationId, curationNm, // 테마/큐레이션
patnrId, patncNm, // 파트너
prdtId, prdtNm, // 상품 ID, 이름
revwGrd, // 평점
expsOrd, // 상품 순번
}
```
#### Theme Product 추가 필드
```javascript
{
showId, showNm, // 쇼 정보
catCd, catNm, // 카테고리
befPrice, lastPrice, // 원가, 할인가
rewdAplyFlag, // 보상 적용
tsvFlag, // 오늘의 특가
shopTpNm: "product", // 상품 타입
}
```
#### Hotel Product 추가 필드
```javascript
{
hotelId, // 호텔 ID (prdtId 대체)
price, // 가격 (단순)
shopTpNm: "hotel", // 상품 타입
}
```
---
### 8. 선택 인덱스 처리
#### Theme Product
```javascript
// URL 파라미터로 특정 상품 지정
if (panelInfo?.themePrdtId) {
for (let i = 0; i < themeProductInfos.length; i++) {
if (themeProductInfos[i].prdtId === panelInfo?.themePrdtId) {
setSelectedIndex(i); // ← 해당 상품으로 이동
}
}
}
```
#### Hotel Product
```javascript
// URL 파라미터로 특정 호텔 지정
if (panelInfo?.themeHotelId) {
for (let i = 0; i < hotelInfos.length; i++) {
if (hotelInfos[i].hotelId === panelInfo?.themeHotelId) {
setSelectedIndex(i); // ← 해당 호텔로 이동
}
}
}
```
---
## 📐 레이아웃 비교
### Theme Product
| 요소 | 크기 |
|------|------|
| ThemeIndicator | 834×930px |
| 메인 이미지 | 600×600px |
| 썸네일 | 150×150px (반복) |
| QR 코드 | 160×160px |
### Hotel Product
| 요소 | 크기 |
|------|------|
| ThemeIndicator | 774×930px |
| 메인 이미지 | 600×600px |
| optionContainer | 1026×990px |
| 편의시설 박스 | 138×138px (반복, 최대 10개) |
| QR 코드 | 160×160px |
---
## 🔄 컴포넌트 재사용
### 공유 컴포넌트
```
ThemeIndicator
├─ Theme Product에서 사용
└─ Hotel Product에서도 사용
(비디오 재생은 X)
IndicatorOptions
├─ Theme Product에서 사용
└─ Hotel Product에서도 사용
(주소 정보만 표시)
StarRating
├─ Theme Product에서 사용
└─ Hotel Product에서도 사용
```
### 전용 컴포넌트
```
Theme Product:
├─ ShowProduct.jsx
├─ ShowSingleOption.jsx
├─ ShowUnableOption.jsx
└─ SingleOption / UnableOption (기존)
Hotel Product:
├─ HotelProduct.jsx
└─ StarRating (호텔 등급용)
```
---
## 📊 정리 테이블
| 기능 | Theme | Hotel |
|------|-------|-------|
| **비디오 지원** | ✅ | ❌ |
| **자동 재생** | ✅ | ❌ |
| **편의시설 표시** | ❌ | ✅ (최대 10개) |
| **예약 정보** | ❌ | ✅ (숙박일, 성인 수) |
| **등급 변환** | ❌ | ✅ (Fair/Good/Very Good/Excellent) |
| **옵션 선택** | ✅ | ❌ |
| **수량 선택** | ✅ | ❌ |
| **결제 가능** | 조건부 (pmtSuptYn) | ❌ (항상 불가) |
| **상세 팝업** | ✅ (설명/교환) | ❌ |
| **가격 형식** | `"299\|199\|Y\|..."` | `"299.99"` |
| **통화 기호** | 미사용 | ✅ (`$`, `€`, `¥` 등) |
| **SMS 타입** | APP00204 | APP00205 |
| **QR 코드** | ✅ | ✅ |
| **로그 추적** | 상세 (가격, 할인율) | 기본 (가격만) |
---
## 🎯 사용 시나리오
### Theme Product 사용 예
```
사용자가 "오페라 공연" 클릭
DetailPanel 로드 (type: "theme")
ShowProduct 렌더링
공연 비디오 자동 재생
사용자가 설명 버튼 클릭
상품 설명 팝업 표시
사용자가 SMS 버튼 클릭
친구에게 공연 정보 전송
```
### Hotel Product 사용 예
```
사용자가 "두바이 호텔" 클릭
DetailPanel 로드 (type: "hotel")
HotelProduct 렌더링
호텔 사진, 편의시설, 가격 표시
사용자가 화살표로 다른 호텔 선택
별점/편의시설/가격 업데이트
사용자가 SEE MORE 버튼 클릭
호텔 상세 정보 SMS 전송
```
---
## 💡 개발 가이드
### 새로운 상품 타입 추가 시
1. **DetailPanel에 분기 추가**
```javascript
if (panelInfo?.type === "newtype") {
dispatch(getNewTypeDetailInfo(...));
}
```
2. **Redux Action/Reducer 생성**
```javascript
GET_NEWTYPE_DETAIL_INFO action 추가
state.home.newTypeData 상태 추가
```
3. **메인 컴포넌트 생성** (ShowProduct/HotelProduct 참고)
- ThemeIndicator 재사용 (또는 커스터마이징)
- IndicatorOptions 재사용 (또는 커스터마이징)
- 세부 UI 컴포넌트 작성
4. **ThemeProduct에 라우팅 추가**
```javascript
{themeType === "newtype" && (
<NewTypeProduct {...props} />
)}
```
---
## 🔗 관련 파일 구조
```
src/views/DetailPanel/
├─ DetailPanel.jsx / DetailPanel.backup.jsx
├─ ThemeProduct/
│ ├─ ThemeProduct.jsx (라우팅)
│ ├─ ShowProduct.jsx (테마 상품)
│ ├─ HotelProduct.jsx (호텔 상품)
│ ├─ ShowOptions/
│ │ ├─ ShowSingleOption.jsx
│ │ └─ ShowUnableOption.jsx
│ └─ *.module.less
├─ components/
│ ├─ ProductOption.jsx (래퍼)
│ ├─ indicator/
│ │ ├─ ThemeIndicator.jsx (공유)
│ │ ├─ IndicatorOptions.jsx (공유)
│ │ └─ *.module.less
│ ├─ StarRating.jsx (공유)
│ └─ ...
├─ SingleProduct/
│ └─ SingleOption.jsx (테마용)
├─ UnableProduct/
│ └─ UnableOption.jsx (테마용)
└─ ...
src/actions/
├─ homeActions.js
│ ├─ getThemeCurationDetailInfo()
│ └─ getThemeHotelDetailInfo()
└─ ...
src/reducers/
└─ homeReducer.js
├─ GET_THEME_CURATION_DETAIL_INFO case
└─ GET_THEME_HOTEL_DETAIL_INFO case
```
---
## ✅ 최종 체크리스트
### Theme Product
- ✅ 비디오 자동 재생 (조건부)
- ✅ 상세 정보 팝업 (설명/교환)
- ✅ 옵션 선택 (날짜/좌석)
- ✅ 수량 선택
- ✅ 가격 할인율 표시
- ✅ 결제 가능 여부 판단
- ✅ SMS 공유 (모든 상품)
- ✅ 상세 로깅
### Hotel Product
- ✅ 호텔 이미지 갤러리
- ✅ 편의시설 그리드 (최대 10개)
- ✅ 별점 등급 변환
- ✅ 예약 정보 표시 (숙박일, 성인 수)
- ✅ 가격 + 통화 기호
- ✅ 주소 정보
- ✅ QR 코드
- ✅ SMS 공유 (SEE MORE)
- ✅ 로깅
---
이 문서를 통해 Theme Product와 Hotel Product의 차이점을 명확히 이해할 수 있으며, 향후 유사한 상품 타입 추가 시 참고할 수 있습니다.

View File

@@ -0,0 +1,13 @@
# 251122 DetailPanel 기능 이관 점검 (backup 대비 누락 가능성)
백업본(`DetailPanel.backup.jsx`)에는 있었지만 현재 `DetailPanel.jsx + ProductAllSection.jsx`로 리팩토링하면서 빠졌을 수 있는 항목들. 유지해야 하는 기능이면 재이관 필요.
## 백업에만 있고 현행에는 없는 것
- 호텔/여행형 상품 처리: `hotelData`/`hotelInfos` 기반 가격 표시(Price), 테마/호텔 정보 렌더링, SMS 팝업용 필드 등. 현행 DetailPanel에는 호텔 관련 로직이 모두 없음.
- 최근 본 상품 저장: `saveToLocalSettings``changeLocalSettings` dispatch. 현행에는 “필요하면 구현” 주석만 존재.
- 이미지 길이 설정: 테마/호텔 이미지 개수를 `getProductImageLength`로 Redux 반영. 현행에는 없음.
- 언마운트 정리 범위 축소: 백업은 `clearProductDetail`, `clearThemeDetail`, `clearCouponInfo`, `setContainerLastFocusedElement(null, ['indicator-GridListContainer'])` 모두 호출. 현행은 `clearProductDetail``setContainerLastFocusedElement`만.
## 참고
- MobileSend 팝업, YouMayLike 요청, OptionId 초기화 등은 다른 컴포넌트(ProductAllSection/DetailMobileSendPopUp 등)로 분리되어 있음.
- 위 네 가지가 실제로 필요하면 ProductAllSection/DetailPanel 측에 재연결이 필요.

View File

@@ -25,7 +25,7 @@ let tokenRefreshing = false;
const axiosQueue = []; const axiosQueue = [];
export const setTokenRefreshing = (value) => { export const setTokenRefreshing = (value) => {
console.log('TAxios setTokenRefreshing ', value); // console.log('TAxios setTokenRefreshing ', value);
tokenRefreshing = value; tokenRefreshing = value;
}; };
export const runDelayedAction = (dispatch, getState) => { export const runDelayedAction = (dispatch, getState) => {

View File

@@ -80,6 +80,29 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
() => fp.pipe(() => panelInfo, fp.get('bgVideoInfo'), fp.defaultTo(null))(), () => fp.pipe(() => panelInfo, fp.get('bgVideoInfo'), fp.defaultTo(null))(),
[panelInfo] [panelInfo]
); );
const hasThemeContents = useMemo(
() =>
fp.pipe(
() => ({ panelType, themeData }),
({ panelType, themeData }) =>
panelType === 'theme' &&
fp.pipe(
() => themeData,
fp.get('productInfos'),
(list) => Array.isArray(list) && list.length > 0
)()
)(),
[panelType, themeData]
);
const themeProducts = useMemo(
() =>
fp.pipe(
() => themeData,
fp.get('productInfos'),
(list) => (Array.isArray(list) ? list : [])
)(),
[themeData]
);
const panelShouldReload = useMemo( const panelShouldReload = useMemo(
() => fp.pipe(() => panelInfo, fp.get('shouldReload'), fp.defaultTo(false))(), () => fp.pipe(() => panelInfo, fp.get('shouldReload'), fp.defaultTo(false))(),
[panelInfo] [panelInfo]
@@ -775,6 +798,21 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
} }
}, [getProductType, productData, themeData, panelType]); }, [getProductType, productData, themeData, panelType]);
// Theme 콘텐츠 진입 시 전달된 themePrdtId를 초기 선택으로 반영
useEffect(() => {
if (panelType !== 'theme') return;
if (Array.isArray(themeProducts) && panelInfo?.themePrdtId) {
const matchedIndex = themeProducts.findIndex(
(item) => item?.prdtId === panelInfo.themePrdtId
);
if (matchedIndex >= 0) {
setSelectedIndex(matchedIndex);
}
}
}, [panelType, themeProducts, panelInfo?.themePrdtId, setSelectedIndex]);
// themeProductInfo 업데이트 - selectedIndex 변경 시마다 실행 // themeProductInfo 업데이트 - selectedIndex 변경 시마다 실행
useEffect(() => { useEffect(() => {
if (themeData?.productInfos && selectedIndex !== undefined) { if (themeData?.productInfos && selectedIndex !== undefined) {
@@ -965,6 +1003,8 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
productType={productType} productType={productType}
productInfo={productDataSource} productInfo={productDataSource}
panelInfo={panelInfo} panelInfo={panelInfo}
hasThemeContents={hasThemeContents}
themeProducts={themeProducts}
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
selectedPatnrId={panelPatnrId} selectedPatnrId={panelPatnrId}
selectedPrdtId={panelPrdtId} selectedPrdtId={panelPrdtId}

View File

@@ -116,7 +116,7 @@ const HorizontalContainer = SpotlightContainerDecorator(
const ShopByMobileContainer = SpotlightContainerDecorator( const ShopByMobileContainer = SpotlightContainerDecorator(
{ {
spotlightDirection: 'horizontal', spotlightDirection: 'horizontal',
enterTo: 'default-element', enterTo: 'last-focused',
restrict: 'self-only', restrict: 'self-only',
defaultElement: SpotlightIds?.DETAIL_SHOPBYMOBILE || 'detail_shop_by_mobile', defaultElement: SpotlightIds?.DETAIL_SHOPBYMOBILE || 'detail_shop_by_mobile',
}, },
@@ -126,7 +126,7 @@ const ShopByMobileContainer = SpotlightContainerDecorator(
const BuyNowContainer = SpotlightContainerDecorator( const BuyNowContainer = SpotlightContainerDecorator(
{ {
spotlightDirection: 'horizontal', spotlightDirection: 'horizontal',
enterTo: 'default-element', enterTo: 'last-focused',
restrict: 'self-only', restrict: 'self-only',
defaultElement: 'detail-buy-now-button', defaultElement: 'detail-buy-now-button',
}, },
@@ -154,15 +154,24 @@ const ButtonStackContainer = SpotlightContainerDecorator(
const SpottableComponent = Spottable('div'); const SpottableComponent = Spottable('div');
const getProductData = curry((productType, themeProductInfo, productInfo) => const getProductData = curry(
pipe( (productType, themeProductInfo, themeProducts, selectedIndex, productInfo) =>
when( pipe(
() => isVal(productType) && productType === 'theme' && isVal(themeProductInfo), when(
() => themeProductInfo () => isVal(productType) && productType === 'theme',
), () => {
defaultTo(productInfo), if (isVal(themeProductInfo)) {
defaultTo({}) // 빈 객체라도 반환하여 컴포넌트가 렌더링되도록 함 return themeProductInfo;
)(productInfo) }
if (Array.isArray(themeProducts) && isVal(selectedIndex)) {
return themeProducts[selectedIndex];
}
return null;
}
),
defaultTo(productInfo),
defaultTo({}) // 빈 객체라도 반환하여 컴포넌트가 렌더링되도록 함
)(productInfo)
); );
const deriveFavoriteFlag = curry((favoriteOverride, productData) => { const deriveFavoriteFlag = curry((favoriteOverride, productData) => {
@@ -183,6 +192,8 @@ export default function ProductAllSection({
productType, productType,
productInfo, productInfo,
panelInfo, panelInfo,
hasThemeContents,
themeProducts,
launchedFromPlayer, launchedFromPlayer,
selectedIndex, selectedIndex,
selectedPatnrId, selectedPatnrId,
@@ -461,8 +472,8 @@ export default function ProductAllSection({
const [activeButton, setActiveButton] = useState(null); const [activeButton, setActiveButton] = useState(null);
const productData = useMemo( const productData = useMemo(
() => getProductData(productType, themeProductInfo, productInfo), () => getProductData(productType, themeProductInfo, themeProducts, selectedIndex, productInfo),
[productType, themeProductInfo, productInfo] [productType, themeProductInfo, themeProducts, selectedIndex, productInfo]
); );
// 단품(결제 가능 상품) - DetailPanel.backup.jsx와 동일한 로직 // 단품(결제 가능 상품) - DetailPanel.backup.jsx와 동일한 로직
@@ -1116,14 +1127,17 @@ export default function ProductAllSection({
// 초기 로딩 후 Skeleton 숨기기 // 초기 로딩 후 Skeleton 숨기기
useEffect(() => { useEffect(() => {
if (productType && productData && isInitialLoading) { const hasDataReady =
productType === 'theme' ? hasThemeContents && !!productData : !!productData;
if (productType && hasDataReady && isInitialLoading) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsInitialLoading(false); setIsInitialLoading(false);
}, 150); // 150ms 후에 Skeleton 숨김 }, 150); // 150ms 후에 Skeleton 숨김
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [productType, productData, isInitialLoading]); }, [productType, productData, hasThemeContents, isInitialLoading]);
// 컴포넌트 unmount 시 초기 상태로 복원 // 컴포넌트 unmount 시 초기 상태로 복원
useEffect(() => { useEffect(() => {
@@ -1644,4 +1658,6 @@ export default function ProductAllSection({
ProductAllSection.propTypes = { ProductAllSection.propTypes = {
productType: PropTypes.oneOf(['buyNow', 'shopByMobile', 'theme']).isRequired, productType: PropTypes.oneOf(['buyNow', 'shopByMobile', 'theme']).isRequired,
hasThemeContents: PropTypes.bool,
themeProducts: PropTypes.array,
}; };

View File

@@ -30,12 +30,13 @@
// 2. Info Section - 645px // 2. Info Section - 645px
.infoSection { .infoSection {
width: 650px; width: 650px;
height: 100%; height: 884px; // 100% → 884px로 변경
padding: 0; padding: 0;
margin: 0; margin: 0;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start; // 그대로 유지
align-items: flex-start; align-items: flex-start; // 그대로 유지
overflow: hidden; // 내용이 넘칠 경우 방지
> div { > div {
height: auto; /* 고정 높이 339px 제거 - 콘텐츠에 맞게 자동 조정 (Chromium 68 호환) */ height: auto; /* 고정 높이 339px 제거 - 콘텐츠에 맞게 자동 조정 (Chromium 68 호환) */
min-height: 339px; /* 최소 높이만 유지 */ min-height: 339px; /* 최소 높이만 유지 */