[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 = [];
|
||||
|
||||
export const setTokenRefreshing = (value) => {
|
||||
console.log('TAxios setTokenRefreshing ', value);
|
||||
// console.log('TAxios setTokenRefreshing ', value);
|
||||
tokenRefreshing = value;
|
||||
};
|
||||
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))(),
|
||||
[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}
|
||||
|
||||
@@ -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,11 +154,20 @@ const ButtonStackContainer = SpotlightContainerDecorator(
|
||||
|
||||
const SpottableComponent = Spottable('div');
|
||||
|
||||
const getProductData = curry((productType, themeProductInfo, productInfo) =>
|
||||
const getProductData = curry(
|
||||
(productType, themeProductInfo, themeProducts, selectedIndex, productInfo) =>
|
||||
pipe(
|
||||
when(
|
||||
() => isVal(productType) && productType === 'theme' && isVal(themeProductInfo),
|
||||
() => themeProductInfo
|
||||
() => isVal(productType) && productType === 'theme',
|
||||
() => {
|
||||
if (isVal(themeProductInfo)) {
|
||||
return themeProductInfo;
|
||||
}
|
||||
if (Array.isArray(themeProducts) && isVal(selectedIndex)) {
|
||||
return themeProducts[selectedIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
),
|
||||
defaultTo(productInfo),
|
||||
defaultTo({}) // 빈 객체라도 반환하여 컴포넌트가 렌더링되도록 함
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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; /* 최소 높이만 유지 */
|
||||
|
||||
Reference in New Issue
Block a user