[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:
389
com.twin.app.shoptime/HOTEL_UI_HANDLING_REPORT.md
Normal file
389
com.twin.app.shoptime/HOTEL_UI_HANDLING_REPORT.md
Normal 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}]
|
||||||
|
{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 연동으로 호텔 정보를 공유할 수 있습니다.
|
||||||
409
com.twin.app.shoptime/HOTEL_UI_VISUAL_GUIDE.md
Normal file
409
com.twin.app.shoptime/HOTEL_UI_VISUAL_GUIDE.md
Normal 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 데이터 |
|
||||||
692
com.twin.app.shoptime/THEME_PRODUCT_UI_ANALYSIS.md
Normal file
692
com.twin.app.shoptime/THEME_PRODUCT_UI_ANALYSIS.md
Normal 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"`)
|
||||||
437
com.twin.app.shoptime/THEME_PRODUCT_VISUAL_GUIDE.md
Normal file
437
com.twin.app.shoptime/THEME_PRODUCT_VISUAL_GUIDE.md
Normal 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 지원**: 전자제품 원격 제어 네비게이션 완벽 지원
|
||||||
498
com.twin.app.shoptime/THEME_VS_HOTEL_COMPARISON.md
Normal file
498
com.twin.app.shoptime/THEME_VS_HOTEL_COMPARISON.md
Normal 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의 차이점을 명확히 이해할 수 있으며, 향후 유사한 상품 타입 추가 시 참고할 수 있습니다.
|
||||||
13
com.twin.app.shoptime/docs/todo/251122-detailpanel-diff.md
Normal file
13
com.twin.app.shoptime/docs/todo/251122-detailpanel-diff.md
Normal 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 측에 재연결이 필요.
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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; /* 최소 높이만 유지 */
|
||||||
|
|||||||
Reference in New Issue
Block a user