diff --git a/com.twin.app.shoptime/HOTEL_UI_HANDLING_REPORT.md b/com.twin.app.shoptime/HOTEL_UI_HANDLING_REPORT.md new file mode 100644 index 00000000..75658a6a --- /dev/null +++ b/com.twin.app.shoptime/HOTEL_UI_HANDLING_REPORT.md @@ -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 ( +
+ {themeType === "theme" && ( + + )} + {themeType === "hotel" && ( + + )} +
+ ); +} +``` + +--- + +## 🏨 호텔 상품 UI 처리 (HotelProduct.jsx) + +### 3. HotelProduct 컴포넌트 구조 + +#### A. 이미지 갤러리 영역 +```jsx + +``` +- 호텔 이미지들을 인디케이터로 표시 +- 선택된 인덱스에 따라 이미지 변경 + +#### B. 주소/위치 정보 +```jsx + +``` + +#### C. 호텔 정보 카드 영역 + +```jsx +
+ {/* 1. 상단 레이어: 로고 + 별점 + 등급 */} +
+ +
+ + +
{label}
+
+
+ + {/* 2. 제목: [호텔타입] 호텔명 */} +
+ [{hotelInfos[selectedIndex]?.hotelDetailInfo.hotelType}]  + {hotelInfos[selectedIndex]?.hotelNm} +
+ + {/* 3. 편의시설 그리드 (최대 10개) */} +
+ {amenitiesInfos && amenitiesInfos.map((item) => ( +
+ +

{item.lgAmntNm}

+
+ ))} +
+ + {/* 4. 하단 레이어: 예약 정보 + 가격 + QR코드 */} +
+
+
+ {nights}Nights {adultsCount}Adults +
+
+ {hotelInfos[selectedIndex]?.hotelDetailInfo.roomType} +
+
+
From
+

+ {hotelInfos[selectedIndex]?.hotelDetailInfo.currencySign} + {hotelInfos[selectedIndex]?.hotelDetailInfo.price} +

+
+
+
+ +
+
+ + {/* 5. CTA 버튼: "SEE MORE" */} + + {$L("SEE MORE")} + +
+``` + +--- + +## 🎯 데이터 매핑 상세 설명 + +### 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 연동으로 호텔 정보를 공유할 수 있습니다. diff --git a/com.twin.app.shoptime/HOTEL_UI_VISUAL_GUIDE.md b/com.twin.app.shoptime/HOTEL_UI_VISUAL_GUIDE.md new file mode 100644 index 00000000..d613175d --- /dev/null +++ b/com.twin.app.shoptime/HOTEL_UI_VISUAL_GUIDE.md @@ -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: 해결되면 주석제거 + + ``` + +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 데이터 | diff --git a/com.twin.app.shoptime/THEME_PRODUCT_UI_ANALYSIS.md b/com.twin.app.shoptime/THEME_PRODUCT_UI_ANALYSIS.md new file mode 100644 index 00000000..cf0fbf25 --- /dev/null +++ b/com.twin.app.shoptime/THEME_PRODUCT_UI_ANALYSIS.md @@ -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 + +``` + +--- + +## 📝 IndicatorOptions 컴포넌트 + +### 구성 요소 + +#### 1. 상단 정보 영역 +```jsx +
+ + {productInfo?.expsPrdtNo && ( +
ID: {productInfo?.expsPrdtNo}
+ )} +
+ +
+ {productInfo?.prdtNm} +
+ +
+ + +
+``` + +#### 2. 버튼 영역 +```jsx +{isBillingProductVisible && ( + + descriptionClick("DESCRIPTION", description)} + spotlightId="description_Btn" + > + {$L("DESCRIPTION")} + + descriptionClick("RETURNS & EXCHANGES", exchangeInfo)} + spotlightId="return_Exchanges_Btn" + > + {$L("RETURNS & EXCHANGES")} + + +)} + + + {$L("SHOP BY MOBILE")} + +``` + +#### 3. QR 코드 +```jsx +
+ +
+
+ Please check for more detailed information +
+
+
+``` + +#### 4. 설명 팝업 +```jsx +const renderPopUp = () => { + return ( + +
+ + +

{productInfo?.prdtNm}

+ +
+
+ + ); +}; +``` + +--- + +## 💳 결제 여부에 따른 UI 분기 + +### ShowSingleOption (pmtSuptYn === "Y" && webOSVersion >= "6.0") + +```jsx + +``` + +**구성:** +```jsx +// ShowOptions/ShowSingleOption.jsx + + + +``` + +**SingleOption 렌더링:** +- 상품 옵션 선택 (이용일자, 좌석 등) +- 수량 선택 +- 가격 표시 +- "구매" 버튼 + +--- + +### ShowUnableOption (결제 불가능) + +**조건:** +- `pmtSuptYn === "N"` OR +- `webOSVersion < "6.0"` + +```jsx + +``` + +**구성:** +```jsx +// ShowOptions/ShowUnableOption.jsx + + + +``` + +**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 + +``` + +--- + +## 🔍 데이터 매핑 상세 + +### 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에서 사용 + +``` + +--- + +## 🎬 ProductOption 래퍼 + +모든 옵션(SingleOption, UnableOption)은 ProductOption으로 감싸짐: + +```jsx + + + +``` + +**ProductOption 역할:** +```jsx +export default function ProductOption({ children, productInfo }) { + return ( + + {productInfo && ( +
+ {/* 로고 */} + + + {/* 상품 ID */} + {productInfo?.expsPrdtNo && ( +
ID: {productInfo?.expsPrdtNo}
+ )} + + {/* 상품명 */} +
+ {productInfo?.prdtNm} +
+ + {/* 평점 + 태그 */} + + +
+ )} + {children} +
+ ); +} +``` + +--- + +## ⚙️ DetailPanel과의 통합 + +```javascript +// DetailPanel.backup.jsx에서 Theme Product 처리 +{isTravelProductVisible && ( + +)} + +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"`) diff --git a/com.twin.app.shoptime/THEME_PRODUCT_VISUAL_GUIDE.md b/com.twin.app.shoptime/THEME_PRODUCT_VISUAL_GUIDE.md new file mode 100644 index 00000000..d627320d --- /dev/null +++ b/com.twin.app.shoptime/THEME_PRODUCT_VISUAL_GUIDE.md @@ -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 지원**: 전자제품 원격 제어 네비게이션 완벽 지원 diff --git a/com.twin.app.shoptime/THEME_VS_HOTEL_COMPARISON.md b/com.twin.app.shoptime/THEME_VS_HOTEL_COMPARISON.md new file mode 100644 index 00000000..0c4d16d4 --- /dev/null +++ b/com.twin.app.shoptime/THEME_VS_HOTEL_COMPARISON.md @@ -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 표시 +
+ {hotelInfos[selectedIndex]?.hotelDetailInfo.currencySign} + {hotelInfos[selectedIndex]?.hotelDetailInfo.price} +
+ +// 로그 전송 +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" && ( + + )} + ``` + +--- + +## 🔗 관련 파일 구조 + +``` +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의 차이점을 명확히 이해할 수 있으며, 향후 유사한 상품 타입 추가 시 참고할 수 있습니다. diff --git a/com.twin.app.shoptime/docs/todo/251122-detailpanel-diff.md b/com.twin.app.shoptime/docs/todo/251122-detailpanel-diff.md new file mode 100644 index 00000000..905fa1d7 --- /dev/null +++ b/com.twin.app.shoptime/docs/todo/251122-detailpanel-diff.md @@ -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 측에 재연결이 필요. diff --git a/com.twin.app.shoptime/src/api/TAxios.js b/com.twin.app.shoptime/src/api/TAxios.js index a16fa9e7..9cda4614 100644 --- a/com.twin.app.shoptime/src/api/TAxios.js +++ b/com.twin.app.shoptime/src/api/TAxios.js @@ -25,7 +25,7 @@ let tokenRefreshing = false; const axiosQueue = []; export const setTokenRefreshing = (value) => { - console.log('TAxios setTokenRefreshing ', value); + // console.log('TAxios setTokenRefreshing ', value); tokenRefreshing = value; }; export const runDelayedAction = (dispatch, getState) => { diff --git a/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx index a1eb17b7..5b05e028 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx @@ -80,6 +80,29 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) { () => fp.pipe(() => panelInfo, fp.get('bgVideoInfo'), fp.defaultTo(null))(), [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( () => fp.pipe(() => panelInfo, fp.get('shouldReload'), fp.defaultTo(false))(), [panelInfo] @@ -775,6 +798,21 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) { } }, [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 변경 시마다 실행 useEffect(() => { if (themeData?.productInfos && selectedIndex !== undefined) { @@ -965,6 +1003,8 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) { productType={productType} productInfo={productDataSource} panelInfo={panelInfo} + hasThemeContents={hasThemeContents} + themeProducts={themeProducts} selectedIndex={selectedIndex} selectedPatnrId={panelPatnrId} selectedPrdtId={panelPrdtId} diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx index aa5146b2..7200a019 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx @@ -116,7 +116,7 @@ const HorizontalContainer = SpotlightContainerDecorator( const ShopByMobileContainer = SpotlightContainerDecorator( { spotlightDirection: 'horizontal', - enterTo: 'default-element', + enterTo: 'last-focused', restrict: 'self-only', defaultElement: SpotlightIds?.DETAIL_SHOPBYMOBILE || 'detail_shop_by_mobile', }, @@ -126,7 +126,7 @@ const ShopByMobileContainer = SpotlightContainerDecorator( const BuyNowContainer = SpotlightContainerDecorator( { spotlightDirection: 'horizontal', - enterTo: 'default-element', + enterTo: 'last-focused', restrict: 'self-only', defaultElement: 'detail-buy-now-button', }, @@ -154,15 +154,24 @@ const ButtonStackContainer = SpotlightContainerDecorator( const SpottableComponent = Spottable('div'); -const getProductData = curry((productType, themeProductInfo, productInfo) => - pipe( - when( - () => isVal(productType) && productType === 'theme' && isVal(themeProductInfo), - () => themeProductInfo - ), - defaultTo(productInfo), - defaultTo({}) // 빈 객체라도 반환하여 컴포넌트가 렌더링되도록 함 - )(productInfo) +const getProductData = curry( + (productType, themeProductInfo, themeProducts, selectedIndex, productInfo) => + pipe( + when( + () => isVal(productType) && productType === 'theme', + () => { + if (isVal(themeProductInfo)) { + return themeProductInfo; + } + if (Array.isArray(themeProducts) && isVal(selectedIndex)) { + return themeProducts[selectedIndex]; + } + return null; + } + ), + defaultTo(productInfo), + defaultTo({}) // 빈 객체라도 반환하여 컴포넌트가 렌더링되도록 함 + )(productInfo) ); const deriveFavoriteFlag = curry((favoriteOverride, productData) => { @@ -183,6 +192,8 @@ export default function ProductAllSection({ productType, productInfo, panelInfo, + hasThemeContents, + themeProducts, launchedFromPlayer, selectedIndex, selectedPatnrId, @@ -461,8 +472,8 @@ export default function ProductAllSection({ const [activeButton, setActiveButton] = useState(null); const productData = useMemo( - () => getProductData(productType, themeProductInfo, productInfo), - [productType, themeProductInfo, productInfo] + () => getProductData(productType, themeProductInfo, themeProducts, selectedIndex, productInfo), + [productType, themeProductInfo, themeProducts, selectedIndex, productInfo] ); // 단품(결제 가능 상품) - DetailPanel.backup.jsx와 동일한 로직 @@ -1116,14 +1127,17 @@ export default function ProductAllSection({ // 초기 로딩 후 Skeleton 숨기기 useEffect(() => { - if (productType && productData && isInitialLoading) { + const hasDataReady = + productType === 'theme' ? hasThemeContents && !!productData : !!productData; + + if (productType && hasDataReady && isInitialLoading) { const timer = setTimeout(() => { setIsInitialLoading(false); }, 150); // 150ms 후에 Skeleton 숨김 return () => clearTimeout(timer); } - }, [productType, productData, isInitialLoading]); + }, [productType, productData, hasThemeContents, isInitialLoading]); // 컴포넌트 unmount 시 초기 상태로 복원 useEffect(() => { @@ -1644,4 +1658,6 @@ export default function ProductAllSection({ ProductAllSection.propTypes = { productType: PropTypes.oneOf(['buyNow', 'shopByMobile', 'theme']).isRequired, + hasThemeContents: PropTypes.bool, + themeProducts: PropTypes.array, }; diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less index 110debd2..568cc2c9 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less @@ -30,12 +30,13 @@ // 2. Info Section - 645px .infoSection { width: 650px; - height: 100%; + height: 884px; // 100% → 884px로 변경 padding: 0; margin: 0; display: flex; - justify-content: flex-start; - align-items: flex-start; + justify-content: flex-start; // 그대로 유지 + align-items: flex-start; // 그대로 유지 + overflow: hidden; // 내용이 넘칠 경우 방지 > div { height: auto; /* 고정 높이 339px 제거 - 콘텐츠에 맞게 자동 조정 (Chromium 68 호환) */ min-height: 339px; /* 최소 높이만 유지 */