Compare commits
140 Commits
gitlab_mer
...
detail_v3
| Author | SHA1 | Date | |
|---|---|---|---|
| cbd52e1b98 | |||
| 14e10e5b41 | |||
| 9e95602dc1 | |||
| b6b51a016d | |||
| 8a47000c80 | |||
| 464adc39a3 | |||
| e44bcaf19f | |||
| 8d45d89d09 | |||
| 9153c70af0 | |||
| e74a8bc79d | |||
| f1638fc50b | |||
| dbbd7114a2 | |||
| ec5829eebe | |||
| ce51902150 | |||
| 209d983954 | |||
| 251e1ee3d4 | |||
| 9878c39512 | |||
| 11bfdc0825 | |||
| 77e1dc56a2 | |||
| c7ac0d7460 | |||
| ef7615a538 | |||
| bd2a90b6f5 | |||
| 8b64875bfe | |||
| 5278151102 | |||
| eaa0201469 | |||
| cf2cc57d95 | |||
|
|
8cce06bcc3 | ||
|
|
1f7020b28b | ||
|
|
6e9ebcfe3a | ||
|
|
e6eccebb77 | ||
|
|
24c11dfe7f | ||
| 9c5de90098 | |||
|
|
2c99ab559c | ||
|
|
3e491ac0fc | ||
| fdd8d875f8 | |||
| 1fac922f61 | |||
|
|
0c8a7d5ca0 | ||
| 8ee426de52 | |||
|
|
62031c02a5 | ||
|
|
3ac5bb40eb | ||
| ad1775dd05 | |||
| 35a6c1e500 | |||
| cb452abe80 | |||
| 3cc84ace17 | |||
| d9aebac816 | |||
| bbcc4eddd1 | |||
|
|
90481f787d | ||
| 9cc6246063 | |||
| 59ac371e63 | |||
| ebce68d059 | |||
| 9c58cf2477 | |||
| 55ee018a7a | |||
| 8644527502 | |||
|
|
afaffd965a | ||
| e21f6a1072 | |||
| 18175a03de | |||
| 78bf217d75 | |||
|
|
1747eb1326 | ||
|
|
6e78c02ed8 | ||
| e3c94afe28 | |||
|
|
1af864845b | ||
|
|
776a875920 | ||
|
|
129b6e6623 | ||
| d5336b4322 | |||
| 276ee65979 | |||
| d8dce0a89d | |||
| e797a8a399 | |||
| db109f77c1 | |||
| a055de9e69 | |||
| d8030aba11 | |||
| cb0764b3ac | |||
| cac42de0ca | |||
| fec9919221 | |||
| 2cffe6f0a9 | |||
| e5ec726fba | |||
| 08de7888c7 | |||
| af439249dc | |||
| 65309f034c | |||
|
|
2f5a99444d | ||
|
|
c05a241d4e | ||
|
|
61c61eae9b | ||
|
|
e95e4b828f | ||
| 1b764b34d5 | |||
| 15aecd0792 | |||
| 42e74f39e9 | |||
| 10f47a9c63 | |||
| 886d95d8bf | |||
| 29c7e4a911 | |||
| 604b4c6404 | |||
| 187043d9e7 | |||
| 4778805dbf | |||
| 508e9e1042 | |||
| f8acaa2c3b | |||
| 7c073165bb | |||
| 7af47679cc | |||
| ae0e24144a | |||
| 42095d9d61 | |||
|
|
b9fb388d9b | ||
|
|
c29c1c0ff9 | ||
|
|
fa0a350bbb | ||
|
|
8a7699d3c6 | ||
| 70c5200917 | |||
| 13a74ea6c2 | |||
| b1640cab2f | |||
| a18c61380c | |||
| da1a050a10 | |||
| 341af91564 | |||
| 4699797a99 | |||
| 2d93ee6ca4 | |||
| 4ebbb773db | |||
| ef04d805de | |||
| a503bf923a | |||
| 70381438ac | |||
| 2ac217fb10 | |||
| 3b9773394c | |||
| 66ce0cc3c0 | |||
| ea256990eb | |||
| f7ff26347b | |||
| c40ce59d7a | |||
| 98dde0d6a0 | |||
| e474ac3ef2 | |||
| d1f63ee402 | |||
| af30f8c688 | |||
|
|
f140be087b | ||
| 03b41c04fc | |||
| a58cfb4e81 | |||
| b1c5664b98 | |||
| c9c6fc07a9 | |||
|
|
5d587dbdeb | ||
| fdb9507024 | |||
| a33213fb8c | |||
| 1bf490c46c | |||
| 63ab5e2015 | |||
| 9d80faf79d | |||
| 2290e334d1 | |||
| 7a12cb43be | |||
|
|
fd1ebcbc1c | ||
| 1fe0428caf | |||
| 938e5d0440 | |||
| 96511fa7f3 |
2
com.twin.app.shoptime/.gitignore
vendored
@@ -20,3 +20,5 @@ nul
|
|||||||
|
|
||||||
.optimal
|
.optimal
|
||||||
OPTIMAL.md
|
OPTIMAL.md
|
||||||
|
.docs
|
||||||
|
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
# DEBUG_MODE 조건부 로깅 구현 완료
|
|
||||||
|
|
||||||
**작업 일시**: 2025-11-12
|
|
||||||
**작업 범위**: ProductVideo.v2.jsx, MediaPanel.jsx
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 작업 개요
|
|
||||||
|
|
||||||
ProductVideo.v2.jsx와 MediaPanel.jsx의 모든 로그 출력을 `DEBUG_MODE = true/false` 플래그로 제어할 수 있도록 구현했습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 구현 내용
|
|
||||||
|
|
||||||
### 1. DEBUG_MODE 설정
|
|
||||||
|
|
||||||
각 파일의 최상단에 DEBUG_MODE 상수를 추가합니다:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ DEBUG 모드 설정
|
|
||||||
const DEBUG_MODE = true; // false로 설정하면 모든 로그 비활성화
|
|
||||||
```
|
|
||||||
|
|
||||||
**설정 변경 방법:**
|
|
||||||
- 프로덕션: `const DEBUG_MODE = false;` 로 변경
|
|
||||||
- 개발/테스트: `const DEBUG_MODE = true;` 유지
|
|
||||||
|
|
||||||
### 2. debugLog 헬퍼 함수
|
|
||||||
|
|
||||||
DEBUG_MODE를 검사하는 래퍼 함수를 구현합니다:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ DEBUG_MODE 기반 console 래퍼
|
|
||||||
const debugLog = (...args) => {
|
|
||||||
if (DEBUG_MODE) {
|
|
||||||
console.log(...args);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**특징:**
|
|
||||||
- `console.log(...)` 대신 `debugLog(...)` 사용
|
|
||||||
- DEBUG_MODE가 false이면 로그 출력 안 됨
|
|
||||||
- 성능 오버헤드 거의 없음 (조건 체크만 수행)
|
|
||||||
|
|
||||||
### 3. console 메서드별 처리
|
|
||||||
|
|
||||||
| 메서드 | 처리 방식 | 파일 |
|
|
||||||
|--------|----------|------|
|
|
||||||
| `console.log()` | `debugLog()` 로 변경 | ProductVideo.v2.jsx, MediaPanel.jsx |
|
|
||||||
| `console.warn()` | `if (DEBUG_MODE) console.warn()` | ProductVideo.v2.jsx, MediaPanel.jsx |
|
|
||||||
| `console.error()` | `if (DEBUG_MODE) console.error()` | ProductVideo.v2.jsx |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 변경 통계
|
|
||||||
|
|
||||||
### ProductVideo.v2.jsx
|
|
||||||
```
|
|
||||||
- console.log() → debugLog(): 약 40+ 개
|
|
||||||
- console.warn() → if (DEBUG_MODE) console.warn(): 2개
|
|
||||||
- console.error() → if (DEBUG_MODE) console.error(): 1개
|
|
||||||
```
|
|
||||||
|
|
||||||
### MediaPanel.jsx
|
|
||||||
```
|
|
||||||
- console.log() → debugLog(): 약 10+ 개
|
|
||||||
- console.warn() → if (DEBUG_MODE) console.warn(): 1개
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 사용 방법
|
|
||||||
|
|
||||||
### DEBUG 로그 활성화 (개발 모드)
|
|
||||||
```javascript
|
|
||||||
const DEBUG_MODE = true; // ✅ 모든 로그 출력됨
|
|
||||||
```
|
|
||||||
|
|
||||||
### DEBUG 로그 비활성화 (프로덕션)
|
|
||||||
```javascript
|
|
||||||
const DEBUG_MODE = false; // ❌ 모든 로그 숨김
|
|
||||||
```
|
|
||||||
|
|
||||||
### 한 줄 변경으로 전체 로깅 제어
|
|
||||||
각 파일의 두 번째 줄만 변경하면 됩니다:
|
|
||||||
|
|
||||||
**ProductVideo.v2.jsx Line 36**
|
|
||||||
```javascript
|
|
||||||
const DEBUG_MODE = true; // 변경: true ↔ false
|
|
||||||
```
|
|
||||||
|
|
||||||
**MediaPanel.jsx Line 25**
|
|
||||||
```javascript
|
|
||||||
const DEBUG_MODE = true; // 변경: true ↔ false
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 장점
|
|
||||||
|
|
||||||
1. **성능 최적화**
|
|
||||||
- 프로덕션에서 로그 오버헤드 제거
|
|
||||||
- 조건 검사만 수행 (콘솔 I/O 없음)
|
|
||||||
|
|
||||||
2. **개발 편의성**
|
|
||||||
- 한 줄 변경으로 전체 로깅 제어
|
|
||||||
- 파일 수정 없이 ENV 변수로 제어 가능 (향후)
|
|
||||||
|
|
||||||
3. **디버깅 용이**
|
|
||||||
- 필요할 때만 로그 활성화
|
|
||||||
- 로그 양 제어로 콘솔 지저분함 방지
|
|
||||||
|
|
||||||
4. **유지보수 편함**
|
|
||||||
- 기존 console 호출 그대로 유지
|
|
||||||
- 로그 코드 삭제 불필요
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 향후 개선 사항
|
|
||||||
|
|
||||||
### 1. 환경 변수 기반 설정
|
|
||||||
```javascript
|
|
||||||
const DEBUG_MODE = process.env.REACT_APP_DEBUG === 'true';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 세부 로그 레벨 구분
|
|
||||||
```javascript
|
|
||||||
const LOG_LEVEL = {
|
|
||||||
ERROR: 0,
|
|
||||||
WARN: 1,
|
|
||||||
INFO: 2,
|
|
||||||
DEBUG: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
const debugLog = (level, ...args) => {
|
|
||||||
if (LOG_LEVEL[level] <= getCurrentLogLevel()) {
|
|
||||||
console.log(...args);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Redux DevTools 통합
|
|
||||||
```javascript
|
|
||||||
const debugLog = (...args) => {
|
|
||||||
if (DEBUG_MODE) {
|
|
||||||
console.log(...args);
|
|
||||||
// Redux DevTools 에 추가 정보 기록
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 검증 항목
|
|
||||||
|
|
||||||
- [x] ProductVideo.v2.jsx: 모든 console.log → debugLog 변경
|
|
||||||
- [x] ProductVideo.v2.jsx: console.warn/error 조건부 처리
|
|
||||||
- [x] MediaPanel.jsx: 모든 console.log → debugLog 변경
|
|
||||||
- [x] MediaPanel.jsx: console.warn 조건부 처리
|
|
||||||
- [x] debugLog 함수 올바르게 구현 (무한 루프 방지)
|
|
||||||
- [x] DEBUG_MODE 설정 가능
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 다음 단계
|
|
||||||
|
|
||||||
1. **사용자 테스트**
|
|
||||||
- DEBUG_MODE = true일 때 모든 로그 정상 출력 확인
|
|
||||||
- DEBUG_MODE = false일 때 모든 로그 숨겨지는지 확인
|
|
||||||
|
|
||||||
2. **성능 테스트**
|
|
||||||
- 프로덕션 모드에서 성능 개선 확인
|
|
||||||
|
|
||||||
3. **ENV 변수 연동**
|
|
||||||
- `.env.development`, `.env.production` 설정
|
|
||||||
- 빌드 시 자동으로 DEBUG_MODE 설정
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 코드 예시
|
|
||||||
|
|
||||||
### Before (수정 전)
|
|
||||||
```javascript
|
|
||||||
console.log('🎬 [handleThumbnailClick] 썸네일 클릭됨', {...});
|
|
||||||
console.warn('[ProductVideoV2] 비디오 정지 실패:', error);
|
|
||||||
console.error('🖥️ [toggleControls] 디스패치 에러:', error);
|
|
||||||
```
|
|
||||||
|
|
||||||
### After (수정 후)
|
|
||||||
```javascript
|
|
||||||
debugLog('🎬 [handleThumbnailClick] 썸네일 클릭됨', {...});
|
|
||||||
if (DEBUG_MODE) console.warn('[ProductVideoV2] 비디오 정지 실패:', error);
|
|
||||||
if (DEBUG_MODE) console.error('🖥️ [toggleControls] 디스패치 에러:', error);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📌 주의사항
|
|
||||||
|
|
||||||
1. **주석 처리된 로그**
|
|
||||||
- 기존의 주석 처리된 console.log는 유지됨
|
|
||||||
- 필요시 나중에 삭제 가능
|
|
||||||
|
|
||||||
2. **debugLog 함수 위치**
|
|
||||||
- 컴포넌트 함수 외부에 선언됨
|
|
||||||
- 매번 새로 생성되지 않음 (성능 최적화)
|
|
||||||
|
|
||||||
3. **프로덕션 배포**
|
|
||||||
- 배포 전에 DEBUG_MODE를 false로 반드시 변경할 것
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 결론
|
|
||||||
|
|
||||||
ProductVideo.v2.jsx와 MediaPanel.jsx의 모든 로그 출력을 DEBUG_MODE 플래그로 제어할 수 있도록 구현완료.
|
|
||||||
이를 통해 개발/테스트 중에는 디버깅 정보를 쉽게 확인할 수 있으며,
|
|
||||||
프로덕션 환경에서는 로그 오버헤드를 제거하여 성능을 향상시킬 수 있습니다.
|
|
||||||
|
|
||||||
**작업 상태**: ✅ 완료
|
|
||||||
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
@@ -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 데이터 |
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
# MediaPanel.jsx 메모리 누수 방지 및 클린업 개선
|
|
||||||
|
|
||||||
**작업 일시**: 2025-11-12
|
|
||||||
**파일**: MediaPanel.jsx
|
|
||||||
**상태**: ✅ 완료 (코드 수정만, git/npm 미실행)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 작업 개요
|
|
||||||
|
|
||||||
MediaPanel.jsx의 메모리 누수를 방지하고 안전한 리소스 정리를 위해 다음과 같은 개선사항을 추가했습니다:
|
|
||||||
|
|
||||||
- ✅ 안전한 비디오 플레이어 메서드 호출 래퍼
|
|
||||||
- ✅ 강화된 컴포넌트 언마운트 클린업
|
|
||||||
- ✅ DOM 스타일 초기화 및 정리
|
|
||||||
- ✅ 에러 처리 강화
|
|
||||||
- ✅ 이벤트 리스너 추적 및 정리
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 주요 개선 사항
|
|
||||||
|
|
||||||
### 1. 안전한 메서드 호출 래퍼 (safePlayerCall)
|
|
||||||
|
|
||||||
**위치**: Line 107-117
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ 안전한 비디오 플레이어 메서드 호출
|
|
||||||
const safePlayerCall = useCallback((methodName, ...args) => {
|
|
||||||
if (videoPlayer.current && typeof videoPlayer.current[methodName] === 'function') {
|
|
||||||
try {
|
|
||||||
return videoPlayer.current[methodName](...args);
|
|
||||||
} catch (err) {
|
|
||||||
if (DEBUG_MODE) console.warn(`[MediaPanel] ${methodName} 호출 실패:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
**장점:**
|
|
||||||
- null/undefined 안전 검사
|
|
||||||
- 메서드 존재 여부 확인
|
|
||||||
- 에러 처리 통일
|
|
||||||
- 트라이-캐치로 예외 처리
|
|
||||||
|
|
||||||
**사용 예:**
|
|
||||||
```javascript
|
|
||||||
safePlayerCall('play');
|
|
||||||
safePlayerCall('toggleControls');
|
|
||||||
const mediaState = safePlayerCall('getMediaState');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 레퍼런스 추적 Ref 추가
|
|
||||||
|
|
||||||
**위치**: Line 64
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const mediaEventListenersRef = useRef([]); // ✅ 미디어 이벤트 리스너 추적
|
|
||||||
```
|
|
||||||
|
|
||||||
**목적:**
|
|
||||||
- 등록된 이벤트 리스너 관리
|
|
||||||
- 언마운트 시 모든 리스너 제거 가능
|
|
||||||
- 메모리 누수 방지
|
|
||||||
|
|
||||||
### 3. isOnTop 변경 시 안전한 제어
|
|
||||||
|
|
||||||
**위치**: Line 178-188
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
if (videoPlayer.current?.getMediaState()?.paused) {
|
|
||||||
videoPlayer.current.play();
|
|
||||||
}
|
|
||||||
if (videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible()) {
|
|
||||||
videoPlayer.current.showControls();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
// ✅ 안전한 메서드 호출로 null/undefined 체크
|
|
||||||
const mediaState = safePlayerCall('getMediaState');
|
|
||||||
if (mediaState?.paused) {
|
|
||||||
safePlayerCall('play');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isControlsHidden = videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible();
|
|
||||||
if (isControlsHidden) {
|
|
||||||
safePlayerCall('showControls');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점:**
|
|
||||||
- mediaState null 체크 강화
|
|
||||||
- 모든 플레이어 호출을 안전한 래퍼로 통일
|
|
||||||
- 에러 처리 일관성
|
|
||||||
|
|
||||||
### 4. 비디오 클릭 핸들러 개선
|
|
||||||
|
|
||||||
**위치**: Line 199-208
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
if (videoPlayer.current && typeof videoPlayer.current.toggleControls === 'function') {
|
|
||||||
videoPlayer.current.toggleControls();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
safePlayerCall('toggleControls');
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점:**
|
|
||||||
- 코드 간결성
|
|
||||||
- 에러 처리 통일
|
|
||||||
|
|
||||||
### 5. 뒤로가기 시 비디오 정지
|
|
||||||
|
|
||||||
**위치**: Line 212-213
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ 뒤로가기 시 비디오 정지
|
|
||||||
safePlayerCall('pause');
|
|
||||||
```
|
|
||||||
|
|
||||||
**효과:**
|
|
||||||
- 패널 닫을 때 비디오 자동 정지
|
|
||||||
- 메모리 정리 시작
|
|
||||||
|
|
||||||
### 6. DOM 스타일 설정 및 정리
|
|
||||||
|
|
||||||
**위치**: Line 353-376
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const videoContainer = document.querySelector(`.${css.videoContainer}`);
|
|
||||||
if (videoContainer && panelInfo.thumbnailUrl && !videoLoaded) {
|
|
||||||
videoContainer.style.background = `url(${panelInfo.thumbnailUrl}) center center / contain no-repeat`;
|
|
||||||
videoContainer.style.backgroundColor = 'black';
|
|
||||||
}
|
|
||||||
}, [panelInfo.thumbnailUrl, videoLoaded]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
// ✅ useLayoutEffect: DOM 스타일 설정 (메모리 누수 방지)
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const videoContainer = document.querySelector(`.${css.videoContainer}`);
|
|
||||||
if (videoContainer && panelInfo.thumbnailUrl && !videoLoaded) {
|
|
||||||
try {
|
|
||||||
videoContainer.style.background = `url(${panelInfo.thumbnailUrl}) center center / contain no-repeat`;
|
|
||||||
videoContainer.style.backgroundColor = 'black';
|
|
||||||
} catch (err) {
|
|
||||||
if (DEBUG_MODE) console.warn('[MediaPanel] 썸네일 스타일 설정 실패:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ cleanup: 컴포넌트 언마운트 시 DOM 스타일 초기화
|
|
||||||
return () => {
|
|
||||||
if (videoContainer) {
|
|
||||||
try {
|
|
||||||
videoContainer.style.background = '';
|
|
||||||
videoContainer.style.backgroundColor = '';
|
|
||||||
} catch (err) {
|
|
||||||
// 스타일 초기화 실패는 무시
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [panelInfo.thumbnailUrl, videoLoaded]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점:**
|
|
||||||
- 에러 처리 추가
|
|
||||||
- cleanup 함수로 DOM 스타일 초기화
|
|
||||||
- 메모리 누수 방지
|
|
||||||
|
|
||||||
### 7. mediainfoHandler 강화
|
|
||||||
|
|
||||||
**위치**: Line 280-326
|
|
||||||
|
|
||||||
**개선 사항:**
|
|
||||||
- safePlayerCall 사용으로 null 안정성
|
|
||||||
- hlsError 처리 강화
|
|
||||||
- timeupdate 이벤트에서 mediaState 체크
|
|
||||||
- error 이벤트에서 null 기본값 제공
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
case 'timeupdate': {
|
|
||||||
const mediaState = safePlayerCall('getMediaState');
|
|
||||||
if (mediaState) {
|
|
||||||
setCurrentTime(mediaState.currentTime || 0); // ✅ 기본값 제공
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. 컴포넌트 언마운트 시 전체 클린업 강화
|
|
||||||
|
|
||||||
**위치**: Line 382-429
|
|
||||||
|
|
||||||
**개선 사항:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// ✅ onEnded 타이머 정리
|
|
||||||
if (onEndedTimerRef.current) {
|
|
||||||
clearTimeout(onEndedTimerRef.current);
|
|
||||||
onEndedTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Redux 상태 정리
|
|
||||||
dispatch(stopMediaAutoClose?.()) || null;
|
|
||||||
|
|
||||||
// ✅ 비디오 플레이어 정지 및 정리
|
|
||||||
if (videoPlayer.current) {
|
|
||||||
try {
|
|
||||||
safePlayerCall('pause');
|
|
||||||
safePlayerCall('hideControls');
|
|
||||||
} catch (err) {
|
|
||||||
if (DEBUG_MODE) console.warn('[MediaPanel] 비디오 정지 실패:', err);
|
|
||||||
}
|
|
||||||
videoPlayer.current = null; // ✅ ref 초기화
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 이벤트 리스너 정리
|
|
||||||
if (mediaEventListenersRef.current && mediaEventListenersRef.current.length > 0) {
|
|
||||||
mediaEventListenersRef.current.forEach(({ element, event, handler }) => {
|
|
||||||
try {
|
|
||||||
element?.removeEventListener?.(event, handler);
|
|
||||||
} catch (err) {
|
|
||||||
// 리스너 제거 실패는 무시
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mediaEventListenersRef.current = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Spotlight 상태 초기화
|
|
||||||
try {
|
|
||||||
Spotlight.resume?.();
|
|
||||||
} catch (err) {
|
|
||||||
// Spotlight 초기화 실패는 무시
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [dispatch, safePlayerCall]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**정리 항목:**
|
|
||||||
1. ✅ onEnded 타이머 정리
|
|
||||||
2. ✅ Redux 상태 정리
|
|
||||||
3. ✅ 비디오 플레이어 정지
|
|
||||||
4. ✅ 플레이어 ref 초기화
|
|
||||||
5. ✅ 이벤트 리스너 제거
|
|
||||||
6. ✅ Spotlight 상태 복구
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 변경 통계
|
|
||||||
|
|
||||||
| 항목 | 수량 |
|
|
||||||
|------|------|
|
|
||||||
| 새로운 Ref | 1개 (mediaEventListenersRef) |
|
|
||||||
| 새로운 함수 | 1개 (safePlayerCall) |
|
|
||||||
| 개선된 useEffect | 2개 |
|
|
||||||
| 개선된 콜백 | 3개 |
|
|
||||||
| 추가된 클린업 로직 | 6개 항목 |
|
|
||||||
| 에러 처리 강화 | 4개 지점 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 효과
|
|
||||||
|
|
||||||
### 메모리 누수 방지
|
|
||||||
- ✅ 타이머 명시적 정리
|
|
||||||
- ✅ 이벤트 리스너 추적 및 정리
|
|
||||||
- ✅ ref 초기화
|
|
||||||
- ✅ Redux 상태 정리
|
|
||||||
|
|
||||||
### 안정성 향상
|
|
||||||
- ✅ null/undefined 체크 강화
|
|
||||||
- ✅ 에러 처리 통일
|
|
||||||
- ✅ 존재하지 않는 메서드 호출 방지
|
|
||||||
- ✅ 트라이-캐치 예외 처리
|
|
||||||
|
|
||||||
### 코드 품질 개선
|
|
||||||
- ✅ 반복 코드 제거
|
|
||||||
- ✅ 일관된 에러 처리
|
|
||||||
- ✅ 명확한 주석
|
|
||||||
- ✅ 안전한 디폴트값 사용
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 호환성 확인
|
|
||||||
|
|
||||||
### 기존 기능 보존
|
|
||||||
- ✅ 비디오 재생/정지 동작 유지
|
|
||||||
- ✅ controls 표시/숨김 로직 유지
|
|
||||||
- ✅ modal ↔ fullscreen 전환 유지
|
|
||||||
- ✅ onEnded 콜백 동작 유지
|
|
||||||
- ✅ 이벤트 핸들러 동작 유지
|
|
||||||
|
|
||||||
### 추가 보호
|
|
||||||
- ✅ null 참조 예외 방지
|
|
||||||
- ✅ 잘못된 메서드 호출 방지
|
|
||||||
- ✅ DOM 접근 에러 방지
|
|
||||||
- ✅ 타이머 중복 정리 방지
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📌 주의사항
|
|
||||||
|
|
||||||
### DEBUG_MODE 설정
|
|
||||||
```javascript
|
|
||||||
const DEBUG_MODE = false; // 프로덕션
|
|
||||||
const DEBUG_MODE = true; // 개발/디버깅
|
|
||||||
```
|
|
||||||
|
|
||||||
- DEBUG_MODE = false일 때: 모든 경고 로그 숨김
|
|
||||||
- DEBUG_MODE = true일 때: 모든 디버그 로그 표시
|
|
||||||
|
|
||||||
### safePlayerCall 사용 규칙
|
|
||||||
1. 존재하지 않을 수 있는 메서드만 사용
|
|
||||||
2. 반환값이 필요하면 null 체크
|
|
||||||
3. 항상 try-catch로 감싸짐
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ Good
|
|
||||||
const state = safePlayerCall('getMediaState');
|
|
||||||
if (state) { /* ... */ }
|
|
||||||
|
|
||||||
// ✅ Good
|
|
||||||
safePlayerCall('play');
|
|
||||||
|
|
||||||
// ❌ Bad - 존재하는 메서드는 직접 호출
|
|
||||||
videoPlayer.current.getVideoNode();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 향후 개선 사항
|
|
||||||
|
|
||||||
1. **이벤트 리스너 자동 추적**
|
|
||||||
```javascript
|
|
||||||
const addTrackedListener = useCallback((element, event, handler) => {
|
|
||||||
element.addEventListener(event, handler);
|
|
||||||
mediaEventListenersRef.current.push({ element, event, handler });
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **성능 모니터링**
|
|
||||||
- 메모리 사용량 로깅
|
|
||||||
- 타이머 정리 시간 측정
|
|
||||||
|
|
||||||
3. **테스트 커버리지**
|
|
||||||
- 반복 마운트/언마운트 테스트
|
|
||||||
- 메모리 누수 테스트
|
|
||||||
- 에러 케이스 테스트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 검증 항목
|
|
||||||
|
|
||||||
- [x] 기존 기능 동작 확인
|
|
||||||
- [x] 메모리 누수 방지 로직 추가
|
|
||||||
- [x] null/undefined 안전성 강화
|
|
||||||
- [x] 에러 처리 통일
|
|
||||||
- [x] 클린업 함수 완성
|
|
||||||
- [x] 주석 및 문서화 완료
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 코드 예시
|
|
||||||
|
|
||||||
### safePlayerCall 사용 예
|
|
||||||
```javascript
|
|
||||||
// Before
|
|
||||||
if (videoPlayer.current?.getMediaState()?.paused) {
|
|
||||||
videoPlayer.current.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
// After
|
|
||||||
const mediaState = safePlayerCall('getMediaState');
|
|
||||||
if (mediaState?.paused) {
|
|
||||||
safePlayerCall('play');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 언마운트 클린업
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// 타이머 정리
|
|
||||||
if (onEndedTimerRef.current) {
|
|
||||||
clearTimeout(onEndedTimerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redux 정리
|
|
||||||
dispatch(stopMediaAutoClose?.());
|
|
||||||
|
|
||||||
// 플레이어 정리
|
|
||||||
safePlayerCall('pause');
|
|
||||||
videoPlayer.current = null;
|
|
||||||
|
|
||||||
// 리스너 정리
|
|
||||||
mediaEventListenersRef.current.forEach(({ element, event, handler }) => {
|
|
||||||
element?.removeEventListener?.(event, handler);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [dispatch, safePlayerCall]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 결론
|
|
||||||
|
|
||||||
MediaPanel.jsx에 다음과 같은 메모리 누수 방지 및 클린업 기능을 추가했습니다:
|
|
||||||
|
|
||||||
1. **안전한 메서드 호출** - safePlayerCall 래퍼
|
|
||||||
2. **강화된 클린업** - 6개 항목 정리
|
|
||||||
3. **에러 처리** - 통일된 예외 처리
|
|
||||||
4. **리스너 추적** - 이벤트 리스너 관리 준비
|
|
||||||
5. **DOM 정리** - 스타일 초기화
|
|
||||||
|
|
||||||
이를 통해 장시간 사용 시에도 메모리 누수 없이 안정적으로 동작할 것으로 기대됩니다.
|
|
||||||
|
|
||||||
**작업 상태**: ✅ 완료 (코드 수정만, git/npm 미실행)
|
|
||||||
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
@@ -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
@@ -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의 차이점을 명확히 이해할 수 있으며, 향후 유사한 상품 타입 추가 시 참고할 수 있습니다.
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
# 타이머 클린업 및 메모리 누수 방지 작업 완료 보고
|
|
||||||
|
|
||||||
**작업 일시**: 2025-11-12
|
|
||||||
**작업 범위**: ProductVideo.v2.jsx, MediaPanel.jsx, MediaPlayer.v2.jsx
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 작업 개요
|
|
||||||
|
|
||||||
비디오 플레이어 관련 컴포넌트들에서 타이머와 이벤트 리스너가 제대로 정리되지 않아 발생할 수 있는 메모리 누수를 방지하기 위해 다음 개선 작업을 수행했습니다:
|
|
||||||
|
|
||||||
- ✅ **setTimeout/setInterval 타이머의 명시적 정리**
|
|
||||||
- ✅ **이벤트 리스너의 적절한 등록/해제**
|
|
||||||
- ✅ **Ref를 통한 타이머 추적 및 정리**
|
|
||||||
- ✅ **컴포넌트 언마운트 시 리소스 정리**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 ProductVideo.v2.jsx 개선 사항
|
|
||||||
|
|
||||||
### 1. autoPlay 타이머 정리 강화
|
|
||||||
**파일 위치**: Line 566-597
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Before
|
|
||||||
return () => {
|
|
||||||
if (autoPlayTimerRef.current) {
|
|
||||||
clearTimeout(autoPlayTimerRef.current);
|
|
||||||
autoPlayTimerRef.current = null;
|
|
||||||
}
|
|
||||||
clearAllVideoTimers();
|
|
||||||
if (videoPlayerRef.current) {
|
|
||||||
try {
|
|
||||||
videoPlayerRef.current.pause();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[ProductVideoV2] 비디오 정지 실패:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// After
|
|
||||||
return () => {
|
|
||||||
// ✅ autoPlay timer 정리
|
|
||||||
if (autoPlayTimerRef.current) {
|
|
||||||
clearTimeout(autoPlayTimerRef.current);
|
|
||||||
autoPlayTimerRef.current = null;
|
|
||||||
}
|
|
||||||
// ✅ 전역 비디오 타이머 정리 (메모리 누수 방지)
|
|
||||||
clearAllVideoTimers?.(); // Optional chaining 추가
|
|
||||||
// ✅ 비디오 플레이어 정지
|
|
||||||
if (videoPlayerRef.current) {
|
|
||||||
try {
|
|
||||||
videoPlayerRef.current.pause?.(); // Optional chaining 추가
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[ProductVideoV2] 비디오 정지 실패:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- Optional chaining (`?.`) 추가로 null/undefined 체크 안정성 향상
|
|
||||||
- `isPlaying` dependency 제거 (무한 루프 방지)
|
|
||||||
- 명확한 주석으로 코드 가독성 개선
|
|
||||||
|
|
||||||
### 2. 전체화면 전환 시 타이머 정리
|
|
||||||
**파일 위치**: Line 615-647
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Before
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPlaying && videoPlayerRef.current) {
|
|
||||||
// ...
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
// ...
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}, [isFullscreen, isPlaying]);
|
|
||||||
|
|
||||||
// After
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPlaying && videoPlayerRef.current) {
|
|
||||||
// ...
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
// ...
|
|
||||||
}, 100);
|
|
||||||
// ✅ cleanup: 타이머 정리
|
|
||||||
return () => {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isFullscreen, isPlaying]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- Null 체크 추가로 안정성 향상
|
|
||||||
- 명확한 cleanup 함수 작성
|
|
||||||
|
|
||||||
### 3. 전역 document 이벤트 리스너 정리 명확화
|
|
||||||
**파일 위치**: Line 504-537
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- 명확한 주석으로 이벤트 리스너 등록/해제 의도 표명
|
|
||||||
- cleanup 함수에서 일관된 이벤트 리스너 제거
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎬 MediaPanel.jsx 개선 사항
|
|
||||||
|
|
||||||
### 1. onEnded 타이머 관리 개선
|
|
||||||
**파일 위치**: Line 52-53 (ref 추가), Line 285-308 (콜백 개선)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Added ref for timer tracking
|
|
||||||
const onEndedTimerRef = useRef(null); // ✅ onEnded 타이머 관리
|
|
||||||
|
|
||||||
// Before
|
|
||||||
const onEnded = useCallback(
|
|
||||||
(e) => {
|
|
||||||
Spotlight.pause();
|
|
||||||
setTimeout(() => {
|
|
||||||
Spotlight.resume();
|
|
||||||
dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL));
|
|
||||||
}, 1500);
|
|
||||||
e?.stopPropagation();
|
|
||||||
e?.preventDefault();
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// After
|
|
||||||
const onEnded = useCallback(
|
|
||||||
(e) => {
|
|
||||||
Spotlight.pause();
|
|
||||||
// ✅ 이전 타이머가 있으면 정리
|
|
||||||
if (onEndedTimerRef.current) {
|
|
||||||
clearTimeout(onEndedTimerRef.current);
|
|
||||||
}
|
|
||||||
// ✅ 새로운 타이머 저장 (cleanup 시 정리용)
|
|
||||||
onEndedTimerRef.current = setTimeout(() => {
|
|
||||||
Spotlight.resume();
|
|
||||||
dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL));
|
|
||||||
onEndedTimerRef.current = null;
|
|
||||||
}, 1500);
|
|
||||||
e?.stopPropagation();
|
|
||||||
e?.preventDefault();
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- useRef를 통한 타이머 추적으로 중복 호출 방지
|
|
||||||
- 명시적 타이머 정리 로직
|
|
||||||
|
|
||||||
### 2. 컴포넌트 언마운트 시 타이머 정리
|
|
||||||
**파일 위치**: Line 322-340 (신규 useEffect 추가)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ 컴포넌트 언마운트 시 모든 타이머 정리
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// onEnded 타이머 정리
|
|
||||||
if (onEndedTimerRef.current) {
|
|
||||||
clearTimeout(onEndedTimerRef.current);
|
|
||||||
onEndedTimerRef.current = null;
|
|
||||||
}
|
|
||||||
// ✅ 비디오 플레이어 정지
|
|
||||||
if (videoPlayer.current) {
|
|
||||||
try {
|
|
||||||
videoPlayer.current.pause?.();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[MediaPanel] 비디오 정지 실패:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- 컴포넌트 언마운트 시 모든 타이머 정리
|
|
||||||
- 비디오 플레이어 강제 정지로 리소스 누수 방지
|
|
||||||
|
|
||||||
### 3. Modal 스타일 설정 시 ResizeObserver 정리
|
|
||||||
**파일 위치**: Line 114-171
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ modal 스타일 설정
|
|
||||||
useEffect(() => {
|
|
||||||
let resizeObserver = null;
|
|
||||||
// ... 스타일 설정 로직
|
|
||||||
// ✅ cleanup: resize observer 정리
|
|
||||||
return () => {
|
|
||||||
if (resizeObserver) {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [panelInfo, isOnTop]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- ResizeObserver 초기화로 미래 구현 시 메모리 누수 방지 준비
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📹 MediaPlayer.v2.jsx 개선 사항
|
|
||||||
|
|
||||||
### 1. proportionLoaded 업데이트 타이머 최적화
|
|
||||||
**파일 위치**: Line 411-431
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Before
|
|
||||||
useEffect(() => {
|
|
||||||
updateProportionLoaded();
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
updateProportionLoaded();
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [updateProportionLoaded]);
|
|
||||||
|
|
||||||
// After
|
|
||||||
useEffect(() => {
|
|
||||||
updateProportionLoaded();
|
|
||||||
// ✅ 1초마다 업데이트 (비디오 재생 중일 때만)
|
|
||||||
let intervalId = null;
|
|
||||||
if (!paused) {
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
updateProportionLoaded();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
// ✅ cleanup: interval 정리
|
|
||||||
return () => {
|
|
||||||
if (intervalId !== null) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [updateProportionLoaded, paused]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- 비디오 일시정지 중에는 interval 생성하지 않음 (불필요한 타이머 제거)
|
|
||||||
- `paused` dependency 추가로 상태 변화 감지
|
|
||||||
- 명시적 null 체크로 정리 안정성 향상
|
|
||||||
|
|
||||||
### 2. 컴포넌트 언마운트 시 전체 cleanup 강화
|
|
||||||
**파일 위치**: Line 433-454
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ Cleanup: 컴포넌트 언마운트 시 모든 타이머 및 상태 정리
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// ✅ controlsTimeoutRef 정리
|
|
||||||
if (controlsTimeoutRef.current) {
|
|
||||||
clearTimeout(controlsTimeoutRef.current);
|
|
||||||
controlsTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
// ✅ 비디오 플레이어 정지
|
|
||||||
if (videoRef.current) {
|
|
||||||
try {
|
|
||||||
videoRef.current.pause?.();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[MediaPlayer.v2] 비디오 정지 실패:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ✅ MediaPlayer 언마운트 시 Redux 상태 정리
|
|
||||||
dispatch(stopMediaAutoClose());
|
|
||||||
};
|
|
||||||
}, [dispatch]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- 비디오 플레이어 강제 정지 추가
|
|
||||||
- Optional chaining으로 안정성 향상
|
|
||||||
- 에러 핸들링 추가
|
|
||||||
|
|
||||||
### 3. hideControls 메서드 주석 추가
|
|
||||||
**파일 위치**: Line 290-299
|
|
||||||
|
|
||||||
**개선점**:
|
|
||||||
- 타이머 정리 의도 명확화를 위한 주석 추가
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 핵심 개선 패턴
|
|
||||||
|
|
||||||
### 1. **Ref를 통한 타이머 추적**
|
|
||||||
```javascript
|
|
||||||
const timerRef = useRef(null);
|
|
||||||
|
|
||||||
const startTimer = () => {
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
// ...
|
|
||||||
timerRef.current = null;
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Optional Chaining으로 안정성 향상**
|
|
||||||
```javascript
|
|
||||||
// Before
|
|
||||||
videoRef.current.pause();
|
|
||||||
|
|
||||||
// After
|
|
||||||
videoRef.current.pause?.();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **조건부 타이머 생성**
|
|
||||||
```javascript
|
|
||||||
// Before - 항상 interval 생성
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
updateProportionLoaded();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// After - 필요할 때만 생성
|
|
||||||
let intervalId = null;
|
|
||||||
if (!paused) {
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
updateProportionLoaded();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 검증 항목
|
|
||||||
|
|
||||||
다음 항목들이 개선되었습니다:
|
|
||||||
|
|
||||||
- [x] **autoPlay 타이머** 정리 강화 (ProductVideo.v2.jsx)
|
|
||||||
- [x] **전체화면 전환 타이머** 정리 (ProductVideo.v2.jsx)
|
|
||||||
- [x] **Document 이벤트 리스너** 정리 명확화 (ProductVideo.v2.jsx)
|
|
||||||
- [x] **onEnded 타이머** Ref 추적 (MediaPanel.jsx)
|
|
||||||
- [x] **컴포넌트 언마운트 cleanup** 강화 (MediaPanel.jsx)
|
|
||||||
- [x] **Modal 스타일 설정** ResizeObserver 정리 준비 (MediaPanel.jsx)
|
|
||||||
- [x] **proportionLoaded 업데이트** 타이머 최적화 (MediaPlayer.v2.jsx)
|
|
||||||
- [x] **전체 cleanup 함수** 강화 (MediaPlayer.v2.jsx)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 다음 단계
|
|
||||||
|
|
||||||
### 권장 사항
|
|
||||||
|
|
||||||
1. **Redux Actions 검토**
|
|
||||||
- `clearAllVideoTimers()` 액션이 실제로 모든 타이머를 정리하는지 확인
|
|
||||||
- `startMediaAutoClose()`, `stopMediaAutoClose()` 타이머 정리 로직 검토
|
|
||||||
|
|
||||||
2. **VideoPlayer/Media 컴포넌트**
|
|
||||||
- webOS Media 컴포넌트의 타이머 정리 로직 확인
|
|
||||||
- TReactPlayer의 cleanup 로직 검토
|
|
||||||
|
|
||||||
3. **테스트**
|
|
||||||
- 장시간 비디오 재생 후 메모리 사용량 모니터링
|
|
||||||
- 여러 번 반복 재생/정지 시 메모리 누수 확인
|
|
||||||
- 전체화면 전환 시 리소스 누수 확인
|
|
||||||
|
|
||||||
4. **성능 모니터링**
|
|
||||||
- Chrome DevTools Memory tab에서 힙 스냅샷 비교
|
|
||||||
- 컴포넌트 마운트/언마운트 반복 시 메모리 증감 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 주요 변경 요약
|
|
||||||
|
|
||||||
| 파일 | 변경 사항 | 라인 | 개선 효과 |
|
|
||||||
|------|---------|------|---------|
|
|
||||||
| ProductVideo.v2.jsx | autoPlay 타이머 정리 강화 | 566-597 | 메모리 누수 방지 |
|
|
||||||
| ProductVideo.v2.jsx | 전체화면 전환 타이머 정리 | 615-647 | 타이머 중복 방지 |
|
|
||||||
| ProductVideo.v2.jsx | Document 이벤트 리스너 정리 | 504-537 | 이벤트 리스너 누수 방지 |
|
|
||||||
| MediaPanel.jsx | onEnded 타이머 Ref 추적 | 52-53, 285-308 | 타이머 중복 호출 방지 |
|
|
||||||
| MediaPanel.jsx | 컴포넌트 언마운트 cleanup | 322-340 | 메모리 누수 방지 |
|
|
||||||
| MediaPanel.jsx | Modal 스타일 ResizeObserver | 114-171 | 옵저버 정리 준비 |
|
|
||||||
| MediaPlayer.v2.jsx | proportionLoaded 타이머 최적화 | 411-431 | 불필요한 타이머 제거 |
|
|
||||||
| MediaPlayer.v2.jsx | 전체 cleanup 강화 | 433-454 | 메모리 누수 방지 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 결론
|
|
||||||
|
|
||||||
비디오 플레이어 관련 컴포넌트들의 타이머와 이벤트 리스너 정리를 체계적으로 개선했습니다.
|
|
||||||
이를 통해 장시간 비디오 재생 시에도 메모리 누수 없이 안정적으로 동작할 것으로 기대됩니다.
|
|
||||||
|
|
||||||
**작업 상태**: ✅ 완료
|
|
||||||
BIN
com.twin.app.shoptime/assets/images/bg/Pinkfong_new.png
Normal file
|
After Width: | Height: | Size: 1013 KiB |
BIN
com.twin.app.shoptime/assets/images/bg/hsn_new.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
com.twin.app.shoptime/assets/images/bg/koreaKiosk_new.png
Normal file
|
After Width: | Height: | Size: 728 KiB |
BIN
com.twin.app.shoptime/assets/images/bg/lgelectronics_new.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
com.twin.app.shoptime/assets/images/bg/ontv4u_new.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
com.twin.app.shoptime/assets/images/bg/qvc_new.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
com.twin.app.shoptime/assets/images/bg/shoplc_new.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
com.twin.app.shoptime/assets/images/icons/coupon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
com.twin.app.shoptime/assets/images/theme/image-1.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
com.twin.app.shoptime/assets/images/theme/image-2.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
com.twin.app.shoptime/assets/images/theme/image-3.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
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 측에 재연결이 필요.
|
||||||
@@ -72,6 +72,9 @@ import { types } from '../actions/actionTypes';
|
|||||||
// } from "../utils/focus-monitor";
|
// } from "../utils/focus-monitor";
|
||||||
// import { PanelHoc } from "../components/TPanel/TPanel";
|
// import { PanelHoc } from "../components/TPanel/TPanel";
|
||||||
|
|
||||||
|
// DEBUG_MODE - true인 경우에만 로그 출력
|
||||||
|
const DEBUG_MODE = false;
|
||||||
|
|
||||||
let foreGroundChangeTimer = null;
|
let foreGroundChangeTimer = null;
|
||||||
|
|
||||||
// 기존 콘솔 메서드를 백업
|
// 기존 콘솔 메서드를 백업
|
||||||
@@ -185,13 +188,16 @@ const sendVoiceLogToPanel = (args) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.log = function (...args) {
|
console.log = function (...args) {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
// Voice 로그를 VoicePanel로 전송
|
// Voice 로그를 VoicePanel로 전송
|
||||||
sendVoiceLogToPanel(args);
|
sendVoiceLogToPanel(args);
|
||||||
// 원래 console.log 실행
|
// 원래 console.log 실행
|
||||||
originalConsoleLog.apply(console, processArgs(args));
|
originalConsoleLog.apply(console, processArgs(args));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.error = function (...args) {
|
console.error = function (...args) {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
// Voice 로그를 VoicePanel로 전송 (에러는 강제로 ERROR 타입)
|
// Voice 로그를 VoicePanel로 전송 (에러는 강제로 ERROR 타입)
|
||||||
try {
|
try {
|
||||||
const firstArg = args[0];
|
const firstArg = args[0];
|
||||||
@@ -226,9 +232,11 @@ console.error = function (...args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
originalConsoleError.apply(console, processArgs(args));
|
originalConsoleError.apply(console, processArgs(args));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.warn = function (...args) {
|
console.warn = function (...args) {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
// Voice 로그를 VoicePanel로 전송 (경고는 ERROR 타입으로)
|
// Voice 로그를 VoicePanel로 전송 (경고는 ERROR 타입으로)
|
||||||
try {
|
try {
|
||||||
const firstArg = args[0];
|
const firstArg = args[0];
|
||||||
@@ -265,6 +273,7 @@ console.warn = function (...args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
originalConsoleWarn.apply(console, processArgs(args));
|
originalConsoleWarn.apply(console, processArgs(args));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const originFocus = Spotlight.focus;
|
const originFocus = Spotlight.focus;
|
||||||
@@ -304,12 +313,12 @@ const logFocusTransition = (previousNode, currentNode) => {
|
|||||||
const currentId = resolveSpotlightIdFromNode(currentNode);
|
const currentId = resolveSpotlightIdFromNode(currentNode);
|
||||||
|
|
||||||
if (previousId && previousId !== currentId) {
|
if (previousId && previousId !== currentId) {
|
||||||
console.log(`[SpotlightFocus] blur - ${previousId}`);
|
if (DEBUG_MODE) console.log(`[SpotlightFocus] blur - ${previousId}`);
|
||||||
lastLoggedBlurSpotlightId = previousId;
|
lastLoggedBlurSpotlightId = previousId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentId && currentId !== lastLoggedSpotlightId) {
|
if (currentId && currentId !== lastLoggedSpotlightId) {
|
||||||
console.log(`[SpotlightFocus] focus - ${currentId}`);
|
if (DEBUG_MODE) console.log(`[SpotlightFocus] focus - ${currentId}`);
|
||||||
lastLoggedSpotlightId = currentId;
|
lastLoggedSpotlightId = currentId;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -421,6 +430,24 @@ const resolveSpotlightIdFromEvent = (event) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Spotlight Focus 추적 로그 [251115]
|
||||||
|
// DOM 이벤트 리스너로 대체
|
||||||
|
|
||||||
|
// document.addEventListener('focusin', (ev) => {
|
||||||
|
// console.log('[SPOTLIGHT FOCUS-IN]', ev.target);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// document.addEventListener('focusout', (ev) => {
|
||||||
|
// console.log('[SPOTLIGHT FOCUS-OUT]', ev.target);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Spotlight 커스텀 이벤트가 있다면 추가
|
||||||
|
// if (typeof Spotlight !== 'undefined' && Spotlight.addEventListener) {
|
||||||
|
// Spotlight.addEventListener('focus', (ev) => {
|
||||||
|
// console.log('[SPOTLIGHT: focus]', ev.target);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
function AppBase(props) {
|
function AppBase(props) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const httpHeader = useSelector((state) => state.common.httpHeader);
|
const httpHeader = useSelector((state) => state.common.httpHeader);
|
||||||
@@ -438,6 +465,7 @@ function AppBase(props) {
|
|||||||
// const termsFlag = useSelector((state) => state.common.termsFlag);
|
// const termsFlag = useSelector((state) => state.common.termsFlag);
|
||||||
const termsData = useSelector((state) => state.home.termsData);
|
const termsData = useSelector((state) => state.home.termsData);
|
||||||
|
|
||||||
|
// 🔽 Spotlight focus/blur 로그 (옵션)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Config.FOCUS_DEBUG) {
|
if (!Config.FOCUS_DEBUG) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const types = {
|
|||||||
POP_PANEL: 'POP_PANEL',
|
POP_PANEL: 'POP_PANEL',
|
||||||
UPDATE_PANEL: 'UPDATE_PANEL',
|
UPDATE_PANEL: 'UPDATE_PANEL',
|
||||||
RESET_PANELS: 'RESET_PANELS',
|
RESET_PANELS: 'RESET_PANELS',
|
||||||
|
FOCUS_PANEL: 'FOCUS_PANEL', // 🔽 [251114] 명시적 포커스 이동
|
||||||
|
|
||||||
// 🔽 [신규] panel history actions
|
// 🔽 [신규] panel history actions
|
||||||
ENQUEUE_PANEL_HISTORY: 'ENQUEUE_PANEL_HISTORY',
|
ENQUEUE_PANEL_HISTORY: 'ENQUEUE_PANEL_HISTORY',
|
||||||
@@ -82,10 +83,12 @@ export const types = {
|
|||||||
CLEAR_CART: 'CLEAR_CART',
|
CLEAR_CART: 'CLEAR_CART',
|
||||||
//cart api action
|
//cart api action
|
||||||
GET_MY_INFO_CART_SEARCH: 'GET_MY_INFO_CART_SEARCH',
|
GET_MY_INFO_CART_SEARCH: 'GET_MY_INFO_CART_SEARCH',
|
||||||
INSERT_MY_INFO_CART : "INSERT_MY_INFO_CART",
|
INSERT_MY_INFO_CART: 'INSERT_MY_INFO_CART',
|
||||||
DELETE_MY_INFO_CART : "DELETE_MY_INFO_CART",
|
DELETE_MY_INFO_CART: 'DELETE_MY_INFO_CART',
|
||||||
DELETE_ALL_MY_INFO_CART : "DELETE_ALL_MY_INFO_CART",
|
DELETE_ALL_MY_INFO_CART: 'DELETE_ALL_MY_INFO_CART',
|
||||||
UPDATE_MY_INFO_CART : "UPDATE_MY_INFO_CART",
|
UPDATE_MY_INFO_CART: 'UPDATE_MY_INFO_CART',
|
||||||
|
//cart checkbox toggle action
|
||||||
|
TOGGLE_CHECK_CART: 'TOGGLE_CHECK_CART',
|
||||||
|
|
||||||
// appData actions
|
// appData actions
|
||||||
ADD_MAIN_INDEX: 'ADD_MAIN_INDEX',
|
ADD_MAIN_INDEX: 'ADD_MAIN_INDEX',
|
||||||
@@ -109,6 +112,7 @@ export const types = {
|
|||||||
CHECK_ENTER_THROUGH_GNB: 'CHECK_ENTER_THROUGH_GNB',
|
CHECK_ENTER_THROUGH_GNB: 'CHECK_ENTER_THROUGH_GNB',
|
||||||
SET_DEFAULT_FOCUS: 'SET_DEFAULT_FOCUS',
|
SET_DEFAULT_FOCUS: 'SET_DEFAULT_FOCUS',
|
||||||
SET_BANNER_INDEX: 'SET_BANNER_INDEX',
|
SET_BANNER_INDEX: 'SET_BANNER_INDEX',
|
||||||
|
SET_VIDEO_TRANSITION_LOCK: 'SET_VIDEO_TRANSITION_LOCK',
|
||||||
RESET_HOME_INFO: 'RESET_HOME_INFO',
|
RESET_HOME_INFO: 'RESET_HOME_INFO',
|
||||||
UPDATE_HOME_INFO: 'UPDATE_HOME_INFO',
|
UPDATE_HOME_INFO: 'UPDATE_HOME_INFO',
|
||||||
|
|
||||||
@@ -255,6 +259,27 @@ export const types = {
|
|||||||
CLEAR_PLAYER_INFO: 'CLEAR_PLAYER_INFO',
|
CLEAR_PLAYER_INFO: 'CLEAR_PLAYER_INFO',
|
||||||
UPDATE_VIDEO_PLAY_STATE: 'UPDATE_VIDEO_PLAY_STATE',
|
UPDATE_VIDEO_PLAY_STATE: 'UPDATE_VIDEO_PLAY_STATE',
|
||||||
|
|
||||||
|
// 🔽 [251116] 새로운 비디오 상태 관리 시스템 - 재생 상태
|
||||||
|
SET_PLAYBACK_LOADING: 'SET_PLAYBACK_LOADING',
|
||||||
|
SET_PLAYBACK_SUCCESS: 'SET_PLAYBACK_SUCCESS',
|
||||||
|
SET_PLAYBACK_ERROR: 'SET_PLAYBACK_ERROR',
|
||||||
|
SET_PLAYBACK_PLAYING: 'SET_PLAYBACK_PLAYING',
|
||||||
|
SET_PLAYBACK_NOT_PLAYING: 'SET_PLAYBACK_NOT_PLAYING',
|
||||||
|
SET_PLAYBACK_BUFFERING: 'SET_PLAYBACK_BUFFERING',
|
||||||
|
|
||||||
|
// 🔽 [251116] 새로운 비디오 상태 관리 시스템 - 화면 상태
|
||||||
|
SET_DISPLAY_HIDDEN: 'SET_DISPLAY_HIDDEN',
|
||||||
|
SET_DISPLAY_VISIBLE: 'SET_DISPLAY_VISIBLE',
|
||||||
|
SET_DISPLAY_MINIMIZED: 'SET_DISPLAY_MINIMIZED',
|
||||||
|
SET_DISPLAY_FULLSCREEN: 'SET_DISPLAY_FULLSCREEN',
|
||||||
|
|
||||||
|
// 🔽 [251116] 복합 상태 액션들
|
||||||
|
SET_VIDEO_LOADING: 'SET_VIDEO_LOADING',
|
||||||
|
SET_VIDEO_PLAYING: 'SET_VIDEO_PLAYING',
|
||||||
|
SET_VIDEO_STOPPED: 'SET_VIDEO_STOPPED',
|
||||||
|
SET_VIDEO_MINIMIZED_PLAYING: 'SET_VIDEO_MINIMIZED_PLAYING',
|
||||||
|
SET_VIDEO_ERROR: 'SET_VIDEO_ERROR',
|
||||||
|
|
||||||
// 🔽 [추가] 플레이 제어 매니저 액션 타입
|
// 🔽 [추가] 플레이 제어 매니저 액션 타입
|
||||||
/**
|
/**
|
||||||
* 홈 화면 배너의 비디오 재생 제어를 위한 액션 타입.
|
* 홈 화면 배너의 비디오 재생 제어를 위한 액션 타입.
|
||||||
|
|||||||
@@ -171,6 +171,22 @@ export const deleteAllMyinfoCart = (props) => (dispatch, getState) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장바구니 상품 체크박스 토글
|
||||||
|
* @param {Object} item - 선택된 상품 정보
|
||||||
|
* @param {Boolean} isChecked - 선택 여부
|
||||||
|
*/
|
||||||
|
export const toggleCheckCart = (item, isChecked) => (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: types.TOGGLE_CHECK_CART,
|
||||||
|
payload: {
|
||||||
|
item: item,
|
||||||
|
isChecked: isChecked,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 장바구니 상품 수정
|
* 장바구니 상품 수정
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { URLS } from "../api/apiConfig";
|
import { URLS } from '../api/apiConfig';
|
||||||
import { TAxios } from "../api/TAxios";
|
import { TAxios } from '../api/TAxios';
|
||||||
import { types } from "./actionTypes";
|
import { types } from './actionTypes';
|
||||||
import { changeAppStatus, showError } from "./commonActions";
|
import {
|
||||||
|
changeAppStatus,
|
||||||
|
showError,
|
||||||
|
} from './commonActions';
|
||||||
|
|
||||||
// 회원 체크아웃 정보 조회 IF-LGSP-345
|
// 회원 체크아웃 정보 조회 IF-LGSP-345
|
||||||
export const getMyInfoCheckoutInfo =
|
export const getMyInfoCheckoutInfo =
|
||||||
@@ -150,6 +153,7 @@ export const getCheckoutTotalAmt =
|
|||||||
dirPurcSelYn,
|
dirPurcSelYn,
|
||||||
bilAddrSno,
|
bilAddrSno,
|
||||||
dlvrAddrSno,
|
dlvrAddrSno,
|
||||||
|
isPageLoading,
|
||||||
orderProductCoupontUse,
|
orderProductCoupontUse,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
@@ -195,7 +199,7 @@ export const getCheckoutTotalAmt =
|
|||||||
"post",
|
"post",
|
||||||
URLS.GET_CHECKOUT_TOTAL_AMT,
|
URLS.GET_CHECKOUT_TOTAL_AMT,
|
||||||
{},
|
{},
|
||||||
{ mbrNo, dirPurcSelYn, bilAddrSno, dlvrAddrSno, orderProductCoupontUse },
|
{ mbrNo, dirPurcSelYn, bilAddrSno, dlvrAddrSno, isPageLoading, orderProductCoupontUse },
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onFail
|
onFail
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { URLS } from "../api/apiConfig";
|
import { URLS } from '../api/apiConfig';
|
||||||
import { TAxios } from "../api/TAxios";
|
import { TAxios } from '../api/TAxios';
|
||||||
import { types } from "./actionTypes";
|
import { types } from './actionTypes';
|
||||||
|
import { showError } from './commonActions';
|
||||||
|
|
||||||
// IF-LGSP-339 : 회원 다운로드 쿠폰 정보 조회
|
// IF-LGSP-339 : 회원 다운로드 쿠폰 정보 조회
|
||||||
export const getProductCouponInfo = (props) => (dispatch, getState) => {
|
export const getProductCouponInfo = (props) => (dispatch, getState) => {
|
||||||
@@ -37,11 +38,21 @@ export const getProductCouponTotDownload = (props) => (dispatch, getState) => {
|
|||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getProductCouponTotDownload onSuccess ", response.data);
|
console.log("getProductCouponTotDownload onSuccess ", response.data);
|
||||||
|
if(response.data.retCode === 0){
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_PRODUCT_COUPON_TOTDOWNLOAD,
|
type: types.GET_PRODUCT_COUPON_TOTDOWNLOAD,
|
||||||
payload: response.data.data,
|
payload: response.data.data,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
dispatch(
|
||||||
|
showError(
|
||||||
|
response.data.retCode,
|
||||||
|
response.data.retMsg,
|
||||||
|
false,
|
||||||
|
response.data.retDetailCode
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
@@ -63,15 +74,24 @@ export const getProductCouponTotDownload = (props) => (dispatch, getState) => {
|
|||||||
export const getProductCouponDownload = (props) => (dispatch, getState) => {
|
export const getProductCouponDownload = (props) => (dispatch, getState) => {
|
||||||
const { mbrNo, cpnSno } = props;
|
const { mbrNo, cpnSno } = props;
|
||||||
|
|
||||||
console.log("#mbrNo , cpnSno", mbrNo, cpnSno);
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getProductCouponDownload onSuccess ", response.data);
|
console.log("getProductCouponDownload onSuccess ", response.data);
|
||||||
|
if(response.data.retCode === 0){
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_PRODUCT_COUPON_DOWNLOAD,
|
type: types.GET_PRODUCT_COUPON_DOWNLOAD,
|
||||||
payload: response.data.data,
|
payload: response.data.data,
|
||||||
retCode: response.data.retCode,
|
retCode: response.data.retCode,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
dispatch(
|
||||||
|
showError(
|
||||||
|
response.data.retCode,
|
||||||
|
response.data.retMsg,
|
||||||
|
false,
|
||||||
|
response.data.retDetailCode
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { URLS } from "../api/apiConfig";
|
import { URLS } from '../api/apiConfig';
|
||||||
import {
|
import { runDelayedAction, setTokenRefreshing, TAxios, TAxiosAdvancedPromise } from '../api/TAxios';
|
||||||
runDelayedAction,
|
import * as lunaSend from '../lunaSend';
|
||||||
setTokenRefreshing,
|
import { types } from './actionTypes';
|
||||||
TAxios,
|
import { changeLocalSettings } from './commonActions';
|
||||||
TAxiosAdvancedPromise,
|
import { fetchCurrentUserHomeTerms } from './homeActions';
|
||||||
} from "../api/TAxios";
|
|
||||||
import * as lunaSend from "../lunaSend";
|
|
||||||
import { types } from "./actionTypes";
|
|
||||||
import { changeLocalSettings } from "./commonActions";
|
|
||||||
import { fetchCurrentUserHomeTerms } from "./homeActions";
|
|
||||||
|
|
||||||
const MAX_RETRY_COUNT = 3;
|
const MAX_RETRY_COUNT = 3;
|
||||||
const RETRY_DELAY = 2000; // 2 seconds
|
const RETRY_DELAY = 2000; // 2 seconds
|
||||||
@@ -17,7 +12,7 @@ const RETRY_DELAY = 2000; // 2 seconds
|
|||||||
export const getAuthenticationCode = () => (dispatch, getState) => {
|
export const getAuthenticationCode = () => (dispatch, getState) => {
|
||||||
setTokenRefreshing(true);
|
setTokenRefreshing(true);
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getAuthenticationCode onSuccess: ", response.data);
|
console.log('getAuthenticationCode onSuccess: ', response.data);
|
||||||
const accessToken = response.data.data.accessToken;
|
const accessToken = response.data.data.accessToken;
|
||||||
const refreshToken = response.data.data.refreshToken ?? null;
|
const refreshToken = response.data.data.refreshToken ?? null;
|
||||||
|
|
||||||
@@ -27,21 +22,11 @@ export const getAuthenticationCode = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getAuthenticationCode onFail: ", error);
|
console.error('getAuthenticationCode onFail: ', error);
|
||||||
setTokenRefreshing(false);
|
setTokenRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(dispatch, getState, 'get', URLS.GET_AUTHENTICATION_CODE, {}, {}, onSuccess, onFail, true);
|
||||||
dispatch,
|
|
||||||
getState,
|
|
||||||
"get",
|
|
||||||
URLS.GET_AUTHENTICATION_CODE,
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
onSuccess,
|
|
||||||
onFail,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// IF-LGSP-001 디바이스 등록 및 약관 동의
|
// IF-LGSP-001 디바이스 등록 및 약관 동의
|
||||||
@@ -50,7 +35,7 @@ export const registerDevice =
|
|||||||
const { agreeTerms } = params;
|
const { agreeTerms } = params;
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("registerDevice onSuccess: ", response.data);
|
console.log('registerDevice onSuccess: ', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.REGISTER_DEVICE,
|
type: types.REGISTER_DEVICE,
|
||||||
@@ -65,7 +50,7 @@ export const registerDevice =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("registerDevice onFail: ", error);
|
console.error('registerDevice onFail: ', error);
|
||||||
if (onFailCallback) {
|
if (onFailCallback) {
|
||||||
onFailCallback(error);
|
onFailCallback(error);
|
||||||
}
|
}
|
||||||
@@ -74,7 +59,7 @@ export const registerDevice =
|
|||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"post",
|
'post',
|
||||||
URLS.REGISTER_DEVICE,
|
URLS.REGISTER_DEVICE,
|
||||||
{},
|
{},
|
||||||
{ agreeTerms },
|
{ agreeTerms },
|
||||||
@@ -89,7 +74,7 @@ export const registerDeviceInfo = (params) => (dispatch, getState) => {
|
|||||||
const { evntTpCd, evntId, evntApplcnFlag, entryMenu, mbphNo } = params;
|
const { evntTpCd, evntId, evntApplcnFlag, entryMenu, mbphNo } = params;
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("registerDeviceInfo onSuccess: ", response.data);
|
console.log('registerDeviceInfo onSuccess: ', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.REGISTER_DEVICE_INFO,
|
type: types.REGISTER_DEVICE_INFO,
|
||||||
@@ -99,13 +84,13 @@ export const registerDeviceInfo = (params) => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("registerDeviceInfo onFail: ", error);
|
console.error('registerDeviceInfo onFail: ', error);
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"post",
|
'post',
|
||||||
URLS.REGISTER_DEVICE_INFO,
|
URLS.REGISTER_DEVICE_INFO,
|
||||||
{},
|
{},
|
||||||
{ evntTpCd, evntId, evntApplcnFlag, entryMenu, mbphNo },
|
{ evntTpCd, evntId, evntApplcnFlag, entryMenu, mbphNo },
|
||||||
@@ -117,7 +102,7 @@ export const registerDeviceInfo = (params) => (dispatch, getState) => {
|
|||||||
// 디바이스 부가 정보 조회 IF-LGSP-003
|
// 디바이스 부가 정보 조회 IF-LGSP-003
|
||||||
export const getDeviceAdditionInfo = () => (dispatch, getState) => {
|
export const getDeviceAdditionInfo = () => (dispatch, getState) => {
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getDeviceAdditionInfo onSuccess: ", response.data);
|
console.log('getDeviceAdditionInfo onSuccess: ', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_DEVICE_INFO,
|
type: types.GET_DEVICE_INFO,
|
||||||
@@ -126,26 +111,17 @@ export const getDeviceAdditionInfo = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getDeviceAdditionInfo onFail: ", error);
|
console.error('getDeviceAdditionInfo onFail: ', error);
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(dispatch, getState, 'get', URLS.GET_DEVICE_INFO, {}, {}, onSuccess, onFail);
|
||||||
dispatch,
|
|
||||||
getState,
|
|
||||||
"get",
|
|
||||||
URLS.GET_DEVICE_INFO,
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
onSuccess,
|
|
||||||
onFail
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 인증번호 재요청 IF-LGSP-096
|
// 인증번호 재요청 IF-LGSP-096
|
||||||
export const getReAuthenticationCode = () => (dispatch, getState) => {
|
export const getReAuthenticationCode = () => (dispatch, getState) => {
|
||||||
setTokenRefreshing(true);
|
setTokenRefreshing(true);
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getReAuthenticationCode onSuccess: ", response.data);
|
// console.log("getReAuthenticationCode onSuccess: ", response.data);
|
||||||
const accessToken = response.data.data.accessToken;
|
const accessToken = response.data.data.accessToken;
|
||||||
dispatch(changeLocalSettings({ accessToken }));
|
dispatch(changeLocalSettings({ accessToken }));
|
||||||
setTokenRefreshing(false);
|
setTokenRefreshing(false);
|
||||||
@@ -153,14 +129,14 @@ export const getReAuthenticationCode = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getReAuthenticationCode onFail: ", error);
|
console.error('getReAuthenticationCode onFail: ', error);
|
||||||
setTokenRefreshing(false);
|
setTokenRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"get",
|
'get',
|
||||||
URLS.GET_RE_AUTHENTICATION_CODE,
|
URLS.GET_RE_AUTHENTICATION_CODE,
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { URLS } from "../api/apiConfig";
|
import { URLS } from '../api/apiConfig';
|
||||||
import { TAxios,TAxiosPromise } from "../api/TAxios";
|
import { TAxios, TAxiosPromise } from '../api/TAxios';
|
||||||
import { types } from "./actionTypes";
|
import { types } from './actionTypes';
|
||||||
import { changeAppStatus, getTermsAgreeYn } from "./commonActions";
|
import { changeAppStatus, getTermsAgreeYn } from './commonActions';
|
||||||
import { collectBannerPositions } from "../utils/domUtils";
|
import { collectBannerPositions } from '../utils/domUtils';
|
||||||
|
|
||||||
// 약관 정보 조회 IF-LGSP-005
|
// 약관 정보 조회 IF-LGSP-005
|
||||||
export const getHomeTerms = (props) => (dispatch, getState) => {
|
export const getHomeTerms = (props) => (dispatch, getState) => {
|
||||||
const { trmsTpCdList, mbrNo } = props;
|
const { trmsTpCdList, mbrNo } = props;
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getHomeTerms onSuccess ", response.data);
|
console.log('getHomeTerms onSuccess ', response.data);
|
||||||
|
|
||||||
if (response.data.retCode === 0) {
|
if (response.data.retCode === 0) {
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -23,13 +23,13 @@ export const getHomeTerms = (props) => (dispatch, getState) => {
|
|||||||
const termsIdMap = {};
|
const termsIdMap = {};
|
||||||
let hasOptionalTerms = false; // MST00405 존재 여부 확인
|
let hasOptionalTerms = false; // MST00405 존재 여부 확인
|
||||||
|
|
||||||
response.data.data.terms.forEach(term => {
|
response.data.data.terms.forEach((term) => {
|
||||||
if (term.trmsTpCd && term.trmsId) {
|
if (term.trmsTpCd && term.trmsId) {
|
||||||
termsIdMap[term.trmsTpCd] = term.trmsId;
|
termsIdMap[term.trmsTpCd] = term.trmsId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MST00405 선택약관 존재 여부 확인
|
// MST00405 선택약관 존재 여부 확인
|
||||||
if (term.trmsTpCd === "MST00405") {
|
if (term.trmsTpCd === 'MST00405') {
|
||||||
hasOptionalTerms = true;
|
hasOptionalTerms = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -49,11 +49,16 @@ export const getHomeTerms = (props) => (dispatch, getState) => {
|
|||||||
payload: finalOptionalTermsValue,
|
payload: finalOptionalTermsValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[optionalTermsAvailable] 실제값:", hasOptionalTerms, "강제설정값:", finalOptionalTermsValue);
|
console.log(
|
||||||
|
'[optionalTermsAvailable] 실제값:',
|
||||||
|
hasOptionalTerms,
|
||||||
|
'강제설정값:',
|
||||||
|
finalOptionalTermsValue
|
||||||
|
);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log("약관 ID 매핑 생성:", termsIdMap);
|
console.log('약관 ID 매핑 생성:', termsIdMap);
|
||||||
console.log("선택약관 존재 여부:", hasOptionalTerms);
|
console.log('선택약관 존재 여부:', hasOptionalTerms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +69,13 @@ export const getHomeTerms = (props) => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getHomeTerms onFail ", error);
|
console.error('getHomeTerms onFail ', error);
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"get",
|
'get',
|
||||||
URLS.GET_HOME_TERMS,
|
URLS.GET_HOME_TERMS,
|
||||||
{ trmsTpCdList, mbrNo },
|
{ trmsTpCdList, mbrNo },
|
||||||
{},
|
{},
|
||||||
@@ -84,16 +89,18 @@ export const fetchCurrentUserHomeTerms = () => (dispatch, getState) => {
|
|||||||
const loginUserData = getState().common.appStatus.loginUserData;
|
const loginUserData = getState().common.appStatus.loginUserData;
|
||||||
|
|
||||||
if (!loginUserData || !loginUserData.userNumber) {
|
if (!loginUserData || !loginUserData.userNumber) {
|
||||||
console.error("fetchCurrentUserHomeTerms: userNumber (mbrNo) is not available. User might not be logged in.");
|
console.error(
|
||||||
|
'fetchCurrentUserHomeTerms: userNumber (mbrNo) is not available. User might not be logged in.'
|
||||||
|
);
|
||||||
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mbrNo = loginUserData.userNumber;
|
const mbrNo = loginUserData.userNumber;
|
||||||
const trmsTpCdList = "MST00401, MST00402, MST00405"; // 기본 약관 코드 리스트
|
const trmsTpCdList = 'MST00401, MST00402, MST00405'; // 기본 약관 코드 리스트
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("fetchCurrentUserHomeTerms onSuccess ", response.data);
|
console.log('fetchCurrentUserHomeTerms onSuccess ', response.data);
|
||||||
|
|
||||||
if (response.data.retCode === 0) {
|
if (response.data.retCode === 0) {
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -107,13 +114,13 @@ export const fetchCurrentUserHomeTerms = () => (dispatch, getState) => {
|
|||||||
const termsIdMap = {};
|
const termsIdMap = {};
|
||||||
let hasOptionalTerms = false; // MST00405 존재 여부 확인
|
let hasOptionalTerms = false; // MST00405 존재 여부 확인
|
||||||
|
|
||||||
response.data.data.terms.forEach(term => {
|
response.data.data.terms.forEach((term) => {
|
||||||
if (term.trmsTpCd && term.trmsId) {
|
if (term.trmsTpCd && term.trmsId) {
|
||||||
termsIdMap[term.trmsTpCd] = term.trmsId;
|
termsIdMap[term.trmsTpCd] = term.trmsId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MST00405 선택약관 존재 여부 확인
|
// MST00405 선택약관 존재 여부 확인
|
||||||
if (term.trmsTpCd === "MST00405") {
|
if (term.trmsTpCd === 'MST00405') {
|
||||||
hasOptionalTerms = true;
|
hasOptionalTerms = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -132,11 +139,16 @@ export const fetchCurrentUserHomeTerms = () => (dispatch, getState) => {
|
|||||||
type: types.SET_OPTIONAL_TERMS_AVAILABILITY,
|
type: types.SET_OPTIONAL_TERMS_AVAILABILITY,
|
||||||
payload: finalOptionalTermsValue,
|
payload: finalOptionalTermsValue,
|
||||||
});
|
});
|
||||||
console.log("[optionalTermsAvailable] 실제값:", hasOptionalTerms, "강제설정값:", finalOptionalTermsValue);
|
console.log(
|
||||||
|
'[optionalTermsAvailable] 실제값:',
|
||||||
|
hasOptionalTerms,
|
||||||
|
'강제설정값:',
|
||||||
|
finalOptionalTermsValue
|
||||||
|
);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log("약관 ID 매핑 생성:", termsIdMap);
|
console.log('약관 ID 매핑 생성:', termsIdMap);
|
||||||
console.log("선택약관 존재 여부:", hasOptionalTerms);
|
console.log('선택약관 존재 여부:', hasOptionalTerms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,57 +163,54 @@ export const fetchCurrentUserHomeTerms = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("fetchCurrentUserHomeTerms onFail ", error);
|
console.error('fetchCurrentUserHomeTerms onFail ', error);
|
||||||
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"get",
|
'get',
|
||||||
URLS.GET_HOME_TERMS, // 동일한 API 엔드포인트 사용
|
URLS.GET_HOME_TERMS, // 동일한 API 엔드포인트 사용
|
||||||
{ trmsTpCdList, mbrNo },
|
{ trmsTpCdList, mbrNo },
|
||||||
{},
|
{},
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onFail
|
onFail
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 기존 TAxios 패턴과 일치하는 안전한 Redux Action
|
// 기존 TAxios 패턴과 일치하는 안전한 Redux Action
|
||||||
export const fetchCurrentUserHomeTermsSafe = () => async (dispatch, getState) => {
|
export const fetchCurrentUserHomeTermsSafe = () => async (dispatch, getState) => {
|
||||||
const loginUserData = getState().common.appStatus.loginUserData;
|
const loginUserData = getState().common.appStatus.loginUserData;
|
||||||
|
|
||||||
if (!loginUserData || !loginUserData.userNumber) {
|
if (!loginUserData || !loginUserData.userNumber) {
|
||||||
console.error("fetchCurrentUserHomeTerms: userNumber is not available");
|
console.error('fetchCurrentUserHomeTerms: userNumber is not available');
|
||||||
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
||||||
return { success: false, message: "사용자 정보가 없습니다." };
|
return { success: false, message: '사용자 정보가 없습니다.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const mbrNo = loginUserData.userNumber;
|
const mbrNo = loginUserData.userNumber;
|
||||||
const trmsTpCdList = "MST00401, MST00402, MST00405";
|
const trmsTpCdList = 'MST00401, MST00402, MST00405';
|
||||||
|
|
||||||
console.log("Fetching home terms for user:", mbrNo);
|
console.log('Fetching home terms for user:', mbrNo);
|
||||||
|
|
||||||
// 안전한 API 호출 (기존 TAxios 패턴과 동일)
|
// 안전한 API 호출 (기존 TAxios 패턴과 동일)
|
||||||
const result = await TAxiosPromise(
|
const result = await TAxiosPromise(dispatch, getState, 'get', URLS.GET_HOME_TERMS, {
|
||||||
dispatch,
|
trmsTpCdList,
|
||||||
getState,
|
mbrNo,
|
||||||
"get",
|
});
|
||||||
URLS.GET_HOME_TERMS,
|
|
||||||
{ trmsTpCdList, mbrNo }
|
|
||||||
);
|
|
||||||
|
|
||||||
// 네트워크 에러인 경우
|
// 네트워크 에러인 경우
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error("fetchCurrentUserHomeTerms network error:", result.error);
|
console.error('fetchCurrentUserHomeTerms network error:', result.error);
|
||||||
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
||||||
return { success: false, message: "네트워크 오류가 발생했습니다." };
|
return { success: false, message: '네트워크 오류가 발생했습니다.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 TAxios처럼 특별한 retCode들은 TAxios 내부에서 이미 처리됨
|
// 기존 TAxios처럼 특별한 retCode들은 TAxios 내부에서 이미 처리됨
|
||||||
// (401, 402, 501, 602, 603, 604 등은 TAxios에서 알아서 처리하고 onSuccess가 호출되지 않음)
|
// (401, 402, 501, 602, 603, 604 등은 TAxios에서 알아서 처리하고 onSuccess가 호출되지 않음)
|
||||||
|
|
||||||
console.log("fetchCurrentUserHomeTerms response:", result.data);
|
console.log('fetchCurrentUserHomeTerms response:', result.data);
|
||||||
|
|
||||||
// 정상적으로 onSuccess가 호출된 경우에만 여기까지 옴
|
// 정상적으로 onSuccess가 호출된 경우에만 여기까지 옴
|
||||||
if (result.data && result.data.retCode === 0) {
|
if (result.data && result.data.retCode === 0) {
|
||||||
@@ -216,13 +225,13 @@ export const fetchCurrentUserHomeTermsSafe = () => async (dispatch, getState) =>
|
|||||||
const termsIdMap = {};
|
const termsIdMap = {};
|
||||||
let hasOptionalTerms = false; // MST00405 존재 여부 확인
|
let hasOptionalTerms = false; // MST00405 존재 여부 확인
|
||||||
|
|
||||||
result.data.data.terms.forEach(term => {
|
result.data.data.terms.forEach((term) => {
|
||||||
if (term.trmsTpCd && term.trmsId) {
|
if (term.trmsTpCd && term.trmsId) {
|
||||||
termsIdMap[term.trmsTpCd] = term.trmsId;
|
termsIdMap[term.trmsTpCd] = term.trmsId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MST00405 선택약관 존재 여부 확인
|
// MST00405 선택약관 존재 여부 확인
|
||||||
if (term.trmsTpCd === "MST00405") {
|
if (term.trmsTpCd === 'MST00405') {
|
||||||
hasOptionalTerms = true;
|
hasOptionalTerms = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -242,9 +251,14 @@ export const fetchCurrentUserHomeTermsSafe = () => async (dispatch, getState) =>
|
|||||||
payload: finalOptionalTermsValue,
|
payload: finalOptionalTermsValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log("약관 ID 매핑 생성:", termsIdMap);
|
console.log('약관 ID 매핑 생성:', termsIdMap);
|
||||||
console.log("선택약관 존재 여부 - 실제값:", hasOptionalTerms, "강제설정값:", finalOptionalTermsValue);
|
console.log(
|
||||||
|
'선택약관 존재 여부 - 실제값:',
|
||||||
|
hasOptionalTerms,
|
||||||
|
'강제설정값:',
|
||||||
|
finalOptionalTermsValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,21 +271,19 @@ export const fetchCurrentUserHomeTermsSafe = () => async (dispatch, getState) =>
|
|||||||
} else {
|
} else {
|
||||||
// retCode가 0이 아닌 일반적인 API 에러
|
// retCode가 0이 아닌 일반적인 API 에러
|
||||||
// Chromium68 호환성을 위해 Optional Chaining 제거
|
// Chromium68 호환성을 위해 Optional Chaining 제거
|
||||||
console.error("API returned non-zero retCode:", result.data && result.data.retCode);
|
console.error('API returned non-zero retCode:', result.data && result.data.retCode);
|
||||||
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
dispatch({ type: types.GET_TERMS_AGREE_YN_FAILURE });
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: (result.data && result.data.retMsg) || "서버 오류가 발생했습니다."
|
message: (result.data && result.data.retMsg) || '서버 오류가 발생했습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 메뉴 목록 조회 IF-LGSP-044
|
// 메뉴 목록 조회 IF-LGSP-044
|
||||||
export const getHomeMenu = () => (dispatch, getState) => {
|
export const getHomeMenu = () => (dispatch, getState) => {
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getHomeMenu onSuccess ", response.data);
|
// console.log("getHomeMenu onSuccess ", response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_HOME_MENU,
|
type: types.GET_HOME_MENU,
|
||||||
@@ -280,29 +292,20 @@ export const getHomeMenu = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getHomeMenu onFail ", error);
|
console.error('getHomeMenu onFail ', error);
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(dispatch, getState, 'get', URLS.GET_HOME_MENU, {}, {}, onSuccess, onFail);
|
||||||
dispatch,
|
|
||||||
getState,
|
|
||||||
"get",
|
|
||||||
URLS.GET_HOME_MENU,
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
onSuccess,
|
|
||||||
onFail
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테마 전시 정보 상세 조회 IF-LGSP-060
|
// 테마 전시 정보 상세 조회 IF-LGSP-060
|
||||||
export const getThemeCurationDetailInfo = (params) => (dispatch, getState) => {
|
export const getThemeCurationDetailInfo = (params) => (dispatch, getState) => {
|
||||||
const { patnrId, curationId, bgImgNo } = params;
|
const { patnrId, curationId, bgImgNo } = params;
|
||||||
|
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: true, type: "wait" } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: true, type: 'wait' } }));
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getThemeCurationDetailInfo onSuccess", response.data);
|
console.log('getThemeCurationDetailInfo onSuccess', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_THEME_CURATION_DETAIL_INFO,
|
type: types.GET_THEME_CURATION_DETAIL_INFO,
|
||||||
@@ -313,14 +316,14 @@ export const getThemeCurationDetailInfo = (params) => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getThemeCurationDetailInfo onFail", error);
|
console.error('getThemeCurationDetailInfo onFail', error);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"get",
|
'get',
|
||||||
URLS.GET_THEME_CURATION_DETAIL_INFO,
|
URLS.GET_THEME_CURATION_DETAIL_INFO,
|
||||||
{ patnrId, curationId, bgImgNo },
|
{ patnrId, curationId, bgImgNo },
|
||||||
{},
|
{},
|
||||||
@@ -332,10 +335,10 @@ export const getThemeCurationDetailInfo = (params) => (dispatch, getState) => {
|
|||||||
export const getThemeHotelDetailInfo = (params) => (dispatch, getState) => {
|
export const getThemeHotelDetailInfo = (params) => (dispatch, getState) => {
|
||||||
const { patnrId, curationId } = params;
|
const { patnrId, curationId } = params;
|
||||||
|
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: true, type: "wait" } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: true, type: 'wait' } }));
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getThemeHotelDetailInfo onSuccess", response.data);
|
console.log('getThemeHotelDetailInfo onSuccess', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_THEME_HOTEL_DETAIL_INFO,
|
type: types.GET_THEME_HOTEL_DETAIL_INFO,
|
||||||
@@ -346,14 +349,14 @@ export const getThemeHotelDetailInfo = (params) => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getThemeHotelDetailInfo onFail", error);
|
console.error('getThemeHotelDetailInfo onFail', error);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"get",
|
'get',
|
||||||
URLS.GET_THEME_HOTEL_DETAIL_INFO,
|
URLS.GET_THEME_HOTEL_DETAIL_INFO,
|
||||||
{ patnrId, curationId },
|
{ patnrId, curationId },
|
||||||
{},
|
{},
|
||||||
@@ -364,7 +367,7 @@ export const getThemeHotelDetailInfo = (params) => (dispatch, getState) => {
|
|||||||
// HOME LAYOUT 정보 조회 IF-LGSP-300
|
// HOME LAYOUT 정보 조회 IF-LGSP-300
|
||||||
export const getHomeLayout = () => (dispatch, getState) => {
|
export const getHomeLayout = () => (dispatch, getState) => {
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getHomeLayout onSuccess", response.data);
|
console.log('getHomeLayout onSuccess', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_HOME_LAYOUT,
|
type: types.GET_HOME_LAYOUT,
|
||||||
@@ -374,57 +377,39 @@ export const getHomeLayout = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getHomeLayout onFail", error);
|
console.error('getHomeLayout onFail', error);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(dispatch, getState, 'get', URLS.GET_HOME_LAYOUT, {}, {}, onSuccess, onFail);
|
||||||
dispatch,
|
|
||||||
getState,
|
|
||||||
"get",
|
|
||||||
URLS.GET_HOME_LAYOUT,
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
onSuccess,
|
|
||||||
onFail
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// HOME Main Contents Banner 정보 조회 IF-LGSP-301
|
// HOME Main Contents Banner 정보 조회 IF-LGSP-301
|
||||||
export const getHomeMainContents = () => (dispatch, getState) => {
|
export const getHomeMainContents = () => (dispatch, getState) => {
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getHomeMainContents onSuccess", response.data);
|
console.log('getHomeMainContents onSuccess', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_HOME_MAIN_CONTENTS,
|
type: types.GET_HOME_MAIN_CONTENTS,
|
||||||
payload: response.data.data,
|
payload: response.data.data,
|
||||||
status: "fulfilled",
|
status: 'fulfilled',
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getHomeMainContents onFail", error);
|
console.error('getHomeMainContents onFail', error);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(dispatch, getState, 'get', URLS.GET_HOME_MAIN_CONTENTS, {}, {}, onSuccess, onFail);
|
||||||
dispatch,
|
|
||||||
getState,
|
|
||||||
"get",
|
|
||||||
URLS.GET_HOME_MAIN_CONTENTS,
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
onSuccess,
|
|
||||||
onFail
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Theme 전시 정보 조회 : IF-LGSP-045
|
// Theme 전시 정보 조회 : IF-LGSP-045
|
||||||
export const getThemeCurationInfo = () => (dispatch, getState) => {
|
export const getThemeCurationInfo = () => (dispatch, getState) => {
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getThemeCurationInfo onSuccess", response.data);
|
console.log('getThemeCurationInfo onSuccess', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_THEME_CURATION_INFO,
|
type: types.GET_THEME_CURATION_INFO,
|
||||||
@@ -435,30 +420,21 @@ export const getThemeCurationInfo = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getThemeCurationInfo onFail", error);
|
console.error('getThemeCurationInfo onFail', error);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(dispatch, getState, 'get', URLS.GET_THEME_CURATION_INFO, {}, {}, onSuccess, onFail);
|
||||||
dispatch,
|
|
||||||
getState,
|
|
||||||
"get",
|
|
||||||
URLS.GET_THEME_CURATION_INFO,
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
onSuccess,
|
|
||||||
onFail
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테마 메뉴(=테마 페이지) 선반 조회 : IF-LGSP-095
|
// 테마 메뉴(=테마 페이지) 선반 조회 : IF-LGSP-095
|
||||||
export const getThemeMenuShelfInfo = (props) => (dispatch, getState) => {
|
export const getThemeMenuShelfInfo = (props) => (dispatch, getState) => {
|
||||||
const { curationId } = props;
|
const { curationId } = props;
|
||||||
|
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: true, type: "wait" } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: true, type: 'wait' } }));
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getThemeMenuShelfInfo onSuccess", response.data);
|
console.log('getThemeMenuShelfInfo onSuccess', response.data);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_THEME_MENU_SHELF_INFO,
|
type: types.GET_THEME_MENU_SHELF_INFO,
|
||||||
@@ -467,14 +443,14 @@ export const getThemeMenuShelfInfo = (props) => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getThemeMenuShelfInfo onFail", error);
|
console.error('getThemeMenuShelfInfo onFail', error);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"get",
|
'get',
|
||||||
URLS.GET_THEME_MENU_SHELF_INFO,
|
URLS.GET_THEME_MENU_SHELF_INFO,
|
||||||
{ curationId },
|
{ curationId },
|
||||||
{},
|
{},
|
||||||
@@ -507,6 +483,11 @@ export const setDefaultFocus = (focus) => ({
|
|||||||
payload: focus,
|
payload: focus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setVideoTransitionLock = (isLocked) => ({
|
||||||
|
type: types.SET_VIDEO_TRANSITION_LOCK,
|
||||||
|
payload: Boolean(isLocked),
|
||||||
|
});
|
||||||
|
|
||||||
export const checkEnterThroughGNB = (boolean) => ({
|
export const checkEnterThroughGNB = (boolean) => ({
|
||||||
type: types.CHECK_ENTER_THROUGH_GNB,
|
type: types.CHECK_ENTER_THROUGH_GNB,
|
||||||
payload: boolean,
|
payload: boolean,
|
||||||
@@ -514,8 +495,8 @@ export const checkEnterThroughGNB = (boolean) => ({
|
|||||||
|
|
||||||
export const setBannerIndex = (bannerId, index) => {
|
export const setBannerIndex = (bannerId, index) => {
|
||||||
if (!bannerId) {
|
if (!bannerId) {
|
||||||
console.warn("setBannerIndex called with undefined bannerId");
|
console.warn('setBannerIndex called with undefined bannerId');
|
||||||
return { type: "NO_OP" };
|
return { type: 'NO_OP' };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: types.SET_BANNER_INDEX,
|
type: types.SET_BANNER_INDEX,
|
||||||
@@ -569,10 +550,10 @@ export const collectAndSaveBannerPositions = (bannerIds) => async (dispatch) =>
|
|||||||
const positions = await collectBannerPositions(bannerIds);
|
const positions = await collectBannerPositions(bannerIds);
|
||||||
dispatch(setBannerPositions(positions));
|
dispatch(setBannerPositions(positions));
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log("[homeActions] 배너 위치 수집 완료:", positions);
|
console.log('[homeActions] 배너 위치 수집 완료:', positions);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[homeActions] 배너 위치 수집 실패:", error);
|
console.error('[homeActions] 배너 위치 수집 실패:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import { URLS } from '../api/apiConfig';
|
import { URLS } from '../api/apiConfig';
|
||||||
import { TAxios } from '../api/TAxios';
|
import { TAxios } from '../api/TAxios';
|
||||||
import { convertUtcToLocal } from '../components/MediaPlayer/util';
|
import { convertUtcToLocal } from '../components/MediaPlayer/util';
|
||||||
import {
|
import { CATEGORY_DATA_MAX_RESULTS_LIMIT, LOG_CONTEXT_NAME, LOG_MESSAGE_ID } from '../utils/Config';
|
||||||
CATEGORY_DATA_MAX_RESULTS_LIMIT,
|
|
||||||
LOG_CONTEXT_NAME,
|
|
||||||
LOG_MESSAGE_ID,
|
|
||||||
} from '../utils/Config';
|
|
||||||
import * as HelperMethods from '../utils/helperMethods';
|
import * as HelperMethods from '../utils/helperMethods';
|
||||||
import { types } from './actionTypes';
|
import { types } from './actionTypes';
|
||||||
import {
|
import { addReservation, changeAppStatus, deleteReservation } from './commonActions';
|
||||||
addReservation,
|
|
||||||
changeAppStatus,
|
|
||||||
deleteReservation,
|
|
||||||
} from './commonActions';
|
|
||||||
|
|
||||||
//IF-LGSP-007
|
//IF-LGSP-007
|
||||||
export const getMainLiveShow = (props) => (dispatch, getState) => {
|
export const getMainLiveShow = (props) => (dispatch, getState) => {
|
||||||
@@ -233,7 +225,7 @@ export const getSubCategory =
|
|||||||
getState,
|
getState,
|
||||||
'get',
|
'get',
|
||||||
URLS.GET_SUB_CATEGORY,
|
URLS.GET_SUB_CATEGORY,
|
||||||
{ lgCatCd, patnrIdList, pageSize, pageNo, tabType, filterType,recommendIncFlag },
|
{ lgCatCd, patnrIdList, pageSize, pageNo, tabType, filterType, recommendIncFlag },
|
||||||
{},
|
{},
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onFail
|
onFail
|
||||||
@@ -435,7 +427,7 @@ export const getMainLiveShowNowProduct =
|
|||||||
({ patnrId, showId, lstChgDt }) =>
|
({ patnrId, showId, lstChgDt }) =>
|
||||||
(dispatch, getState) => {
|
(dispatch, getState) => {
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log('getMainLiveShowNowProduct onSuccess', response.data);
|
// console.log('getMainLiveShowNowProduct onSuccess', response.data);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_MAIN_LIVE_SHOW_NOW_PRODUCT,
|
type: types.GET_MAIN_LIVE_SHOW_NOW_PRODUCT,
|
||||||
|
|||||||
@@ -23,15 +23,16 @@ export const startMediaPlayer =
|
|||||||
const topPanel = panels[panels.length - 1];
|
const topPanel = panels[panels.length - 1];
|
||||||
let panelWorkingAction = pushPanel;
|
let panelWorkingAction = pushPanel;
|
||||||
|
|
||||||
console.log('[startMediaPlayer] ========== Called ==========');
|
console.log('[startMediaPlayer]-LoadingVideo 🚀 시작:', {
|
||||||
console.log('[startMediaPlayer] Current panels:', JSON.stringify(panels, null, 2));
|
showUrl: rest?.showUrl?.substring(0, 50),
|
||||||
console.log('[startMediaPlayer] topPanel:', JSON.stringify(topPanel, null, 2));
|
showNm: rest?.showNm,
|
||||||
|
prdtId: rest?.prdtId,
|
||||||
|
modal,
|
||||||
|
modalContainerId,
|
||||||
|
});
|
||||||
|
|
||||||
if (topPanel && topPanel.name === panel_names.MEDIA_PANEL) {
|
if (topPanel && topPanel.name === panel_names.MEDIA_PANEL) {
|
||||||
panelWorkingAction = updatePanel;
|
panelWorkingAction = updatePanel;
|
||||||
console.log('[startMediaPlayer] Using updatePanel (existing MediaPanel)');
|
|
||||||
} else {
|
|
||||||
console.log('[startMediaPanel] Using pushPanel (new MediaPanel)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParams = {
|
const allParams = {
|
||||||
@@ -42,8 +43,6 @@ export const startMediaPlayer =
|
|||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[startMediaPlayer] All parameters:', JSON.stringify(allParams, null, 2));
|
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
panelWorkingAction(
|
panelWorkingAction(
|
||||||
{
|
{
|
||||||
@@ -54,7 +53,7 @@ export const startMediaPlayer =
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[startMediaPlayer] Panel action dispatched');
|
console.log('[startMediaPlayer]-LoadingVideo ✅ MediaPanel dispatch 완료');
|
||||||
|
|
||||||
if (modal && modalContainerId && !spotlightDisable) {
|
if (modal && modalContainerId && !spotlightDisable) {
|
||||||
Spotlight.setPointerMode(false);
|
Spotlight.setPointerMode(false);
|
||||||
@@ -90,18 +89,18 @@ export const finishMediaPreview = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 강제로 modal MediaPanel을 종료합니다 (스택 어디에 있든)
|
* 강제로 DetailPanel ProductVideo MediaPanel을 종료합니다 (modal/fullscreen 모두)
|
||||||
*/
|
*/
|
||||||
export const finishModalMediaForce = () => (dispatch, getState) => {
|
export const finishModalMediaForce = () => (dispatch, getState) => {
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
|
|
||||||
const hasModalMediaPanel = panels.some(
|
const hasProductVideoPanel = panels.some(
|
||||||
(panel) => panel.name === panel_names.MEDIA_PANEL && panel.panelInfo?.modal
|
(panel) =>
|
||||||
|
panel.name === panel_names.MEDIA_PANEL &&
|
||||||
|
(panel.panelInfo?.modal || panel.panelInfo?.modalContainerId === 'product-video-player')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasModalMediaPanel) {
|
if (hasProductVideoPanel) {
|
||||||
// console.log('[finishModalMediaForce] Force closing modal MediaPanel');
|
|
||||||
|
|
||||||
if (startMediaFocusTimer) {
|
if (startMediaFocusTimer) {
|
||||||
clearTimeout(startMediaFocusTimer);
|
clearTimeout(startMediaFocusTimer);
|
||||||
startMediaFocusTimer = null;
|
startMediaFocusTimer = null;
|
||||||
@@ -238,44 +237,43 @@ export const switchMediaToModal = (modalContainerId, modalClassName) => (dispatc
|
|||||||
export const minimizeModalMedia = () => (dispatch, getState) => {
|
export const minimizeModalMedia = () => (dispatch, getState) => {
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
|
|
||||||
console.log('[minimizeModalMedia] ========== Called ==========');
|
console.log('[Minimize] ========== Called ==========');
|
||||||
console.log('[minimizeModalMedia] Total panels:', panels.length);
|
console.log('[Minimize] Total panels:', panels.length);
|
||||||
console.log(
|
console.log(
|
||||||
'[minimizeModalMedia] All panels:',
|
'[Minimize] All panels:',panels
|
||||||
JSON.stringify(
|
// JSON.stringify(
|
||||||
panels.map((p) => ({ name: p.name, modal: p.panelInfo?.modal })),
|
// panels.map((p) => ({ name: p.name, modal: p.panelInfo?.modal })),
|
||||||
null,
|
// null,
|
||||||
2
|
// 2
|
||||||
)
|
// )
|
||||||
);
|
);
|
||||||
|
|
||||||
const modalMediaPanel = panels.find(
|
const modalMediaPanel = panels.find(
|
||||||
(panel) => panel.name === panel_names.MEDIA_PANEL && panel.panelInfo?.modal
|
(panel) => panel.name === panel_names.MEDIA_PANEL && panel.panelInfo?.modal
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[minimizeModalMedia] Found modalMediaPanel:', !!modalMediaPanel);
|
// console.log('[Minimize] Found modalMediaPanel:', !!modalMediaPanel);
|
||||||
if (modalMediaPanel) {
|
if (modalMediaPanel) {
|
||||||
console.log(
|
console.log(
|
||||||
'[minimizeModalMedia] modalMediaPanel.panelInfo:',
|
'[Minimize] modalMediaPanel.panelInfo:',
|
||||||
JSON.stringify(modalMediaPanel.panelInfo, null, 2)
|
JSON.stringify(modalMediaPanel.panelInfo, null, 2)
|
||||||
);
|
);
|
||||||
console.log(
|
// console.log('[Minimize] ✅ Minimizing modal MediaPanel (modal=false, isMinimized=true)');
|
||||||
'[minimizeModalMedia] ✅ Minimizing modal MediaPanel (modal=false, isMinimized=true)'
|
|
||||||
);
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updatePanel({
|
updatePanel({
|
||||||
name: panel_names.MEDIA_PANEL,
|
name: panel_names.MEDIA_PANEL,
|
||||||
panelInfo: {
|
panelInfo: {
|
||||||
...modalMediaPanel.panelInfo,
|
...modalMediaPanel.panelInfo,
|
||||||
modal: false, // fullscreen 모드로 전환
|
// modal: false, // fullscreen 모드로 전환
|
||||||
isMinimized: true, // modal-minimized 클래스 적용 (1px 크기)
|
isMinimized: true, // modal-minimized 클래스 적용 (1px 크기)
|
||||||
|
shouldShrinkTo1px: true, // shrink 플래그 추가
|
||||||
// modalContainerId, modalClassName 등은 복원을 위해 유지
|
// modalContainerId, modalClassName 등은 복원을 위해 유지
|
||||||
// isPaused는 변경하지 않음 - 재생은 계속됨
|
// isPaused는 변경하지 않음 - 재생은 계속됨
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('[minimizeModalMedia] ❌ No modal MediaPanel found - cannot minimize');
|
console.log('[Minimize] ❌ No modal MediaPanel found - cannot minimize');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -286,38 +284,46 @@ export const minimizeModalMedia = () => (dispatch, getState) => {
|
|||||||
export const restoreModalMedia = () => (dispatch, getState) => {
|
export const restoreModalMedia = () => (dispatch, getState) => {
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
|
|
||||||
console.log('[restoreModalMedia] ========== Called ==========');
|
if (typeof window !== 'undefined' && window.detailPanelScrollTop !== 0) {
|
||||||
console.log('[restoreModalMedia] Total panels:', panels.length);
|
|
||||||
console.log(
|
console.log(
|
||||||
'[restoreModalMedia] All panels:',
|
'[restoreModalMedia] Blocked restore because detail panel scroll not zero:',
|
||||||
JSON.stringify(
|
window.detailPanelScrollTop
|
||||||
panels.map((p) => ({
|
|
||||||
name: p.name,
|
|
||||||
modal: p.panelInfo?.modal,
|
|
||||||
isMinimized: p.panelInfo?.isMinimized,
|
|
||||||
})),
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// modal=false AND isMinimized=true인 MediaPanel을 찾음 (최소화 상태)
|
// console.log('[Restore]] ========== Called ==========');
|
||||||
|
// console.log('[Restore] Total panels:', panels.length);
|
||||||
|
// console.log(
|
||||||
|
// '[Restore] All panels:',
|
||||||
|
// JSON.stringify(
|
||||||
|
// panels.map((p) => ({
|
||||||
|
// name: p.name,
|
||||||
|
// modal: p.panelInfo?.modal,
|
||||||
|
// isMinimized: p.panelInfo?.isMinimized,
|
||||||
|
// })),
|
||||||
|
// null,
|
||||||
|
// 2
|
||||||
|
// )
|
||||||
|
// );
|
||||||
|
|
||||||
|
// modal=true AND isMinimized=true인 MediaPanel을 찾음 (최소화 상태)
|
||||||
const minimizedMediaPanel = panels.find(
|
const minimizedMediaPanel = panels.find(
|
||||||
(panel) =>
|
(panel) =>
|
||||||
panel.name === panel_names.MEDIA_PANEL &&
|
panel.name === panel_names.MEDIA_PANEL &&
|
||||||
!panel.panelInfo?.modal &&
|
panel.panelInfo?.modal &&
|
||||||
panel.panelInfo?.isMinimized
|
panel.panelInfo?.isMinimized
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[restoreModalMedia] Found minimizedMediaPanel:', !!minimizedMediaPanel);
|
// console.log('[restoreModalMedia] Found minimizedMediaPanel:', !!minimizedMediaPanel);
|
||||||
if (minimizedMediaPanel) {
|
if (minimizedMediaPanel) {
|
||||||
console.log(
|
// console.log(
|
||||||
'[restoreModalMedia] minimizedMediaPanel.panelInfo:',
|
// '[restoreModalMedia] minimizedMediaPanel.panelInfo:',
|
||||||
JSON.stringify(minimizedMediaPanel.panelInfo, null, 2)
|
// JSON.stringify(minimizedMediaPanel.panelInfo, null, 2)
|
||||||
);
|
// );
|
||||||
console.log(
|
// console.log(
|
||||||
'[restoreModalMedia] ✅ Restoring modal MediaPanel (modal=true, isMinimized=false)'
|
// '[restoreModalMedia] ✅ Restoring modal MediaPanel (modal=true, isMinimized=false)'
|
||||||
);
|
// );
|
||||||
dispatch(
|
dispatch(
|
||||||
updatePanel({
|
updatePanel({
|
||||||
name: panel_names.MEDIA_PANEL,
|
name: panel_names.MEDIA_PANEL,
|
||||||
@@ -325,10 +331,11 @@ export const restoreModalMedia = () => (dispatch, getState) => {
|
|||||||
...minimizedMediaPanel.panelInfo,
|
...minimizedMediaPanel.panelInfo,
|
||||||
modal: true, // modal 모드로 복원 (원래 위치로 복귀)
|
modal: true, // modal 모드로 복원 (원래 위치로 복귀)
|
||||||
isMinimized: false, // 최소화 해제
|
isMinimized: false, // 최소화 해제
|
||||||
|
shouldShrinkTo1px: false, // shrink 플래그 초기화
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('[restoreModalMedia] ❌ No minimized MediaPanel found - cannot restore');
|
// console.log('[restoreModalMedia] ❌ No minimized MediaPanel found - cannot restore');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
import { types } from "./actionTypes";
|
import { types } from './actionTypes';
|
||||||
|
import Spotlight from '@enact/spotlight';
|
||||||
|
import { getContainerId } from '@enact/spotlight/src/container';
|
||||||
|
import { panel_names } from '../utils/Config';
|
||||||
|
import { updateHomeInfo } from './homeActions';
|
||||||
|
|
||||||
|
// 시작 메뉴 추적을 위한 상수
|
||||||
|
export const SOURCE_MENUS = {
|
||||||
|
HOME_BEST_SELLER: 'home_best_seller',
|
||||||
|
HOME_PICKED_FOR_YOU: 'home_picked_for_you',
|
||||||
|
HOME_SUB_CATEGORY: 'home_sub_category',
|
||||||
|
HOME_RANDOM_UNIT: 'home_random_unit',
|
||||||
|
HOME_ROLLING_UNIT: 'home_rolling_unit',
|
||||||
|
HOME_EVENT_POPUP: 'home_event_popup',
|
||||||
|
HOME_TODAYS_DEAL: 'home_todays_deal',
|
||||||
|
SEARCH_RESULT: 'search_result',
|
||||||
|
HOME_GENERAL: 'home_general',
|
||||||
|
THEMED_PRODUCT: 'themed_product',
|
||||||
|
GENERAL_PRODUCT: 'general_product',
|
||||||
|
PLAYER_SHOP_NOW: 'player_shop_now', // PlayerPanel의 ShopNow에서 진입
|
||||||
|
PLAYER_MEDIA: 'player_media', // PlayerPanel의 Media에서 진입
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
name: panel_names.PLAYER_PANEL,
|
name: panel_names.PLAYER_PANEL,
|
||||||
@@ -27,3 +48,566 @@ export const resetPanels = (panels) => ({
|
|||||||
type: types.RESET_PANELS,
|
type: types.RESET_PANELS,
|
||||||
payload: panels,
|
payload: panels,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DetailPanel로 이동하는 공통 액션 함수
|
||||||
|
* @param {Object} params - 이동 파라미터
|
||||||
|
* @param {string} params.patnrId - 파트너 ID
|
||||||
|
* @param {string} params.prdtId - 상품 ID
|
||||||
|
* @param {string} [params.curationId] - 큐레이션 ID (테마 상품인 경우)
|
||||||
|
* @param {string} [params.nowShelf] - 현재 셸프 ID
|
||||||
|
* @param {string} [params.type] - 상품 타입 ('theme' 등)
|
||||||
|
* @param {string} [params.sourceMenu] - 시작 메뉴 (SOURCE_MENUS 상수 사용)
|
||||||
|
* @param {Object} [params.additionalInfo] - 추가 정보
|
||||||
|
* @returns {Function} Redux thunk 함수
|
||||||
|
*/
|
||||||
|
export const navigateToDetail = ({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
curationId,
|
||||||
|
nowShelf,
|
||||||
|
type,
|
||||||
|
sourceMenu,
|
||||||
|
additionalInfo = {},
|
||||||
|
}) => {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
// 🔽 현재 포커스 정보 저장 (HomePanel 복귀 시 포커스 복원용)
|
||||||
|
const currentSpotNode = Spotlight.getCurrent();
|
||||||
|
const currentSpotId = currentSpotNode?.getAttribute('data-spotlight-id');
|
||||||
|
const currentContainerId = currentSpotNode ? getContainerId(currentSpotNode) : null;
|
||||||
|
const focusSnapshot = currentSpotId
|
||||||
|
? {
|
||||||
|
lastFocusedTargetId: currentContainerId || currentSpotId,
|
||||||
|
currentSpot: currentSpotId,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const panelInfo = {
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
...additionalInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택적 파라미터들 추가
|
||||||
|
if (curationId) panelInfo.curationId = curationId;
|
||||||
|
if (nowShelf) panelInfo.nowShelf = nowShelf;
|
||||||
|
if (type) panelInfo.type = type;
|
||||||
|
if (sourceMenu) panelInfo.sourceMenu = sourceMenu;
|
||||||
|
|
||||||
|
// 로깅
|
||||||
|
console.log(`[navigateToDetail] ${sourceMenu || 'unknown'} → DetailPanel`, {
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
curationId,
|
||||||
|
nowShelf,
|
||||||
|
type,
|
||||||
|
sourceMenu,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ 그라데이션 배경은 HomePanel 내부 switch 문에서 처리
|
||||||
|
|
||||||
|
// sourceMenu에 따른 사전 처리
|
||||||
|
switch (sourceMenu) {
|
||||||
|
case SOURCE_MENUS.HOME_BEST_SELLER:
|
||||||
|
case SOURCE_MENUS.HOME_PICKED_FOR_YOU:
|
||||||
|
case SOURCE_MENUS.HOME_SUB_CATEGORY:
|
||||||
|
case SOURCE_MENUS.HOME_EVENT_POPUP:
|
||||||
|
case SOURCE_MENUS.HOME_TODAYS_DEAL:
|
||||||
|
case SOURCE_MENUS.HOME_RANDOM_UNIT:
|
||||||
|
case SOURCE_MENUS.HOME_ROLLING_UNIT:
|
||||||
|
case SOURCE_MENUS.HOME_GENERAL: {
|
||||||
|
// ✅ 그라데이션 배경 표시 - HomePanel→DetailPanel 전환 시 (PlayerPanel 출신 제외)
|
||||||
|
|
||||||
|
if (!panelInfo.launchedFromPlayer) {
|
||||||
|
dispatch(
|
||||||
|
updateHomeInfo({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
showGradientBackground: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// console.log('[TRACE-GRADIENT] 🟢 navigateToDetail set showGradientBackground: true - source:', sourceMenu);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'[TRACE-GRADIENT] 🔵 navigateToDetail skipped gradient - launchedFromPlayer: true'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomePanel Redux 상태에 포커스 스냅샷 저장 (Detail→Home 복귀 시 사용)
|
||||||
|
if (Object.keys(focusSnapshot).length > 0) {
|
||||||
|
dispatch(
|
||||||
|
updateHomeInfo({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
...focusSnapshot,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔽 모든 HomePanel에서 DetailPanel로 이동 시 HomeBanner modal 비디오 정지
|
||||||
|
const state = getState();
|
||||||
|
const playerPanelInfo = state.panels.panels.find(
|
||||||
|
(p) => p.name === panel_names.PLAYER_PANEL
|
||||||
|
);
|
||||||
|
|
||||||
|
// playerPanel이 없는 경우 비디오 정지 로직 건너뛰기
|
||||||
|
if (!playerPanelInfo) {
|
||||||
|
// 비디오가 없어도 HomePanel 상태 저장
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
lastSelectedProduct: { patnrId, prdtId },
|
||||||
|
lastActionSource: sourceMenu,
|
||||||
|
...focusSnapshot,
|
||||||
|
...additionalInfo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
panelInfo.fromHome = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentBannerVideoPlaying = playerPanelInfo.panelInfo?.modal !== false;
|
||||||
|
|
||||||
|
// HomeBanner의 modal=true 비디오가 재생 중이면 정지
|
||||||
|
if (isCurrentBannerVideoPlaying) {
|
||||||
|
// 🔽 비디오 상태 저장 후 정지
|
||||||
|
const { finishVideoPreview } = require('./playActions');
|
||||||
|
|
||||||
|
// 비디오 복원을 위한 상태 저장
|
||||||
|
const videoStateToRestore = {
|
||||||
|
...playerPanelInfo.panelInfo,
|
||||||
|
wasPlaying: true,
|
||||||
|
restoreOnBack: true,
|
||||||
|
sourceMenu,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// HomePanel에 비디오 복원 상태 저장
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
videoStateToRestore,
|
||||||
|
lastSelectedProduct: { patnrId, prdtId },
|
||||||
|
lastActionSource: sourceMenu,
|
||||||
|
...focusSnapshot,
|
||||||
|
...additionalInfo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 비디오 상태 저장 후 정지 (로그는 개발 시 필요 시 주석 해제)
|
||||||
|
|
||||||
|
dispatch(finishVideoPreview());
|
||||||
|
} else {
|
||||||
|
// 비디오가 재생 중이 아니어도 HomePanel 상태 저장
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
lastSelectedProduct: { patnrId, prdtId },
|
||||||
|
lastActionSource: sourceMenu,
|
||||||
|
...focusSnapshot,
|
||||||
|
...additionalInfo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomePanel 내부 컴포넌트들: 기본 HomePanel 상태 저장
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
lastSelectedProduct: { patnrId, prdtId },
|
||||||
|
lastActionSource: sourceMenu,
|
||||||
|
...focusSnapshot,
|
||||||
|
...additionalInfo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
panelInfo.sourcePanel = panel_names.HOME_PANEL; // ✅ source panel 정보
|
||||||
|
panelInfo.fromHome = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SOURCE_MENUS.SEARCH_RESULT:
|
||||||
|
// Search: 현재 패널 상태 저장 (updatePanel)
|
||||||
|
if (additionalInfo.searchVal && additionalInfo.currentSpot) {
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.SEARCH_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
searchVal: additionalInfo.searchVal,
|
||||||
|
currentSpot: additionalInfo.currentSpot,
|
||||||
|
tab: additionalInfo.tab || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
panelInfo.sourcePanel = panel_names.SEARCH_PANEL; // ✅ source panel 정보
|
||||||
|
panelInfo.fromSearch = true;
|
||||||
|
panelInfo.searchQuery = additionalInfo.searchVal;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SOURCE_MENUS.THEMED_PRODUCT:
|
||||||
|
// 테마 상품: 별도 처리 필요할 경우
|
||||||
|
panelInfo.sourcePanel = panel_names.HOME_PANEL; // ✅ source panel 정보 (HOME으로 간주)
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SOURCE_MENUS.PLAYER_SHOP_NOW:
|
||||||
|
case SOURCE_MENUS.PLAYER_MEDIA: {
|
||||||
|
// PlayerPanel에서 온 경우
|
||||||
|
const { hidePlayerOverlays } = require('./videoPlayActions');
|
||||||
|
|
||||||
|
// DetailPanel push 전에 VideoPlayer 오버레이 숨김
|
||||||
|
dispatch(hidePlayerOverlays());
|
||||||
|
|
||||||
|
// 현재 포커스된 요소 저장
|
||||||
|
if (Object.keys(focusSnapshot).length > 0) {
|
||||||
|
panelInfo.lastFocusedTargetId = focusSnapshot.lastFocusedTargetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerPanel 정보 보존 (복귀 시 필요)
|
||||||
|
panelInfo.sourcePanel = panel_names.PLAYER_PANEL; // ✅ source panel 정보
|
||||||
|
panelInfo.fromPlayer = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SOURCE_MENUS.GENERAL_PRODUCT:
|
||||||
|
default:
|
||||||
|
// 일반 상품: 기본 처리
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailPanel push
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.DETAIL_PANEL,
|
||||||
|
panelInfo,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테마 상품을 위한 DetailPanel 이동 헬퍼 함수
|
||||||
|
* @param {Object} params - 이동 파라미터
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateToThemeDetail = ({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
curationId,
|
||||||
|
sourceMenu = SOURCE_MENUS.THEMED_PRODUCT,
|
||||||
|
...additionalInfo
|
||||||
|
}) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
curationId,
|
||||||
|
type: 'theme',
|
||||||
|
sourceMenu,
|
||||||
|
...additionalInfo,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈패널 BestSeller에서 DetailPanel로 이동
|
||||||
|
* @param {Object} params - 상품 정보
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateFromBestSeller = ({ patnrId, prdtId, spotlightId }) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
nowShelf: spotlightId,
|
||||||
|
sourceMenu: SOURCE_MENUS.HOME_BEST_SELLER,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈패널 PickedForYou에서 DetailPanel로 이동
|
||||||
|
* @param {Object} params - 상품 정보
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateFromPickedForYou = ({ patnrId, prdtId, spotlightId }) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
nowShelf: spotlightId,
|
||||||
|
sourceMenu: SOURCE_MENUS.HOME_PICKED_FOR_YOU,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈패널 SubCategory에서 DetailPanel로 이동
|
||||||
|
* @param {Object} params - 상품 정보
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateFromSubCategory = ({ patnrId, prdtId, spotlightId }) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
nowShelf: spotlightId,
|
||||||
|
sourceMenu: SOURCE_MENUS.HOME_SUB_CATEGORY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈패널 RandomUnit 배너에서 DetailPanel로 이동
|
||||||
|
* @param {Object} params - 상품 정보
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateFromRandomUnit = ({ patnrId, prdtId, curationId, type = 'product' }) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
curationId,
|
||||||
|
type: type === 'theme' ? 'theme' : undefined,
|
||||||
|
sourceMenu: SOURCE_MENUS.HOME_RANDOM_UNIT,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈패널 RollingUnit 배너에서 DetailPanel로 이동
|
||||||
|
* @param {Object} params - 상품 정보
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateFromRollingUnit = ({ patnrId, prdtId, curationId, additionalInfo = {} }) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
curationId,
|
||||||
|
sourceMenu: SOURCE_MENUS.HOME_ROLLING_UNIT,
|
||||||
|
...additionalInfo,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈패널 EventPopUpBanner에서 DetailPanel로 이동
|
||||||
|
* @param {Object} params - 상품 정보
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateFromEventPopup = ({ patnrId, prdtId }) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
sourceMenu: SOURCE_MENUS.HOME_EVENT_POPUP,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SearchPanel에서 DetailPanel로 이동
|
||||||
|
* @param {Object} params - 검색 및 상품 정보
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateFromSearch = ({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
searchQuery,
|
||||||
|
currentSpot,
|
||||||
|
additionalInfo = {},
|
||||||
|
}) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
sourceMenu: SOURCE_MENUS.SEARCH_RESULT,
|
||||||
|
additionalInfo: {
|
||||||
|
searchVal: searchQuery,
|
||||||
|
currentSpot,
|
||||||
|
tab: 0,
|
||||||
|
...additionalInfo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HomePanel 일반 클릭에서 DetailPanel로 이동
|
||||||
|
* @param {Object} params - 상품 정보
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const navigateFromHomeGeneral = ({ patnrId, prdtId, additionalInfo = {} }) => {
|
||||||
|
return navigateToDetail({
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
sourceMenu: SOURCE_MENUS.HOME_GENERAL,
|
||||||
|
additionalInfo,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DetailPanel에서 돌아올 때 비디오 복원 함수
|
||||||
|
* HomePanel에 저장된 비디오 상태를 확인하고 복원
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const restoreVideoOnBack = () => {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const panels = state.panels.panels;
|
||||||
|
|
||||||
|
// HomePanel 찾기
|
||||||
|
const homePanel = panels.find((p) => p.name === panel_names.HOME_PANEL);
|
||||||
|
const videoStateToRestore = homePanel?.panelInfo?.videoStateToRestore;
|
||||||
|
|
||||||
|
if (!videoStateToRestore || !videoStateToRestore.restoreOnBack) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비디오 복원 시작 (로그는 개발 시 필요 시 주석 해제)
|
||||||
|
|
||||||
|
// 비디오 상태 복원
|
||||||
|
const { startVideoPlayerNew } = require('./playActions');
|
||||||
|
|
||||||
|
// 복원할 비디오 정보 추출
|
||||||
|
const restoreInfo = {
|
||||||
|
bannerId: videoStateToRestore.bannerId || videoStateToRestore.playerState?.currentBannerId,
|
||||||
|
patnrId: videoStateToRestore.patnrId,
|
||||||
|
showId: videoStateToRestore.showId,
|
||||||
|
showUrl: videoStateToRestore.showUrl,
|
||||||
|
shptmBanrTpNm: videoStateToRestore.shptmBanrTpNm,
|
||||||
|
lgCatCd: videoStateToRestore.lgCatCd,
|
||||||
|
modal: true, // HomeBanner는 항상 modal
|
||||||
|
modalContainerId: videoStateToRestore.modalContainerId,
|
||||||
|
modalClassName: videoStateToRestore.modalClassName,
|
||||||
|
chanId: videoStateToRestore.chanId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비디오 재생 시작
|
||||||
|
dispatch(
|
||||||
|
startVideoPlayerNew({
|
||||||
|
...restoreInfo,
|
||||||
|
spotlightDisable: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 복원 상태 정리
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
...homePanel.panelInfo,
|
||||||
|
videoStateToRestore: {
|
||||||
|
...videoStateToRestore,
|
||||||
|
restoreOnBack: false, // 복원 완료 후 플래그 초기화
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DetailPanel 닫기 시 비디오 복원 확인 함수
|
||||||
|
* DetailPanel 패널이 제거될 때 자동으로 비디오 복원 시도
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const handleDetailPanelCloseWithVideoRestore = () => {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const panels = state.panels.panels;
|
||||||
|
|
||||||
|
// 현재 최상단 패널이 DetailPanel인지 확인
|
||||||
|
const topPanel = panels[panels.length - 1];
|
||||||
|
|
||||||
|
if (topPanel?.name === panel_names.DETAIL_PANEL) {
|
||||||
|
// 기존 DetailPanel 닫기 로직 수행
|
||||||
|
dispatch({
|
||||||
|
type: 'POP_PANEL_WITH_VIDEO_RESTORE',
|
||||||
|
payload: panel_names.DETAIL_PANEL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 비디오 복원 시도 (약간의 지연 후)
|
||||||
|
setTimeout(() => {
|
||||||
|
dispatch(restoreVideoOnBack());
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [251114] 명시적 포커스 이동
|
||||||
|
* Panel의 비동기 작업(useEffect, 타이머 등)이 포커스를 탈취하는 것을 방지
|
||||||
|
* @param {string} panelName - 대상 Panel 이름
|
||||||
|
* @param {string} focusTarget - 포커스할 요소 ID
|
||||||
|
* @returns {Function} Redux thunk
|
||||||
|
*/
|
||||||
|
export const focusPanel = (panelName, focusTarget) => {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const panels = state.panels.panels;
|
||||||
|
|
||||||
|
console.log('[focusPanel] 포커스 이동 시도', {
|
||||||
|
panelName,
|
||||||
|
focusTarget,
|
||||||
|
currentPanels: panels.map((p) => p.name),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 안전성 체크 1: Panel이 존재하고 최상단 또는 그 아래에 있는가?
|
||||||
|
const targetPanelIndex = panels.findIndex((p) => p.name === panelName);
|
||||||
|
const targetPanel = panels[targetPanelIndex];
|
||||||
|
const topPanel = panels[panels.length - 1];
|
||||||
|
|
||||||
|
if (!targetPanel) {
|
||||||
|
console.warn(`[focusPanel] ❌ Panel을 찾을 수 없음: ${panelName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel이 최상단 또는 그 아래 레이어에 있는지 확인
|
||||||
|
// MediaPanel(최상단) 위에 다른 Modal이 있는 경우는 허용하지 않음
|
||||||
|
const panelsAboveTarget = panels.slice(targetPanelIndex + 1);
|
||||||
|
const hasBlockingModalAbove = panelsAboveTarget.some(
|
||||||
|
(panel) => panel?.panelInfo?.modal === true && panel.name !== panelName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasBlockingModalAbove) {
|
||||||
|
const blockingModal = panelsAboveTarget.find((panel) => panel?.panelInfo?.modal === true);
|
||||||
|
console.warn(
|
||||||
|
`[focusPanel] ⚠️ 상위에 Modal이 있음. ` +
|
||||||
|
`${panelName}(${targetPanelIndex}층)에 포커스할 수 없음. ` +
|
||||||
|
`상단 Modal: ${blockingModal?.name}(${panelsAboveTarget.indexOf(blockingModal) + targetPanelIndex + 1}층)`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[focusPanel] ✅ Panel 위치 확인: ${panelName}(${targetPanelIndex}층), ` +
|
||||||
|
`전체 Panel: ${panels.length}층`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 포커스 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById(focusTarget);
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
console.warn(`[focusPanel] ❌ 요소를 찾을 수 없음: ${focusTarget}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.offsetParent === null) {
|
||||||
|
console.warn(`[focusPanel] ⚠️ 요소가 숨겨져있음: ${focusTarget}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 포커스 이동
|
||||||
|
Spotlight.focus(focusTarget);
|
||||||
|
console.log(`[focusPanel] ✅ 포커스 이동 성공: ${panelName} → ${focusTarget}`);
|
||||||
|
|
||||||
|
// Reducer에 반영
|
||||||
|
dispatch({
|
||||||
|
type: types.FOCUS_PANEL,
|
||||||
|
payload: {
|
||||||
|
panelName,
|
||||||
|
focusTarget,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ import { panel_names } from '../utils/Config';
|
|||||||
import { types } from './actionTypes';
|
import { types } from './actionTypes';
|
||||||
import { popPanel, pushPanel, updatePanel } from './panelActions';
|
import { popPanel, pushPanel, updatePanel } from './panelActions';
|
||||||
|
|
||||||
|
// 🔽 [251116] 새로운 비디오 상태 관리 시스템 - 재생 상태
|
||||||
|
export const PLAYBACK_STATUS = {
|
||||||
|
LOADING: 'loading',
|
||||||
|
LOAD_SUCCESS: 'load_success',
|
||||||
|
LOAD_ERROR: 'load_error',
|
||||||
|
PLAYING: 'playing',
|
||||||
|
NOT_PLAYING: 'not_playing',
|
||||||
|
BUFFERING: 'buffering',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔽 [251116] 새로운 비디오 상태 관리 시스템 - 화면 상태
|
||||||
|
export const DISPLAY_STATUS = {
|
||||||
|
HIDDEN: 'hidden',
|
||||||
|
VISIBLE: 'visible',
|
||||||
|
MINIMIZED: 'minimized',
|
||||||
|
FULLSCREEN: 'fullscreen',
|
||||||
|
};
|
||||||
|
|
||||||
//yhcho
|
//yhcho
|
||||||
/*
|
/*
|
||||||
dispatch(startVideoPreview({
|
dispatch(startVideoPreview({
|
||||||
@@ -39,18 +57,43 @@ export const clearAllVideoTimers = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const startVideoPlayer =
|
export const startVideoPlayer =
|
||||||
({ modal, modalContainerId, modalClassName, spotlightDisable, useNewPlayer, ...rest }) =>
|
({
|
||||||
|
modal,
|
||||||
|
modalContainerId,
|
||||||
|
modalClassName,
|
||||||
|
spotlightDisable,
|
||||||
|
useNewPlayer,
|
||||||
|
videoId,
|
||||||
|
showUrl,
|
||||||
|
...rest
|
||||||
|
}) =>
|
||||||
(dispatch, getState) => {
|
(dispatch, getState) => {
|
||||||
|
console.log('[startVideoPlayer] ✅ START - videoId:', videoId, ', showUrl:', showUrl, ', modal:', modal);
|
||||||
|
|
||||||
|
// 🔽 [251116] 즉시 로딩 상태 설정
|
||||||
|
const videoIdentifier = videoId || showUrl;
|
||||||
|
if (videoIdentifier) {
|
||||||
|
const displayMode = modal ? DISPLAY_STATUS.VISIBLE : DISPLAY_STATUS.FULLSCREEN;
|
||||||
|
console.log('[startVideoPlayer] 📌 Setting playback loading - identifier:', videoIdentifier, ', displayMode:', displayMode);
|
||||||
|
dispatch(setPlaybackLoading(videoIdentifier, displayMode));
|
||||||
|
} else {
|
||||||
|
console.log('[startVideoPlayer] ⚠️ No videoIdentifier provided (videoId and showUrl are both missing)');
|
||||||
|
}
|
||||||
|
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
const topPanel = panels[panels.length - 1];
|
const topPanel = panels[panels.length - 1];
|
||||||
let panelWorkingAction = pushPanel;
|
let panelWorkingAction = pushPanel;
|
||||||
|
|
||||||
// const panelName = useNewPlayer ? panel_names.PLAYER_PANEL_NEW : panel_names.PLAYER_PANEL;
|
|
||||||
const panelName = panel_names.PLAYER_PANEL;
|
const panelName = panel_names.PLAYER_PANEL;
|
||||||
|
console.log('[startVideoPlayer] 📊 Panel state - panelsCount:', panels.length, ', topPanelName:', topPanel?.name);
|
||||||
|
|
||||||
if (topPanel && topPanel.name === panelName) {
|
if (topPanel && topPanel.name === panelName) {
|
||||||
panelWorkingAction = updatePanel;
|
panelWorkingAction = updatePanel;
|
||||||
|
console.log('[startVideoPlayer] 🔄 UPDATING existing PLAYER_PANEL');
|
||||||
|
} else {
|
||||||
|
console.log('[startVideoPlayer] ➕ PUSHING new PLAYER_PANEL');
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
panelWorkingAction(
|
panelWorkingAction(
|
||||||
{
|
{
|
||||||
@@ -59,18 +102,29 @@ export const startVideoPlayer =
|
|||||||
modal,
|
modal,
|
||||||
modalContainerId,
|
modalContainerId,
|
||||||
modalClassName,
|
modalClassName,
|
||||||
|
videoId, // videoId 추가하여 PlayerPanel에서 사용 가능
|
||||||
|
showUrl, // showUrl 추가하여 PlayerPanel에서 사용 가능
|
||||||
...rest,
|
...rest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (modal && modalContainerId && !spotlightDisable) {
|
console.log('[startVideoPlayer] ✨ Panel action dispatched');
|
||||||
Spotlight.setPointerMode(false);
|
|
||||||
startVideoFocusTimer = setTimeout(() => {
|
// [COMMENTED OUT] 비디오 재생 시 강제 포커스 이동 비활성화
|
||||||
Spotlight.focus(modalContainerId);
|
// if (modal && modalContainerId && !spotlightDisable) {
|
||||||
}, 0);
|
// console.log('[startVideoPlayer] 🎯 Setting Spotlight focus - containerId:', modalContainerId);
|
||||||
}
|
// Spotlight.setPointerMode(false);
|
||||||
|
// startVideoFocusTimer = setTimeout(() => {
|
||||||
|
// console.log('[startVideoPlayer] 🔍 Spotlight.focus called');
|
||||||
|
// Spotlight.focus(modalContainerId);
|
||||||
|
// }, 0);
|
||||||
|
// } else {
|
||||||
|
// console.log('[startVideoPlayer] ⏭️ Spotlight focus skipped - modal:', modal, ', modalContainerId:', !!modalContainerId, ', spotlightDisable:', spotlightDisable);
|
||||||
|
// }
|
||||||
|
|
||||||
|
console.log('[startVideoPlayer] ✅ END');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 중복 재생 방지: 정말 동일한 요청인지 확인
|
// 중복 재생 방지: 정말 동일한 요청인지 확인
|
||||||
@@ -95,24 +149,62 @@ const shouldSkipVideoPlayback = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const startVideoPlayerNew =
|
export const startVideoPlayerNew =
|
||||||
({ modal, modalContainerId, modalClassName, spotlightDisable, useNewPlayer, bannerId, ...rest }) =>
|
({
|
||||||
|
modal,
|
||||||
|
modalContainerId,
|
||||||
|
modalClassName,
|
||||||
|
spotlightDisable,
|
||||||
|
useNewPlayer,
|
||||||
|
bannerId,
|
||||||
|
videoId,
|
||||||
|
showUrl,
|
||||||
|
...rest
|
||||||
|
}) =>
|
||||||
(dispatch, getState) => {
|
(dispatch, getState) => {
|
||||||
|
console.log('[startVideoPlayerNew] *** ✅ START - bannerId:', bannerId, ', videoId:', videoId, ', showUrl:', showUrl, ', modal:', modal);
|
||||||
|
|
||||||
|
// 🔽 [251116] 즉시 로딩 상태 설정
|
||||||
|
const videoIdentifier = videoId || showUrl || bannerId;
|
||||||
|
if (videoIdentifier) {
|
||||||
|
const displayMode = modal ? DISPLAY_STATUS.VISIBLE : DISPLAY_STATUS.FULLSCREEN;
|
||||||
|
console.log('[startVideoPlayerNew] *** 📌 Setting playback loading - identifier:', videoIdentifier, ', displayMode:', displayMode);
|
||||||
|
dispatch(setPlaybackLoading(videoIdentifier, displayMode));
|
||||||
|
} else {
|
||||||
|
console.log('[startVideoPlayerNew] *** ⚠️ No videoIdentifier provided');
|
||||||
|
}
|
||||||
|
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
const topPanel = panels[panels.length - 1];
|
const topPanel = panels[panels.length - 1];
|
||||||
let panelWorkingAction = pushPanel;
|
let panelWorkingAction = pushPanel;
|
||||||
|
|
||||||
// const panelName = useNewPlayer ? panel_names.PLAYER_PANEL_NEW : panel_names.PLAYER_PANEL;
|
// const panelName = useNewPlayer ? panel_names.PLAYER_PANEL_NEW : panel_names.PLAYER_PANEL;
|
||||||
const panelName = panel_names.PLAYER_PANEL;
|
const panelName = panel_names.PLAYER_PANEL;
|
||||||
|
console.log('[startVideoPlayerNew] *** 📊 Panel state - panelsCount:', panels.length, ', topPanelName:', topPanel?.name);
|
||||||
|
|
||||||
if (topPanel && topPanel.name === panelName) {
|
if (topPanel && topPanel.name === panelName) {
|
||||||
panelWorkingAction = updatePanel;
|
panelWorkingAction = updatePanel;
|
||||||
|
console.log('[startVideoPlayerNew] *** 📋 Current PLAYER_PANEL panelInfo:', topPanel.panelInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// playerState 업데이트: 기존 playerState와 새 데이터 병합
|
// 중복 실행 방지: 같은 배너 + 같은 modal 상태/컨테이너 + 같은 URL이면 skip
|
||||||
const currentPlayerState = topPanel?.panelInfo?.playerState || {};
|
const currentPanelInfo = topPanel?.panelInfo || {};
|
||||||
|
const currentPlayerState = currentPanelInfo.playerState || {};
|
||||||
|
const isSameBanner = currentPlayerState.currentBannerId === bannerId;
|
||||||
|
const isSameModalType = currentPanelInfo.modal === modal;
|
||||||
|
const isSameContainer = currentPanelInfo.modalContainerId === modalContainerId;
|
||||||
|
const isSameShowUrl = currentPanelInfo.showUrl === showUrl;
|
||||||
|
const isSameVideoId = currentPanelInfo.videoId === videoId;
|
||||||
|
|
||||||
// ✅ 정확한 비교: 같은 배너 + 같은 modal 상태 + 같은 위치일 때만 skip
|
console.log('[startVideoPlayerNew] *** 🔍 Duplicate check - isSameBanner:', isSameBanner, ', isSameModalType:', isSameModalType, ', isSameContainer:', isSameContainer, ', isSameShowUrl:', isSameShowUrl, ', isSameVideoId:', isSameVideoId);
|
||||||
if (shouldSkipVideoPlayback(topPanel?.panelInfo, modal, modalContainerId, bannerId)) {
|
|
||||||
|
if (isSameBanner && isSameModalType && isSameContainer && isSameShowUrl && isSameVideoId) {
|
||||||
|
console.log('[startVideoPlayerNew] *** ⏭️ SKIPPED - 동일한 요청', {
|
||||||
|
bannerId,
|
||||||
|
modal,
|
||||||
|
modalContainerId,
|
||||||
|
showUrl,
|
||||||
|
videoId,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +212,7 @@ export const startVideoPlayerNew =
|
|||||||
...currentPlayerState,
|
...currentPlayerState,
|
||||||
currentBannerId: bannerId,
|
currentBannerId: bannerId,
|
||||||
};
|
};
|
||||||
|
console.log('[startVideoPlayerNew] *** 🔄 Player state updated - currentBannerId:', bannerId);
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
panelWorkingAction(
|
panelWorkingAction(
|
||||||
@@ -130,23 +223,36 @@ export const startVideoPlayerNew =
|
|||||||
modalContainerId,
|
modalContainerId,
|
||||||
modalClassName,
|
modalClassName,
|
||||||
playerState: newPlayerState,
|
playerState: newPlayerState,
|
||||||
|
videoId, // videoId 추가
|
||||||
|
showUrl, // showUrl 추가
|
||||||
|
bannerId, // bannerId 추가
|
||||||
...rest,
|
...rest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (modal && modalContainerId && !spotlightDisable) {
|
console.log('[startVideoPlayerNew] *** ✨ Panel action dispatched - action:', panelWorkingAction === updatePanel ? 'updatePanel' : 'pushPanel');
|
||||||
Spotlight.setPointerMode(false);
|
|
||||||
startVideoFocusTimer = setTimeout(() => {
|
// [COMMENTED OUT] 비디오 재생 시 강제 포커스 이동 비활성화
|
||||||
Spotlight.focus(modalContainerId);
|
// if (modal && modalContainerId && !spotlightDisable) {
|
||||||
}, 0);
|
// console.log('[startVideoPlayerNew] *** 🎯 Setting Spotlight focus - containerId:', modalContainerId);
|
||||||
}
|
// Spotlight.setPointerMode(false);
|
||||||
|
// startVideoFocusTimer = setTimeout(() => {
|
||||||
|
// console.log('[startVideoPlayerNew] *** 🔍 Spotlight.focus called');
|
||||||
|
// Spotlight.focus(modalContainerId);
|
||||||
|
// }, 0);
|
||||||
|
// } else {
|
||||||
|
// console.log('[startVideoPlayerNew] *** ⏭️ Spotlight focus skipped - modal:', modal, ', modalContainerId:', !!modalContainerId, ', spotlightDisable:', spotlightDisable);
|
||||||
|
// }
|
||||||
|
|
||||||
|
console.log('[startVideoPlayerNew] *** ✅ END');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const finishVideoPreview = () => (dispatch, getState) => {
|
export const finishVideoPreview = () => (dispatch, getState) => {
|
||||||
|
console.log('###-finishVideoPreview');
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
const topPanel = panels[panels.length-1];
|
const topPanel = panels[panels.length - 1];
|
||||||
if (topPanel && topPanel.name === panel_names.PLAYER_PANEL && topPanel.panelInfo.modal) {
|
if (topPanel && topPanel.name === panel_names.PLAYER_PANEL && topPanel.panelInfo.modal) {
|
||||||
if (startVideoFocusTimer) {
|
if (startVideoFocusTimer) {
|
||||||
clearTimeout(startVideoFocusTimer);
|
clearTimeout(startVideoFocusTimer);
|
||||||
@@ -158,7 +264,7 @@ export const finishVideoPreview = () => (dispatch, getState) => {
|
|||||||
|
|
||||||
export const finishModalVideoForce = () => (dispatch, getState) => {
|
export const finishModalVideoForce = () => (dispatch, getState) => {
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
|
console.log('###-finishModalVideoForce');
|
||||||
// modal PlayerPanel이 존재하는지 확인 (스택 어디에 있든)
|
// modal PlayerPanel이 존재하는지 확인 (스택 어디에 있든)
|
||||||
const hasModalPlayerPanel = panels.some(
|
const hasModalPlayerPanel = panels.some(
|
||||||
(panel) => panel.name === panel_names.PLAYER_PANEL && panel.panelInfo?.modal
|
(panel) => panel.name === panel_names.PLAYER_PANEL && panel.panelInfo?.modal
|
||||||
@@ -177,11 +283,9 @@ export const finishModalVideoForce = () => (dispatch, getState) => {
|
|||||||
// 모든 PlayerPanel을 강제 제거 (modal과 fullscreen 모두)
|
// 모든 PlayerPanel을 강제 제거 (modal과 fullscreen 모두)
|
||||||
export const finishAllVideoForce = () => (dispatch, getState) => {
|
export const finishAllVideoForce = () => (dispatch, getState) => {
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
|
console.log('###-finishAllVideoForce');
|
||||||
// 모든 PlayerPanel이 존재하는지 확인 (스택 어디에 있든)
|
// 모든 PlayerPanel이 존재하는지 확인 (스택 어디에 있든)
|
||||||
const hasPlayerPanel = panels.some(
|
const hasPlayerPanel = panels.some((panel) => panel.name === panel_names.PLAYER_PANEL);
|
||||||
(panel) => panel.name === panel_names.PLAYER_PANEL
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasPlayerPanel) {
|
if (hasPlayerPanel) {
|
||||||
if (startVideoFocusTimer) {
|
if (startVideoFocusTimer) {
|
||||||
@@ -196,6 +300,7 @@ export const finishAllVideoForce = () => (dispatch, getState) => {
|
|||||||
// 모달 비디오를 일시정지 (패널은 유지)
|
// 모달 비디오를 일시정지 (패널은 유지)
|
||||||
export const pauseModalVideo = () => (dispatch, getState) => {
|
export const pauseModalVideo = () => (dispatch, getState) => {
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
|
console.log('###-pauseModalVideo');
|
||||||
|
|
||||||
// modal PlayerPanel 찾기
|
// modal PlayerPanel 찾기
|
||||||
const modalPlayerPanel = panels.find(
|
const modalPlayerPanel = panels.find(
|
||||||
@@ -295,7 +400,7 @@ export const resumeFullscreenVideo = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 모달 비디오를 1px로 축소 (배너 정보 저장)
|
// 모달 비디오를 1px로 축소 (배너 정보 저장)
|
||||||
export const shrinkVideoTo1px = () => (dispatch, getState) => {
|
export const hideModalVideo = () => (dispatch, getState) => {
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
|
|
||||||
// modal PlayerPanel 찾기
|
// modal PlayerPanel 찾기
|
||||||
@@ -326,10 +431,10 @@ export const shrinkVideoTo1px = () => (dispatch, getState) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[HomePanel] shrinkVideoTo1px: saving shrinkInfo', {
|
// console.log('[HomePanel] hideModalVideo: saving shrinkInfo', {
|
||||||
shrinkInfo: updatedPlayerState.shrinkInfo,
|
// shrinkInfo: updatedPlayerState.shrinkInfo,
|
||||||
modalStyle: panelInfo.modalStyle,
|
// modalStyle: panelInfo.modalStyle,
|
||||||
});
|
// });
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updatePanel({
|
updatePanel({
|
||||||
@@ -342,43 +447,59 @@ export const shrinkVideoTo1px = () => (dispatch, getState) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('[HomePanel] shrinkVideoTo1px: No modal PlayerPanel found');
|
console.log('[HomePanel] hideModalVideo: No modal PlayerPanel found', {
|
||||||
|
panels: panels.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
modal: p.panelInfo?.modal,
|
||||||
|
shouldShrinkTo1px: p.panelInfo?.shouldShrinkTo1px,
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 축소된 모달 비디오를 원래 크기로 복구
|
// 축소된 모달 비디오를 원래 크기로 복구
|
||||||
export const expandVideoFrom1px = () => (dispatch, getState) => {
|
export const showModalVideo = () => (dispatch, getState) => {
|
||||||
|
console.log('[showModalVideo] *** ✅ START');
|
||||||
const panels = getState().panels.panels;
|
const panels = getState().panels.panels;
|
||||||
|
console.log('[showModalVideo] *** 📊 Total panels count:', panels.length);
|
||||||
|
|
||||||
// 축소된 modal PlayerPanel 찾기
|
// 축소된 modal PlayerPanel 찾기
|
||||||
const shrunkModalPlayerPanel = panels.find(
|
const shrunkModalPlayerPanel = panels.find(
|
||||||
(panel) => panel.name === panel_names.PLAYER_PANEL && panel.panelInfo?.modal && panel.panelInfo?.shouldShrinkTo1px
|
(panel) =>
|
||||||
|
panel.name === panel_names.PLAYER_PANEL &&
|
||||||
|
panel.panelInfo?.modal &&
|
||||||
|
panel.panelInfo?.shouldShrinkTo1px
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('[showModalVideo] *** 🔍 Shrunk modal PlayerPanel found:', !!shrunkModalPlayerPanel);
|
||||||
|
|
||||||
if (shrunkModalPlayerPanel) {
|
if (shrunkModalPlayerPanel) {
|
||||||
const panelInfo = shrunkModalPlayerPanel.panelInfo;
|
const panelInfo = shrunkModalPlayerPanel.panelInfo;
|
||||||
const shrinkInfo = panelInfo.playerState?.shrinkInfo;
|
const shrinkInfo = panelInfo.playerState?.shrinkInfo;
|
||||||
|
|
||||||
console.log('[HomePanel] expandVideoFrom1px: expanding video', {
|
console.log('[showModalVideo] *** 📋 ShrinkInfo available:', !!shrinkInfo);
|
||||||
hasShrinkInfo: !!shrinkInfo,
|
console.log('[showModalVideo] *** 📋 Current panelInfo state:', {
|
||||||
hasModalStyle: !!shrinkInfo?.modalStyle,
|
shouldShrinkTo1px: panelInfo.shouldShrinkTo1px,
|
||||||
hasModalContainerId: !!shrinkInfo?.modalContainerId,
|
modal: panelInfo.modal,
|
||||||
|
modalContainerId: panelInfo.modalContainerId,
|
||||||
|
hasModalStyle: !!panelInfo.modalStyle,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedPanelInfo = {
|
const updatedPanelInfo = {
|
||||||
...panelInfo,
|
...panelInfo,
|
||||||
shouldShrinkTo1px: false, // 축소 플래그 해제
|
shouldShrinkTo1px: false, // 축소 플래그 해제
|
||||||
skipModalStyleRecalculation: true, // ← 복구 과정에서 DOM 재계산 스킵
|
// 저장된 정보로 복구 (하지만 DOM 재계산은 허용)
|
||||||
// 저장된 정보로 복구
|
|
||||||
...(shrinkInfo && {
|
...(shrinkInfo && {
|
||||||
modalContainerId: shrinkInfo.modalContainerId,
|
modalContainerId: shrinkInfo.modalContainerId,
|
||||||
modalClassName: shrinkInfo.modalClassName,
|
modalClassName: shrinkInfo.modalClassName,
|
||||||
modalStyle: shrinkInfo.modalStyle,
|
modalStyle: shrinkInfo.modalStyle,
|
||||||
modalScale: shrinkInfo.modalScale,
|
modalScale: shrinkInfo.modalScale,
|
||||||
}),
|
}),
|
||||||
|
skipModalStyleRecalculation: false, // 위치 변경 시 DOM 기준으로 다시 계산하도록 허용
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[HomePanel] expandVideoFrom1px: updated panelInfo shouldShrinkTo1px=false, modalStyle restored, skipModalStyleRecalculation=true');
|
console.log('[showModalVideo] *** 🔄 Updated panelInfo - shouldShrinkTo1px:', updatedPanelInfo.shouldShrinkTo1px);
|
||||||
|
console.log('[showModalVideo] *** 📍 Restored modalStyle:', updatedPanelInfo.modalStyle);
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updatePanel({
|
updatePanel({
|
||||||
@@ -386,8 +507,134 @@ export const expandVideoFrom1px = () => (dispatch, getState) => {
|
|||||||
panelInfo: updatedPanelInfo,
|
panelInfo: updatedPanelInfo,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
console.log('[showModalVideo] *** ✨ updatePanel dispatched');
|
||||||
} else {
|
} else {
|
||||||
console.log('[HomePanel] expandVideoFrom1px: No shrunk modal PlayerPanel found');
|
console.log('[showModalVideo] *** ⚠️ No shrunk modal PlayerPanel found', {
|
||||||
|
panels: panels.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
modal: p.panelInfo?.modal,
|
||||||
|
shouldShrinkTo1px: p.panelInfo?.shouldShrinkTo1px,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[showModalVideo] *** ✅ END');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔽 패널은 유지하고 비디오만 중지하는 함수들
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 패널을 닫지 않고(popPanel 하지 않고) 비디오만 중지합니다.
|
||||||
|
* 모달 비디오의 재생을 중지하고 숨김 상태로 만듭니다.
|
||||||
|
*/
|
||||||
|
export const stopModalVideoWithoutClosingPanel = () => (dispatch, getState) => {
|
||||||
|
const panels = getState().panels.panels;
|
||||||
|
|
||||||
|
// modal PlayerPanel 찾기
|
||||||
|
const modalPlayerPanel = panels.find(
|
||||||
|
(panel) => panel.name === panel_names.PLAYER_PANEL && panel.panelInfo?.modal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (modalPlayerPanel) {
|
||||||
|
console.log('[stopModalVideoWithoutClosingPanel] Stopping modal video playback');
|
||||||
|
|
||||||
|
// 타이머 정리
|
||||||
|
if (startVideoFocusTimer) {
|
||||||
|
clearTimeout(startVideoFocusTimer);
|
||||||
|
startVideoFocusTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패널은 유지하되, 비디오 중지 상태로 업데이트
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.PLAYER_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
...modalPlayerPanel.panelInfo,
|
||||||
|
shouldStop: true, // 비디오 중지 플래그
|
||||||
|
isPaused: true, // 일시정지 상태
|
||||||
|
isHidden: true, // 화면에서 숨김
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redux 상태도 중지로 업데이트
|
||||||
|
dispatch(setVideoStopped());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 패널을 닫지 않고 전체화면 비디오만 중지합니다.
|
||||||
|
*/
|
||||||
|
export const stopFullscreenVideoWithoutClosingPanel = () => (dispatch, getState) => {
|
||||||
|
const panels = getState().panels.panels;
|
||||||
|
|
||||||
|
// 전체화면 PlayerPanel 찾기
|
||||||
|
const fullscreenPlayerPanel = panels.find(
|
||||||
|
(panel) => panel.name === panel_names.PLAYER_PANEL && !panel.panelInfo?.modal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullscreenPlayerPanel) {
|
||||||
|
console.log('[stopFullscreenVideoWithoutClosingPanel] Stopping fullscreen video playback');
|
||||||
|
|
||||||
|
// 타이머 정리
|
||||||
|
if (startVideoFocusTimer) {
|
||||||
|
clearTimeout(startVideoFocusTimer);
|
||||||
|
startVideoFocusTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패널은 유지하되, 비디오 중지 상태로 업데이트
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.PLAYER_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
...fullscreenPlayerPanel.panelInfo,
|
||||||
|
shouldStop: true, // 비디오 중지 플래그
|
||||||
|
isPaused: true,
|
||||||
|
isHidden: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redux 상태도 중지로 업데이트
|
||||||
|
dispatch(setVideoStopped());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 비디오(모달+전체화면)를 패널 닫지 않고 중지합니다.
|
||||||
|
*/
|
||||||
|
export const stopAllVideosWithoutClosingPanel = () => (dispatch, getState) => {
|
||||||
|
const panels = getState().panels.panels;
|
||||||
|
|
||||||
|
// 모든 PlayerPanel 찾기
|
||||||
|
const playerPanels = panels.filter((panel) => panel.name === panel_names.PLAYER_PANEL);
|
||||||
|
|
||||||
|
if (playerPanels.length > 0) {
|
||||||
|
console.log('[stopAllVideosWithoutClosingPanel] Stopping all video playback');
|
||||||
|
|
||||||
|
// 타이머 정리
|
||||||
|
if (startVideoFocusTimer) {
|
||||||
|
clearTimeout(startVideoFocusTimer);
|
||||||
|
startVideoFocusTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 PlayerPanel을 중지 상태로 업데이트
|
||||||
|
playerPanels.forEach((playerPanel) => {
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.PLAYER_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
...playerPanel.panelInfo,
|
||||||
|
shouldStop: true,
|
||||||
|
isPaused: true,
|
||||||
|
isHidden: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redux 상태도 중지로 업데이트
|
||||||
|
dispatch(setVideoStopped());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -581,7 +828,6 @@ export const goToFullScreen = () => (dispatch, getState) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 영구재생 비디오를 일시정지 상태로 만듭니다. (내부 사용)
|
* 영구재생 비디오를 일시정지 상태로 만듭니다. (내부 사용)
|
||||||
*/
|
*/
|
||||||
@@ -617,3 +863,152 @@ export const returnToPreview = () => (dispatch, getState) => {
|
|||||||
dispatch(finishVideoPreview());
|
dispatch(finishVideoPreview());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* 🔽 [251116] 새로운 비디오 상태 관리 시스템 - 액션 함수들 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 로딩 시작 상태로 설정
|
||||||
|
* @param {string} videoId - 비디오 ID
|
||||||
|
* @param {string} displayMode - 화면 모드 ('visible', 'fullscreen', 'minimized')
|
||||||
|
*/
|
||||||
|
export const setPlaybackLoading = (videoId, displayMode = DISPLAY_STATUS.VISIBLE) => ({
|
||||||
|
type: types.SET_VIDEO_LOADING,
|
||||||
|
payload: {
|
||||||
|
playback: PLAYBACK_STATUS.LOADING,
|
||||||
|
display: displayMode,
|
||||||
|
videoId,
|
||||||
|
loadingProgress: 0,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 로딩 성공 상태로 설정
|
||||||
|
* @param {string} videoId - 비디오 ID
|
||||||
|
* @param {string} displayMode - 화면 모드
|
||||||
|
*/
|
||||||
|
export const setPlaybackSuccess = (videoId, displayMode = DISPLAY_STATUS.VISIBLE) => ({
|
||||||
|
type: types.SET_PLAYBACK_SUCCESS,
|
||||||
|
payload: {
|
||||||
|
playback: PLAYBACK_STATUS.LOAD_SUCCESS,
|
||||||
|
display: displayMode,
|
||||||
|
videoId,
|
||||||
|
loadingProgress: 100,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 로딩 에러 상태로 설정
|
||||||
|
* @param {string} videoId - 비디오 ID
|
||||||
|
* @param {object} error - 에러 정보
|
||||||
|
*/
|
||||||
|
export const setPlaybackError = (videoId, error) => ({
|
||||||
|
type: types.SET_VIDEO_ERROR,
|
||||||
|
payload: {
|
||||||
|
playback: PLAYBACK_STATUS.LOAD_ERROR,
|
||||||
|
display: DISPLAY_STATUS.VISIBLE,
|
||||||
|
videoId,
|
||||||
|
loadingProgress: 0,
|
||||||
|
loadingError: error,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 재생 상태로 설정
|
||||||
|
* @param {string} videoId - 비디오 ID
|
||||||
|
* @param {string} displayMode - 화면 모드
|
||||||
|
*/
|
||||||
|
export const setPlaybackPlaying = (videoId, displayMode = DISPLAY_STATUS.FULLSCREEN) => ({
|
||||||
|
type: types.SET_VIDEO_PLAYING,
|
||||||
|
payload: {
|
||||||
|
playback: PLAYBACK_STATUS.PLAYING,
|
||||||
|
display: displayMode,
|
||||||
|
videoId,
|
||||||
|
loadingProgress: 100,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 정지 상태로 설정
|
||||||
|
*/
|
||||||
|
export const setVideoStopped = () => ({
|
||||||
|
type: types.SET_VIDEO_STOPPED,
|
||||||
|
payload: {
|
||||||
|
playback: PLAYBACK_STATUS.NOT_PLAYING,
|
||||||
|
display: DISPLAY_STATUS.HIDDEN,
|
||||||
|
videoId: null,
|
||||||
|
loadingProgress: 0,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 버퍼링 상태로 설정
|
||||||
|
* @param {string} videoId - 비디오 ID
|
||||||
|
*/
|
||||||
|
export const setPlaybackBuffering = (videoId) => ({
|
||||||
|
type: types.SET_PLAYBACK_BUFFERING,
|
||||||
|
payload: {
|
||||||
|
playback: PLAYBACK_STATUS.BUFFERING,
|
||||||
|
videoId,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최소화된 상태로 비디오 재생
|
||||||
|
* @param {string} videoId - 비디오 ID
|
||||||
|
*/
|
||||||
|
export const setVideoMinimizedPlaying = (videoId) => ({
|
||||||
|
type: types.SET_VIDEO_MINIMIZED_PLAYING,
|
||||||
|
payload: {
|
||||||
|
playback: PLAYBACK_STATUS.PLAYING,
|
||||||
|
display: DISPLAY_STATUS.MINIMIZED,
|
||||||
|
videoId,
|
||||||
|
loadingProgress: 100,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 상태만 변경하는 액션들
|
||||||
|
*/
|
||||||
|
export const setDisplayHidden = () => ({
|
||||||
|
type: types.SET_DISPLAY_HIDDEN,
|
||||||
|
payload: {
|
||||||
|
display: DISPLAY_STATUS.HIDDEN,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setDisplayVisible = () => ({
|
||||||
|
type: types.SET_DISPLAY_VISIBLE,
|
||||||
|
payload: {
|
||||||
|
display: DISPLAY_STATUS.VISIBLE,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setDisplayMinimized = () => ({
|
||||||
|
type: types.SET_DISPLAY_MINIMIZED,
|
||||||
|
payload: {
|
||||||
|
display: DISPLAY_STATUS.MINIMIZED,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setDisplayFullscreen = () => ({
|
||||||
|
type: types.SET_DISPLAY_FULLSCREEN,
|
||||||
|
payload: {
|
||||||
|
display: DISPLAY_STATUS.FULLSCREEN,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { reduce, set, get } from '../utils/fp';
|
|||||||
// CustomerImages용 리뷰 이미지 import
|
// CustomerImages용 리뷰 이미지 import
|
||||||
import reviewSampleImage from '../../assets/images/image-review-sample-1.png';
|
import reviewSampleImage from '../../assets/images/image-review-sample-1.png';
|
||||||
|
|
||||||
|
// DEBUG_MODE - true인 경우에만 로그 출력
|
||||||
|
const DEBUG_MODE = false;
|
||||||
|
|
||||||
// Best Seller 상품 목록 조회 IF-LGSP-303
|
// Best Seller 상품 목록 조회 IF-LGSP-303
|
||||||
// FP helpers
|
// FP helpers
|
||||||
const pickParams = (keys) => (src) =>
|
const pickParams = (keys) => (src) =>
|
||||||
@@ -36,6 +39,7 @@ const createRequestThunk =
|
|||||||
const body = data(props);
|
const body = data(props);
|
||||||
|
|
||||||
// 📡 REQUEST 로그: API 호출 전 (tag별로 다르게 표시)
|
// 📡 REQUEST 로그: API 호출 전 (tag별로 다르게 표시)
|
||||||
|
if (DEBUG_MODE)
|
||||||
console.log(
|
console.log(
|
||||||
`%c[${tag}] 📤 REQUEST - ${method.toUpperCase()} ${url}`,
|
`%c[${tag}] 📤 REQUEST - ${method.toUpperCase()} ${url}`,
|
||||||
'background: #4CAF50; color: white; font-weight: bold; padding: 3px;',
|
'background: #4CAF50; color: white; font-weight: bold; padding: 3px;',
|
||||||
@@ -50,6 +54,7 @@ const createRequestThunk =
|
|||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
// ✅ RESPONSE 로그: API 호출 성공 (tag별로 다르게 표시)
|
// ✅ RESPONSE 로그: API 호출 성공 (tag별로 다르게 표시)
|
||||||
|
if (DEBUG_MODE)
|
||||||
console.log(
|
console.log(
|
||||||
`%c[${tag}] ✅ RESPONSE SUCCESS - ${method.toUpperCase()} ${url}`,
|
`%c[${tag}] ✅ RESPONSE SUCCESS - ${method.toUpperCase()} ${url}`,
|
||||||
'background: #2196F3; color: white; font-weight: bold; padding: 3px;',
|
'background: #2196F3; color: white; font-weight: bold; padding: 3px;',
|
||||||
@@ -71,6 +76,7 @@ const createRequestThunk =
|
|||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
// ❌ ERROR 로그: API 호출 실패 (tag별로 다르게 표시)
|
// ❌ ERROR 로그: API 호출 실패 (tag별로 다르게 표시)
|
||||||
|
if (DEBUG_MODE)
|
||||||
console.error(
|
console.error(
|
||||||
`%c[${tag}] ❌ RESPONSE ERROR - ${method.toUpperCase()} ${url}`,
|
`%c[${tag}] ❌ RESPONSE ERROR - ${method.toUpperCase()} ${url}`,
|
||||||
'background: #F44336; color: white; font-weight: bold; padding: 3px;',
|
'background: #F44336; color: white; font-weight: bold; padding: 3px;',
|
||||||
@@ -100,14 +106,14 @@ const createGetThunk = ({ url, type, params = () => ({}), tag }) =>
|
|||||||
|
|
||||||
export const getBestSeller = (callback) => (dispatch, getState) => {
|
export const getBestSeller = (callback) => (dispatch, getState) => {
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log('getBestSeller onSuccess', response.data);
|
if (DEBUG_MODE) console.log('getBestSeller onSuccess', response.data);
|
||||||
dispatch({ type: types.GET_BEST_SELLER, payload: get('data.data', response) });
|
dispatch({ type: types.GET_BEST_SELLER, payload: get('data.data', response) });
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
callback && callback();
|
callback && callback();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error('getBestSeller onFail', error);
|
if (DEBUG_MODE) console.error('getBestSeller onFail', error);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
callback && callback();
|
callback && callback();
|
||||||
};
|
};
|
||||||
@@ -169,15 +175,15 @@ export const getProductOption = createGetThunk({
|
|||||||
// IF-LGSP-101용 API 응답에서 reviewList + reviewDetail 추출
|
// IF-LGSP-101용 API 응답에서 reviewList + reviewDetail 추출
|
||||||
const extractReviewListApiData = (apiResponse) => {
|
const extractReviewListApiData = (apiResponse) => {
|
||||||
try {
|
try {
|
||||||
console.log('[UserReviewList] 📥 extractReviewListApiData 호출 - 원본 응답:', apiResponse);
|
// console.log('[UserReviewList] 📥 extractReviewListApiData 호출 - 원본 응답:', apiResponse);
|
||||||
|
|
||||||
// ⭐ 핵심: retCode가 0인지 먼저 확인 (HTTP 200이어도 API 에러일 수 있음)
|
// ⭐ 핵심: retCode가 0인지 먼저 확인 (HTTP 200이어도 API 에러일 수 있음)
|
||||||
if (apiResponse && apiResponse.retCode !== 0) {
|
if (apiResponse && apiResponse.retCode !== 0) {
|
||||||
console.error('[UserReviewList] ❌ API 에러 - retCode !== 0:', {
|
// console.error('[UserReviewList] ❌ API 에러 - retCode !== 0:', {
|
||||||
retCode: apiResponse.retCode,
|
// retCode: apiResponse.retCode,
|
||||||
retMsg: apiResponse.retMsg,
|
// retMsg: apiResponse.retMsg,
|
||||||
fullResponse: apiResponse
|
// fullResponse: apiResponse
|
||||||
});
|
// });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,59 +199,67 @@ const extractReviewListApiData = (apiResponse) => {
|
|||||||
const reviewDetail = apiData.reviewDetail || {};
|
const reviewDetail = apiData.reviewDetail || {};
|
||||||
|
|
||||||
// reviewDetail.reviewList에 실제 데이터가 있으면 사용
|
// reviewDetail.reviewList에 실제 데이터가 있으면 사용
|
||||||
if (reviewDetail.reviewList && Array.isArray(reviewDetail.reviewList) && reviewList.length === 0) {
|
if (
|
||||||
|
reviewDetail.reviewList &&
|
||||||
|
Array.isArray(reviewDetail.reviewList) &&
|
||||||
|
reviewList.length === 0
|
||||||
|
) {
|
||||||
reviewList = reviewDetail.reviewList;
|
reviewList = reviewDetail.reviewList;
|
||||||
console.log('[UserReviewList] 🔄 reviewDetail.reviewList에서 데이터 추출됨');
|
// console.log('[UserReviewList] 🔄 reviewDetail.reviewList에서 데이터 추출됨');
|
||||||
}
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
reviewList: reviewList,
|
reviewList: reviewList,
|
||||||
reviewDetail: reviewDetail
|
reviewDetail: reviewDetail,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[UserReviewList] 📊 apiResponse.data 경로에서 추출:', {
|
// console.log('[UserReviewList] 📊 apiResponse.data 경로에서 추출:', {
|
||||||
reviewListLength: data.reviewList.length,
|
// reviewListLength: data.reviewList.length,
|
||||||
reviewDetailKeys: Object.keys(data.reviewDetail),
|
// reviewDetailKeys: Object.keys(data.reviewDetail),
|
||||||
reviewDetail: data.reviewDetail,
|
// reviewDetail: data.reviewDetail,
|
||||||
reviewListSample: data.reviewList.length > 0 ? data.reviewList[0] : 'empty'
|
// reviewListSample: data.reviewList.length > 0 ? data.reviewList[0] : 'empty'
|
||||||
});
|
// });
|
||||||
} else if (apiResponse) {
|
} else if (apiResponse) {
|
||||||
// 직접 경로에서 추출
|
// 직접 경로에서 추출
|
||||||
let reviewList = apiResponse.reviewList || [];
|
let reviewList = apiResponse.reviewList || [];
|
||||||
const reviewDetail = apiResponse.reviewDetail || {};
|
const reviewDetail = apiResponse.reviewDetail || {};
|
||||||
|
|
||||||
// reviewDetail.reviewList에 실제 데이터가 있으면 사용
|
// reviewDetail.reviewList에 실제 데이터가 있으면 사용
|
||||||
if (reviewDetail.reviewList && Array.isArray(reviewDetail.reviewList) && reviewList.length === 0) {
|
if (
|
||||||
|
reviewDetail.reviewList &&
|
||||||
|
Array.isArray(reviewDetail.reviewList) &&
|
||||||
|
reviewList.length === 0
|
||||||
|
) {
|
||||||
reviewList = reviewDetail.reviewList;
|
reviewList = reviewDetail.reviewList;
|
||||||
console.log('[UserReviewList] 🔄 reviewDetail.reviewList에서 데이터 추출됨');
|
// console.log('[UserReviewList] 🔄 reviewDetail.reviewList에서 데이터 추출됨');
|
||||||
}
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
reviewList: reviewList,
|
reviewList: reviewList,
|
||||||
reviewDetail: reviewDetail
|
reviewDetail: reviewDetail,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[UserReviewList] 📊 직접 경로에서 추출:', {
|
// console.log('[UserReviewList] 📊 직접 경로에서 추출:', {
|
||||||
reviewListLength: data.reviewList.length,
|
// reviewListLength: data.reviewList.length,
|
||||||
reviewDetailKeys: Object.keys(data.reviewDetail),
|
// reviewDetailKeys: Object.keys(data.reviewDetail),
|
||||||
reviewDetail: data.reviewDetail,
|
// reviewDetail: data.reviewDetail,
|
||||||
reviewListSample: data.reviewList.length > 0 ? data.reviewList[0] : 'empty'
|
// reviewListSample: data.reviewList.length > 0 ? data.reviewList[0] : 'empty'
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || (!data.reviewList && !data.reviewDetail)) {
|
if (!data || (!data.reviewList && !data.reviewDetail)) {
|
||||||
console.warn('[UserReviewList] ⚠️ reviewList와 reviewDetail 모두 없음:', apiResponse);
|
// console.warn('[UserReviewList] ⚠️ reviewList와 reviewDetail 모두 없음:', apiResponse);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[UserReviewList] ✅ 추출 완료:', {
|
// console.log('[UserReviewList] ✅ 추출 완료:', {
|
||||||
reviewListLength: data.reviewList.length,
|
// reviewListLength: data.reviewList.length,
|
||||||
reviewDetail: data.reviewDetail
|
// reviewDetail: data.reviewDetail
|
||||||
});
|
// });
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[UserReviewList] ❌ extractReviewListApiData 에러:', error);
|
// console.error('[UserReviewList] ❌ extractReviewListApiData 에러:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -367,7 +381,12 @@ export const getVideoIndicatorFocus = (focused) => (dispatch) => {
|
|||||||
// 순차 페이징으로 모든 리뷰 데이터를 수집하는 함수 (TV 앱 성능 최적화)
|
// 순차 페이징으로 모든 리뷰 데이터를 수집하는 함수 (TV 앱 성능 최적화)
|
||||||
// Option 2: 순차 페칭 (메모리 효율, 서버 부하 감소)
|
// Option 2: 순차 페칭 (메모리 효율, 서버 부하 감소)
|
||||||
// ⭐ 재시도 로직 포함: 타임아웃/미응답 케이스 대비
|
// ⭐ 재시도 로직 포함: 타임아웃/미응답 케이스 대비
|
||||||
const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestParams, retryCount = 0) => {
|
const fetchAllReviewsWithSequentialPaging = async (
|
||||||
|
dispatch,
|
||||||
|
getState,
|
||||||
|
requestParams,
|
||||||
|
retryCount = 0
|
||||||
|
) => {
|
||||||
const MAX_RETRIES = 2; // 최대 2회 재시도 (총 3회 시도)
|
const MAX_RETRIES = 2; // 최대 2회 재시도 (총 3회 시도)
|
||||||
const {
|
const {
|
||||||
prdtId,
|
prdtId,
|
||||||
@@ -377,15 +396,15 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
pageSize = 100, // 최대값으로 설정하여 페이징 횟수 최소화
|
pageSize = 100, // 최대값으로 설정하여 페이징 횟수 최소화
|
||||||
} = requestParams;
|
} = requestParams;
|
||||||
|
|
||||||
console.log('[UserReviewList] 🚀 순차 페이징 시작:', {
|
// console.log('[UserReviewList] 🚀 순차 페이징 시작:', {
|
||||||
prdtId,
|
// prdtId,
|
||||||
patnrId,
|
// patnrId,
|
||||||
filterTpCd,
|
// filterTpCd,
|
||||||
filterTpVal,
|
// filterTpVal,
|
||||||
pageSize,
|
// pageSize,
|
||||||
retryCount,
|
// retryCount,
|
||||||
isRetry: retryCount > 0
|
// isRetry: retryCount > 0
|
||||||
});
|
// });
|
||||||
|
|
||||||
let allReviews = [];
|
let allReviews = [];
|
||||||
let currentReviewDetail = null;
|
let currentReviewDetail = null;
|
||||||
@@ -401,13 +420,13 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
filterTpCd,
|
filterTpCd,
|
||||||
pageSize,
|
pageSize,
|
||||||
pageNo,
|
pageNo,
|
||||||
cntryCd: 'US'
|
cntryCd: 'US',
|
||||||
};
|
};
|
||||||
|
|
||||||
// filterTpCd가 'ALL'이 아니면 filterTpVal 추가
|
// filterTpCd가 'ALL'이 아니면 filterTpVal 추가
|
||||||
if (filterTpCd !== 'ALL') {
|
if (filterTpCd !== 'ALL') {
|
||||||
if (!filterTpVal) {
|
if (!filterTpVal) {
|
||||||
console.warn('[UserReviewList] ⚠️ filterTpCd가 ALL이 아니면 filterTpVal은 필수입니다');
|
// console.warn('[UserReviewList] ⚠️ filterTpCd가 ALL이 아니면 filterTpVal은 필수입니다');
|
||||||
}
|
}
|
||||||
params.filterTpVal = filterTpVal;
|
params.filterTpVal = filterTpVal;
|
||||||
}
|
}
|
||||||
@@ -416,13 +435,13 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
// ⭐ 타임아웃 추가: TAxios의 콜백이 호출되지 않는 경우를 대비 (모든 오류 상황 처리)
|
// ⭐ 타임아웃 추가: TAxios의 콜백이 호출되지 않는 경우를 대비 (모든 오류 상황 처리)
|
||||||
const REQUEST_TIMEOUT = 5000; // 5초 타임아웃 (재인증, 팝업 등 오류 상황 처리 포함)
|
const REQUEST_TIMEOUT = 5000; // 5초 타임아웃 (재인증, 팝업 등 오류 상황 처리 포함)
|
||||||
|
|
||||||
console.log(`[UserReviewList] 🔄 API 요청 시작 (page ${pageNo}):`, {
|
// console.log(`[UserReviewList] 🔄 API 요청 시작 (page ${pageNo}):`, {
|
||||||
prdtId,
|
// prdtId,
|
||||||
patnrId,
|
// patnrId,
|
||||||
filterTpCd,
|
// filterTpCd,
|
||||||
pageSize,
|
// pageSize,
|
||||||
pageNo
|
// pageNo
|
||||||
});
|
// });
|
||||||
|
|
||||||
const response = await Promise.race([
|
const response = await Promise.race([
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
@@ -430,80 +449,89 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
|
|
||||||
const onSuccess = (res) => {
|
const onSuccess = (res) => {
|
||||||
if (callbackCalled) {
|
if (callbackCalled) {
|
||||||
console.warn(`[UserReviewList] ⚠️ onSuccess 중복 호출 (page ${pageNo})`);
|
// console.warn(`[UserReviewList] ⚠️ onSuccess 중복 호출 (page ${pageNo})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
callbackCalled = true;
|
callbackCalled = true;
|
||||||
|
|
||||||
console.log(`[UserReviewList] ✅ API 응답 수신 (page ${pageNo}):`, {
|
// console.log(`[UserReviewList] ✅ API 응답 수신 (page ${pageNo}):`, {
|
||||||
status: res?.status,
|
// status: res?.status,
|
||||||
statusText: res?.statusText,
|
// statusText: res?.statusText,
|
||||||
retCode: res?.data?.retCode,
|
// retCode: res?.data?.retCode,
|
||||||
dataExists: !!res?.data,
|
// dataExists: !!res?.data,
|
||||||
reviewDetailExists: !!res?.data?.data?.reviewDetail
|
// reviewDetailExists: !!res?.data?.data?.reviewDetail
|
||||||
});
|
// });
|
||||||
resolve(res);
|
resolve(res);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (err) => {
|
const onFail = (err) => {
|
||||||
if (callbackCalled) {
|
if (callbackCalled) {
|
||||||
console.warn(`[UserReviewList] ⚠️ onFail 중복 호출 (page ${pageNo})`);
|
// console.warn(`[UserReviewList] ⚠️ onFail 중복 호출 (page ${pageNo})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
callbackCalled = true;
|
callbackCalled = true;
|
||||||
|
|
||||||
console.error(`[UserReviewList] ❌ API 콜백 에러 발생 (page ${pageNo}):`, {
|
// console.error(`[UserReviewList] ❌ API 콜백 에러 발생 (page ${pageNo}):`, {
|
||||||
errorMessage: err?.message,
|
// errorMessage: err?.message,
|
||||||
errorStatus: err?.response?.status,
|
// errorStatus: err?.response?.status,
|
||||||
errorStatusText: err?.response?.statusText,
|
// errorStatusText: err?.response?.statusText,
|
||||||
errorRetCode: err?.data?.retCode,
|
// errorRetCode: err?.data?.retCode,
|
||||||
errorRetMsg: err?.data?.retMsg,
|
// errorRetMsg: err?.data?.retMsg,
|
||||||
errorType: typeof err
|
// errorType: typeof err
|
||||||
});
|
// });
|
||||||
reject(err);
|
reject(err);
|
||||||
};
|
};
|
||||||
|
|
||||||
// API 호출
|
// API 호출
|
||||||
console.log(`[UserReviewList] 📡 TAxios 호출 (page ${pageNo})`);
|
// console.log(`[UserReviewList] 📡 TAxios 호출 (page ${pageNo})`);
|
||||||
TAxios(dispatch, getState, 'get', URLS.GET_USER_REVIEW_LIST, params, {}, onSuccess, onFail);
|
TAxios(
|
||||||
|
dispatch,
|
||||||
|
getState,
|
||||||
|
'get',
|
||||||
|
URLS.GET_USER_REVIEW_LIST,
|
||||||
|
params,
|
||||||
|
{},
|
||||||
|
onSuccess,
|
||||||
|
onFail
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
// 타임아웃 Promise (onFail이 호출되지 않은 경우에 대비)
|
// 타임아웃 Promise (onFail이 호출되지 않은 경우에 대비)
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const timeoutError = new Error(`API request timeout without callback (page ${pageNo})`);
|
const timeoutError = new Error(`API request timeout without callback (page ${pageNo})`);
|
||||||
console.error(`[UserReviewList] ⏱️ API 응답 타임아웃 (page ${pageNo}):`, {
|
// console.error(`[UserReviewList] ⏱️ API 응답 타임아웃 (page ${pageNo}):`, {
|
||||||
timeout: REQUEST_TIMEOUT,
|
// timeout: REQUEST_TIMEOUT,
|
||||||
prdtId,
|
// prdtId,
|
||||||
patnrId,
|
// patnrId,
|
||||||
pageNo,
|
// pageNo,
|
||||||
reason: '5초 이내 onSuccess/onFail 콜백이 호출되지 않음'
|
// reason: '5초 이내 onSuccess/onFail 콜백이 호출되지 않음'
|
||||||
});
|
// });
|
||||||
reject(timeoutError);
|
reject(timeoutError);
|
||||||
}, REQUEST_TIMEOUT)
|
}, REQUEST_TIMEOUT)
|
||||||
)
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ⭐ 핵심: HTTP 200이어도 response.data.retCode를 반드시 확인해야 함
|
// ⭐ 핵심: HTTP 200이어도 response.data.retCode를 반드시 확인해야 함
|
||||||
const retCode = response?.data?.retCode;
|
const retCode = response?.data?.retCode;
|
||||||
|
|
||||||
console.log(`[UserReviewList] 📄 페이지 ${pageNo} 응답 상태 확인:`, {
|
// console.log(`[UserReviewList] 📄 페이지 ${pageNo} 응답 상태 확인:`, {
|
||||||
pageNo,
|
// pageNo,
|
||||||
httpStatus: response?.status,
|
// httpStatus: response?.status,
|
||||||
retCode: retCode,
|
// retCode: retCode,
|
||||||
retMsg: response?.data?.retMsg,
|
// retMsg: response?.data?.retMsg,
|
||||||
reviewListLength: response?.data?.data?.reviewDetail?.reviewList?.length || 0,
|
// reviewListLength: response?.data?.data?.reviewDetail?.reviewList?.length || 0,
|
||||||
totRvwCnt: response?.data?.data?.reviewDetail?.totRvwCnt
|
// totRvwCnt: response?.data?.data?.reviewDetail?.totRvwCnt
|
||||||
});
|
// });
|
||||||
|
|
||||||
// retCode가 0이 아니면 API 에러 (HTTP 200이어도 실제 데이터 없을 수 있음)
|
// retCode가 0이 아니면 API 에러 (HTTP 200이어도 실제 데이터 없을 수 있음)
|
||||||
if (retCode !== 0) {
|
if (retCode !== 0) {
|
||||||
console.error(`[UserReviewList] ❌ API 에러 - retCode !== 0 (page ${pageNo}):`, {
|
// console.error(`[UserReviewList] ❌ API 에러 - retCode !== 0 (page ${pageNo}):`, {
|
||||||
retCode,
|
// retCode,
|
||||||
retMsg: response?.data?.retMsg,
|
// retMsg: response?.data?.retMsg,
|
||||||
pageNo,
|
// pageNo,
|
||||||
prdtId,
|
// prdtId,
|
||||||
totalCollected: allReviews.length
|
// totalCollected: allReviews.length
|
||||||
});
|
// });
|
||||||
throw new Error(`API Error: retCode=${retCode}, message=${response?.data?.retMsg}`);
|
throw new Error(`API Error: retCode=${retCode}, message=${response?.data?.retMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,7 +539,7 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
const reviewData = extractReviewListApiData(response.data);
|
const reviewData = extractReviewListApiData(response.data);
|
||||||
|
|
||||||
if (!reviewData || !reviewData.reviewList) {
|
if (!reviewData || !reviewData.reviewList) {
|
||||||
console.warn('[UserReviewList] ⚠️ 리뷰 데이터 추출 실패, 페이징 종료');
|
// console.warn('[UserReviewList] ⚠️ 리뷰 데이터 추출 실패, 페이징 종료');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,12 +551,12 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
// 5. 현재 페이지의 리뷰들을 전체 리스트에 추가
|
// 5. 현재 페이지의 리뷰들을 전체 리스트에 추가
|
||||||
allReviews = allReviews.concat(reviewData.reviewList);
|
allReviews = allReviews.concat(reviewData.reviewList);
|
||||||
|
|
||||||
console.log(`[UserReviewList] ✅ 페이지 ${pageNo} 수집 완료:`, {
|
// console.log(`[UserReviewList] ✅ 페이지 ${pageNo} 수집 완료:`, {
|
||||||
pageNo,
|
// pageNo,
|
||||||
currentPageCount: reviewData.reviewList.length,
|
// currentPageCount: reviewData.reviewList.length,
|
||||||
totalCollected: allReviews.length,
|
// totalCollected: allReviews.length,
|
||||||
totRvwCnt: currentReviewDetail?.totRvwCnt
|
// totRvwCnt: currentReviewDetail?.totRvwCnt
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 6. 페이징 종료 조건 확인
|
// 6. 페이징 종료 조건 확인
|
||||||
// rvwListCnt < pageSize이면 마지막 페이지
|
// rvwListCnt < pageSize이면 마지막 페이지
|
||||||
@@ -538,24 +566,24 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
|
|
||||||
if (receivedCount < pageSize || allReviews.length >= totalReviews) {
|
if (receivedCount < pageSize || allReviews.length >= totalReviews) {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
console.log('[UserReviewList] 📊 페이징 종료:', {
|
// console.log('[UserReviewList] 📊 페이징 종료:', {
|
||||||
reason: receivedCount < pageSize ? '받은 개수 < pageSize' : '수집된 개수 >= 총 개수',
|
// reason: receivedCount < pageSize ? '받은 개수 < pageSize' : '수집된 개수 >= 총 개수',
|
||||||
receivedCount,
|
// receivedCount,
|
||||||
pageSize,
|
// pageSize,
|
||||||
totalCollected: allReviews.length,
|
// totalCollected: allReviews.length,
|
||||||
totalReviews
|
// totalReviews
|
||||||
});
|
// });
|
||||||
} else {
|
} else {
|
||||||
pageNo++;
|
pageNo++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 모든 리뷰 수집 완료, Redux에 디스패치
|
// 7. 모든 리뷰 수집 완료, Redux에 디스패치
|
||||||
console.log('[UserReviewList] 🎉 모든 리뷰 수집 완료:', {
|
// console.log('[UserReviewList] 🎉 모든 리뷰 수집 완료:', {
|
||||||
totalCollected: allReviews.length,
|
// totalCollected: allReviews.length,
|
||||||
totRvwCnt: currentReviewDetail?.totRvwCnt,
|
// totRvwCnt: currentReviewDetail?.totRvwCnt,
|
||||||
pages: pageNo - 1
|
// pages: pageNo - 1
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Redux 디스패치를 위한 최종 데이터 구성
|
// Redux 디스패치를 위한 최종 데이터 구성
|
||||||
const isAllFilter = filterTpCd === 'ALL';
|
const isAllFilter = filterTpCd === 'ALL';
|
||||||
@@ -565,59 +593,61 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
reviewList: allReviews,
|
reviewList: allReviews,
|
||||||
reviewDetail: currentReviewDetail,
|
reviewDetail: currentReviewDetail,
|
||||||
prdtId,
|
prdtId,
|
||||||
...(isAllFilter ? {} : { filterTpCd, filterTpVal })
|
...(isAllFilter ? {} : { filterTpCd, filterTpVal }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const action = {
|
const action = {
|
||||||
type: actionType,
|
type: actionType,
|
||||||
payload: finalPayload
|
payload: finalPayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[UserReviewList] 📦 Redux 디스패치:', {
|
// console.log('[UserReviewList] 📦 Redux 디스패치:', {
|
||||||
actionType,
|
// actionType,
|
||||||
totalReviews: allReviews.length,
|
// totalReviews: allReviews.length,
|
||||||
totRvwCnt: currentReviewDetail?.totRvwCnt,
|
// totRvwCnt: currentReviewDetail?.totRvwCnt,
|
||||||
prdtId
|
// prdtId
|
||||||
});
|
// });
|
||||||
|
|
||||||
dispatch(action);
|
dispatch(action);
|
||||||
|
|
||||||
return finalPayload;
|
return finalPayload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ⭐ 핵심: 다양한 형태의 에러를 안전하게 처리
|
// ⭐ 핵심: 다양한 형태의 에러를 안전하게 처리
|
||||||
const errorMessage = error?.message || (error instanceof Error ? error.toString() : JSON.stringify(error));
|
const errorMessage =
|
||||||
|
error?.message || (error instanceof Error ? error.toString() : JSON.stringify(error));
|
||||||
const httpStatus = error?.response?.status;
|
const httpStatus = error?.response?.status;
|
||||||
const apiRetCode = error?.response?.data?.retCode;
|
const apiRetCode = error?.response?.data?.retCode;
|
||||||
const apiRetMsg = error?.response?.data?.retMsg;
|
const apiRetMsg = error?.response?.data?.retMsg;
|
||||||
|
|
||||||
console.error('[fetchAllReviewsWithSequentialPaging] ❌ 에러 발생:', {
|
// console.error('[fetchAllReviewsWithSequentialPaging] ❌ 에러 발생:', {
|
||||||
errorMessage: errorMessage,
|
// errorMessage: errorMessage,
|
||||||
errorType: typeof error,
|
// errorType: typeof error,
|
||||||
httpStatus: httpStatus,
|
// httpStatus: httpStatus,
|
||||||
apiRetCode: apiRetCode,
|
// apiRetCode: apiRetCode,
|
||||||
apiRetMsg: apiRetMsg,
|
// apiRetMsg: apiRetMsg,
|
||||||
prdtId,
|
// prdtId,
|
||||||
patnrId,
|
// patnrId,
|
||||||
pageNo,
|
// pageNo,
|
||||||
currentCollected: allReviews.length,
|
// currentCollected: allReviews.length,
|
||||||
retryCount,
|
// retryCount,
|
||||||
maxRetries: MAX_RETRIES
|
// maxRetries: MAX_RETRIES
|
||||||
});
|
// });
|
||||||
|
|
||||||
// ⭐ 타임아웃 에러인 경우 재시도
|
// ⭐ 타임아웃 에러인 경우 재시도
|
||||||
const isTimeoutError = errorMessage.includes('timeout') || errorMessage.includes('without callback');
|
const isTimeoutError =
|
||||||
|
errorMessage.includes('timeout') || errorMessage.includes('without callback');
|
||||||
if (isTimeoutError && retryCount < MAX_RETRIES) {
|
if (isTimeoutError && retryCount < MAX_RETRIES) {
|
||||||
console.log(`[fetchAllReviewsWithSequentialPaging] 🔄 타임아웃으로 인한 재시도 (${retryCount + 1}/${MAX_RETRIES}):`, {
|
// console.log(`[fetchAllReviewsWithSequentialPaging] 🔄 타임아웃으로 인한 재시도 (${retryCount + 1}/${MAX_RETRIES}):`, {
|
||||||
prdtId,
|
// prdtId,
|
||||||
patnrId,
|
// patnrId,
|
||||||
pageNo,
|
// pageNo,
|
||||||
retryCount,
|
// retryCount,
|
||||||
delayMs: 1000 * (retryCount + 1)
|
// delayMs: 1000 * (retryCount + 1)
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 지수 백오프: 1초, 2초 대기 후 재시도
|
// 지수 백오프: 1초, 2초 대기 후 재시도
|
||||||
const delayMs = 1000 * (retryCount + 1);
|
const delayMs = 1000 * (retryCount + 1);
|
||||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
|
||||||
// 재귀 호출로 재시도
|
// 재귀 호출로 재시도
|
||||||
return fetchAllReviewsWithSequentialPaging(dispatch, getState, requestParams, retryCount + 1);
|
return fetchAllReviewsWithSequentialPaging(dispatch, getState, requestParams, retryCount + 1);
|
||||||
@@ -630,51 +660,47 @@ const fetchAllReviewsWithSequentialPaging = async (dispatch, getState, requestPa
|
|||||||
|
|
||||||
// User Review List 추가 조회 IF-LGSP-101 (순차 페이징으로 모든 데이터 수집)
|
// User Review List 추가 조회 IF-LGSP-101 (순차 페이징으로 모든 데이터 수집)
|
||||||
export const getUserReviewList = (requestParams) => async (dispatch, getState) => {
|
export const getUserReviewList = (requestParams) => async (dispatch, getState) => {
|
||||||
const {
|
const { prdtId, patnrId, filterTpCd = 'ALL', filterTpVal } = requestParams;
|
||||||
prdtId,
|
|
||||||
patnrId,
|
|
||||||
filterTpCd = 'ALL',
|
|
||||||
filterTpVal
|
|
||||||
} = requestParams;
|
|
||||||
|
|
||||||
console.log('[getUserReviewList] 🚀 getUserReviewList 호출됨 (순차 페이징 사용):', {
|
// console.log('[getUserReviewList] 🚀 getUserReviewList 호출됨 (순차 페이징 사용):', {
|
||||||
prdtId,
|
// prdtId,
|
||||||
patnrId,
|
// patnrId,
|
||||||
filterTpCd,
|
// filterTpCd,
|
||||||
filterTpVal,
|
// filterTpVal,
|
||||||
timestamp: new Date().toISOString()
|
// timestamp: new Date().toISOString()
|
||||||
});
|
// });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// fetchAllReviewsWithSequentialPaging 함수를 호출하여 모든 리뷰 수집
|
// fetchAllReviewsWithSequentialPaging 함수를 호출하여 모든 리뷰 수집
|
||||||
const result = await fetchAllReviewsWithSequentialPaging(dispatch, getState, requestParams);
|
const result = await fetchAllReviewsWithSequentialPaging(dispatch, getState, requestParams);
|
||||||
|
|
||||||
console.log('[getUserReviewList] ✅ 모든 리뷰 수집 완료:', {
|
// console.log('[getUserReviewList] ✅ 모든 리뷰 수집 완료:', {
|
||||||
totalReviews: result.reviewList.length,
|
// totalReviews: result.reviewList.length,
|
||||||
totRvwCnt: result.reviewDetail?.totRvwCnt,
|
// totRvwCnt: result.reviewDetail?.totRvwCnt,
|
||||||
prdtId,
|
// prdtId,
|
||||||
filterTpCd,
|
// filterTpCd,
|
||||||
filterTpVal
|
// filterTpVal
|
||||||
});
|
// });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ⭐ 핵심: 다양한 형태의 에러를 안전하게 처리
|
// ⭐ 핵심: 다양한 형태의 에러를 안전하게 처리
|
||||||
const errorMessage = error?.message || (error instanceof Error ? error.toString() : JSON.stringify(error));
|
const errorMessage =
|
||||||
|
error?.message || (error instanceof Error ? error.toString() : JSON.stringify(error));
|
||||||
const httpStatus = error?.response?.status;
|
const httpStatus = error?.response?.status;
|
||||||
const apiRetCode = error?.response?.data?.retCode;
|
const apiRetCode = error?.response?.data?.retCode;
|
||||||
const apiRetMsg = error?.response?.data?.retMsg;
|
const apiRetMsg = error?.response?.data?.retMsg;
|
||||||
|
|
||||||
console.error('[getUserReviewList] ❌ 순차 페이징 중 에러 발생:', {
|
// console.error('[getUserReviewList] ❌ 순차 페이징 중 에러 발생:', {
|
||||||
errorMessage: errorMessage,
|
// errorMessage: errorMessage,
|
||||||
errorType: typeof error,
|
// errorType: typeof error,
|
||||||
httpStatus: httpStatus,
|
// httpStatus: httpStatus,
|
||||||
apiRetCode: apiRetCode,
|
// apiRetCode: apiRetCode,
|
||||||
apiRetMsg: apiRetMsg,
|
// apiRetMsg: apiRetMsg,
|
||||||
prdtId,
|
// prdtId,
|
||||||
patnrId,
|
// patnrId,
|
||||||
filterTpCd,
|
// filterTpCd,
|
||||||
filterTpVal,
|
// filterTpVal,
|
||||||
stack: error?.stack
|
// stack: error?.stack
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Redux 상태에 에러 정보 저장 (선택사항)
|
// Redux 상태에 에러 정보 저장 (선택사항)
|
||||||
// dispatch({
|
// dispatch({
|
||||||
@@ -708,7 +734,7 @@ const extractReviewFiltersApiData = (apiResponse) => {
|
|||||||
console.error('[ReviewFilters] ❌ API 에러 - retCode !== 0:', {
|
console.error('[ReviewFilters] ❌ API 에러 - retCode !== 0:', {
|
||||||
retCode: retCode,
|
retCode: retCode,
|
||||||
retMsg: apiResponse?.retMsg,
|
retMsg: apiResponse?.retMsg,
|
||||||
fullResponse: apiResponse
|
fullResponse: apiResponse,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -721,7 +747,7 @@ const extractReviewFiltersApiData = (apiResponse) => {
|
|||||||
prdtId: reviewFilterInfos.prdtId,
|
prdtId: reviewFilterInfos.prdtId,
|
||||||
hasFilters: !!reviewFilterInfos.filters,
|
hasFilters: !!reviewFilterInfos.filters,
|
||||||
filtersLength: reviewFilterInfos.filters ? reviewFilterInfos.filters.length : 0,
|
filtersLength: reviewFilterInfos.filters ? reviewFilterInfos.filters.length : 0,
|
||||||
reviewFilterInfosKeys: Object.keys(reviewFilterInfos)
|
reviewFilterInfosKeys: Object.keys(reviewFilterInfos),
|
||||||
});
|
});
|
||||||
|
|
||||||
data = reviewFilterInfos;
|
data = reviewFilterInfos;
|
||||||
@@ -734,7 +760,7 @@ const extractReviewFiltersApiData = (apiResponse) => {
|
|||||||
console.log('[ReviewFilters] ✅ 추출 완료:', {
|
console.log('[ReviewFilters] ✅ 추출 완료:', {
|
||||||
patnrId: data.patnrId,
|
patnrId: data.patnrId,
|
||||||
prdtId: data.prdtId,
|
prdtId: data.prdtId,
|
||||||
filtersLength: data.filters.length
|
filtersLength: data.filters.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -746,16 +772,13 @@ const extractReviewFiltersApiData = (apiResponse) => {
|
|||||||
|
|
||||||
// Review Filters 조회 IF-LGSP-100
|
// Review Filters 조회 IF-LGSP-100
|
||||||
export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
||||||
const {
|
const { prdtId, patnrId } = requestParams;
|
||||||
prdtId,
|
|
||||||
patnrId
|
|
||||||
} = requestParams;
|
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
prdtId,
|
prdtId,
|
||||||
patnrId,
|
patnrId,
|
||||||
// 우선순위 1: cntryCd 기본값 'US' 설정 (TV 환경에서는 자동으로 header로 전달됨)
|
// 우선순위 1: cntryCd 기본값 'US' 설정 (TV 환경에서는 자동으로 header로 전달됨)
|
||||||
cntryCd: 'US'
|
cntryCd: 'US',
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = {};
|
const body = {};
|
||||||
@@ -765,7 +788,7 @@ export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
|||||||
params,
|
params,
|
||||||
body,
|
body,
|
||||||
url: URLS.GET_REVIEW_FILTERS,
|
url: URLS.GET_REVIEW_FILTERS,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
@@ -777,8 +800,8 @@ export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
|||||||
httpStatus: response?.status,
|
httpStatus: response?.status,
|
||||||
retCode: retCode,
|
retCode: retCode,
|
||||||
retMsg: retMsg,
|
retMsg: retMsg,
|
||||||
hasData: !!(response?.data?.data),
|
hasData: !!response?.data?.data,
|
||||||
dataExists: !!response?.data
|
dataExists: !!response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
// retCode !== 0이면 extractReviewFiltersApiData에서 처리하고 null 반환됨
|
// retCode !== 0이면 extractReviewFiltersApiData에서 처리하고 null 반환됨
|
||||||
@@ -788,7 +811,7 @@ export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
|||||||
console.warn('[ReviewFilters] ⚠️ 필터 데이터 추출 실패:', {
|
console.warn('[ReviewFilters] ⚠️ 필터 데이터 추출 실패:', {
|
||||||
retCode: retCode,
|
retCode: retCode,
|
||||||
retMsg: retMsg,
|
retMsg: retMsg,
|
||||||
reason: retCode !== 0 ? 'retCode !== 0' : 'filters 데이터 없음'
|
reason: retCode !== 0 ? 'retCode !== 0' : 'filters 데이터 없음',
|
||||||
});
|
});
|
||||||
return; // 실패 시 dispatch하지 않음
|
return; // 실패 시 dispatch하지 않음
|
||||||
}
|
}
|
||||||
@@ -796,7 +819,7 @@ export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
|||||||
console.log('[ReviewFilters] 📊 필터 데이터 추출 성공:', {
|
console.log('[ReviewFilters] 📊 필터 데이터 추출 성공:', {
|
||||||
patnrId: filtersData.patnrId,
|
patnrId: filtersData.patnrId,
|
||||||
prdtId: filtersData.prdtId,
|
prdtId: filtersData.prdtId,
|
||||||
filtersLength: filtersData.filters ? filtersData.filters.length : 0
|
filtersLength: filtersData.filters ? filtersData.filters.length : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const action = {
|
const action = {
|
||||||
@@ -804,7 +827,7 @@ export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
|||||||
payload: {
|
payload: {
|
||||||
...filtersData,
|
...filtersData,
|
||||||
prdtId: prdtId,
|
prdtId: prdtId,
|
||||||
patnrId: patnrId
|
patnrId: patnrId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -812,7 +835,7 @@ export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
|||||||
actionType: types.GET_REVIEW_FILTERS,
|
actionType: types.GET_REVIEW_FILTERS,
|
||||||
patnrId: patnrId,
|
patnrId: patnrId,
|
||||||
prdtId: prdtId,
|
prdtId: prdtId,
|
||||||
filtersLength: filtersData.filters ? filtersData.filters.length : 0
|
filtersLength: filtersData.filters ? filtersData.filters.length : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(action);
|
dispatch(action);
|
||||||
@@ -839,6 +862,6 @@ export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
|||||||
// All Star 필터 해제 - API 호출 없이 상태만 초기화
|
// All Star 필터 해제 - API 호출 없이 상태만 초기화
|
||||||
export const clearReviewFilter = () => (dispatch) => {
|
export const clearReviewFilter = () => (dispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.CLEAR_REVIEW_FILTER
|
type: types.CLEAR_REVIEW_FILTER,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -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) => {
|
||||||
@@ -120,7 +120,7 @@ export const TAxios = (
|
|||||||
if (axiosInstance) {
|
if (axiosInstance) {
|
||||||
axiosInstance
|
axiosInstance
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
console.log('TAxios response', url, res);
|
// console.log('TAxios response', url, res);
|
||||||
|
|
||||||
const apiSysStatus = res.headers['api-sys-status'];
|
const apiSysStatus = res.headers['api-sys-status'];
|
||||||
const apiSysMessage = res.headers['api-sys-message'];
|
const apiSysMessage = res.headers['api-sys-message'];
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
import classNames from "classnames";
|
import classNames from 'classnames';
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from 'prop-types';
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from 'react-redux';
|
||||||
import onlyUpdateForKeys from "recompose/onlyUpdateForKeys";
|
import onlyUpdateForKeys from 'recompose/onlyUpdateForKeys';
|
||||||
|
|
||||||
import { off, on } from "@enact/core/dispatcher";
|
import { off, on } from '@enact/core/dispatcher';
|
||||||
import { forward } from "@enact/core/handle";
|
import { forward } from '@enact/core/handle';
|
||||||
import hoc from "@enact/core/hoc";
|
import hoc from '@enact/core/hoc';
|
||||||
import ApiDecorator from "@enact/core/internal/ApiDecorator";
|
import ApiDecorator from '@enact/core/internal/ApiDecorator';
|
||||||
import { is } from "@enact/core/keymap";
|
import { is } from '@enact/core/keymap';
|
||||||
import kind from "@enact/core/kind";
|
import kind from '@enact/core/kind';
|
||||||
import { Job } from "@enact/core/util";
|
import { Job } from '@enact/core/util';
|
||||||
import ActionGuide from "@enact/sandstone/ActionGuide";
|
import ActionGuide from '@enact/sandstone/ActionGuide';
|
||||||
import Button from "@enact/sandstone/Button";
|
import Button from '@enact/sandstone/Button';
|
||||||
import $L from "@enact/sandstone/internal/$L";
|
import $L from '@enact/sandstone/internal/$L';
|
||||||
import { compareChildren } from "@enact/sandstone/internal/util";
|
import { compareChildren } from '@enact/sandstone/internal/util';
|
||||||
import Spotlight from "@enact/spotlight";
|
import Spotlight from '@enact/spotlight';
|
||||||
import Pause from "@enact/spotlight/Pause";
|
import Pause from '@enact/spotlight/Pause';
|
||||||
import {
|
import {
|
||||||
SpotlightContainerDecorator,
|
SpotlightContainerDecorator,
|
||||||
spotlightDefaultClass,
|
spotlightDefaultClass,
|
||||||
} from "@enact/spotlight/SpotlightContainerDecorator";
|
} from '@enact/spotlight/SpotlightContainerDecorator';
|
||||||
import Cancelable from "@enact/ui/Cancelable";
|
import Cancelable from '@enact/ui/Cancelable';
|
||||||
import Slottable from "@enact/ui/Slottable";
|
import Slottable from '@enact/ui/Slottable';
|
||||||
|
|
||||||
import ShopTimePlayIcon from "../../../assets/images/btn/btn-video-play-nor@3x.png";
|
import ShopTimePlayIcon from '../../../assets/images/btn/btn-video-play-nor@3x.png';
|
||||||
import ShopTimePauseIcon from "../../../assets/images/btn/btn-voc-pause-nor@3x.png";
|
import ShopTimePauseIcon from '../../../assets/images/btn/btn-voc-pause-nor@3x.png';
|
||||||
import { SpotlightIds } from "../../utils/SpotlightIds";
|
import { SpotlightIds } from '../../utils/SpotlightIds';
|
||||||
import css from "./MediaControls.module.less";
|
import css from './MediaControls.module.less';
|
||||||
import { countReactChildren } from "./util";
|
import { countReactChildren } from './util';
|
||||||
|
|
||||||
const OuterContainer = SpotlightContainerDecorator(
|
const OuterContainer = SpotlightContainerDecorator(
|
||||||
{
|
{
|
||||||
defaultElement: [`.${spotlightDefaultClass}`],
|
defaultElement: [`.${spotlightDefaultClass}`],
|
||||||
},
|
},
|
||||||
"div"
|
'div'
|
||||||
);
|
);
|
||||||
const Container = SpotlightContainerDecorator(
|
const Container = SpotlightContainerDecorator(
|
||||||
{
|
{
|
||||||
enterTo: "default-element",
|
enterTo: 'default-element',
|
||||||
},
|
},
|
||||||
"div"
|
'div'
|
||||||
);
|
);
|
||||||
const MediaButton = onlyUpdateForKeys([
|
const MediaButton = onlyUpdateForKeys([
|
||||||
"children",
|
'children',
|
||||||
"className",
|
'className',
|
||||||
"disabled",
|
'disabled',
|
||||||
"icon",
|
'icon',
|
||||||
"onClick",
|
'onClick',
|
||||||
"spotlightDisabled",
|
'spotlightDisabled',
|
||||||
"onSpotlightRight",
|
'onSpotlightRight',
|
||||||
])(Button);
|
])(Button);
|
||||||
|
|
||||||
const forwardToggleMore = forward("onToggleMore");
|
const forwardToggleMore = forward('onToggleMore');
|
||||||
|
|
||||||
const animationDuration = 300;
|
const animationDuration = 300;
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ const animationDuration = 300;
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const MediaControlsBase = kind({
|
const MediaControlsBase = kind({
|
||||||
name: "MediaControls",
|
name: 'MediaControls',
|
||||||
|
|
||||||
// intentionally assigning these props to MediaControls instead of Base (which is private)
|
// intentionally assigning these props to MediaControls instead of Base (which is private)
|
||||||
propTypes: /** @lends sandstone/MediaPlayer.MediaControls.prototype */ {
|
propTypes: /** @lends sandstone/MediaPlayer.MediaControls.prototype */ {
|
||||||
@@ -286,18 +286,18 @@ const MediaControlsBase = kind({
|
|||||||
},
|
},
|
||||||
|
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
jumpBackwardIcon: "jumpbackward",
|
jumpBackwardIcon: 'jumpbackward',
|
||||||
jumpForwardIcon: "jumpforward",
|
jumpForwardIcon: 'jumpforward',
|
||||||
moreComponentsSpotlightId: "moreComponents",
|
moreComponentsSpotlightId: 'moreComponents',
|
||||||
spotlightId: "mediaControls",
|
spotlightId: 'mediaControls',
|
||||||
pauseIcon: "pause",
|
pauseIcon: 'pause',
|
||||||
playIcon: "play",
|
playIcon: 'play',
|
||||||
visible: true,
|
visible: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
styles: {
|
styles: {
|
||||||
css,
|
css,
|
||||||
className: "controlsFrame",
|
className: 'controlsFrame',
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
@@ -306,8 +306,7 @@ const MediaControlsBase = kind({
|
|||||||
actionGuideShowing: ({ bottomComponents, children }) =>
|
actionGuideShowing: ({ bottomComponents, children }) =>
|
||||||
countReactChildren(children) || bottomComponents,
|
countReactChildren(children) || bottomComponents,
|
||||||
className: ({ visible, styler }) => styler.append({ hidden: !visible }),
|
className: ({ visible, styler }) => styler.append({ hidden: !visible }),
|
||||||
moreButtonsClassName: ({ styler }) =>
|
moreButtonsClassName: ({ styler }) => styler.join('mediaControls', 'moreButtonsComponents'),
|
||||||
styler.join("mediaControls", "moreButtonsComponents"),
|
|
||||||
moreComponentsRendered: ({ showMoreComponents, moreComponentsRendered }) =>
|
moreComponentsRendered: ({ showMoreComponents, moreComponentsRendered }) =>
|
||||||
showMoreComponents || moreComponentsRendered,
|
showMoreComponents || moreComponentsRendered,
|
||||||
},
|
},
|
||||||
@@ -353,8 +352,8 @@ const MediaControlsBase = kind({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (videoVerticalVisible && countryCode !== "US") {
|
if (videoVerticalVisible && countryCode !== 'US') {
|
||||||
Spotlight.focus("tab-0");
|
Spotlight.focus('tab-0');
|
||||||
}
|
}
|
||||||
Spotlight.focus(SpotlightIds.PLAYER_SUBTITLE_BUTTON);
|
Spotlight.focus(SpotlightIds.PLAYER_SUBTITLE_BUTTON);
|
||||||
};
|
};
|
||||||
@@ -367,19 +366,16 @@ const MediaControlsBase = kind({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<OuterContainer {...rest} id={id} spotlightId={spotlightId}>
|
<OuterContainer {...rest} id={id} spotlightId={spotlightId}>
|
||||||
{type !== "LIVE" && (
|
{type !== 'LIVE' && (
|
||||||
<Container
|
<Container
|
||||||
className={classNames(
|
className={classNames(css.mediaControls, videoVerticalVisible && css.videoVertical)}
|
||||||
css.mediaControls,
|
|
||||||
videoVerticalVisible && css.videoVertical
|
|
||||||
)}
|
|
||||||
spotlightDisabled={spotlightDisabled}
|
spotlightDisabled={spotlightDisabled}
|
||||||
onKeyDown={onKeyDownFromMediaButtons}
|
onKeyDown={onKeyDownFromMediaButtons}
|
||||||
>
|
>
|
||||||
{/* {noJumpButtons ? null : <MediaButton aria-label={$L('Previous')} backgroundOpacity="transparent" css={css} disabled={mediaDisabled || jumpBackwardDisabled} icon={jumpBackwardIcon} onClick={onJumpBackwardButtonClick} size="large" spotlightDisabled={spotlightDisabled} />} */}
|
{/* {noJumpButtons ? null : <MediaButton aria-label={$L('Previous')} backgroundOpacity="transparent" css={css} disabled={mediaDisabled || jumpBackwardDisabled} icon={jumpBackwardIcon} onClick={onJumpBackwardButtonClick} size="large" spotlightDisabled={spotlightDisabled} />} */}
|
||||||
|
|
||||||
<MediaButton
|
<MediaButton
|
||||||
aria-label={paused ? $L("Play") : $L("Pause")}
|
aria-label={paused ? $L('Play') : $L('Pause')}
|
||||||
className={spotlightDefaultClass}
|
className={spotlightDefaultClass}
|
||||||
backgroundOpacity="transparent"
|
backgroundOpacity="transparent"
|
||||||
css={css}
|
css={css}
|
||||||
@@ -388,6 +384,7 @@ const MediaControlsBase = kind({
|
|||||||
onClick={onPlayButtonClick}
|
onClick={onPlayButtonClick}
|
||||||
size="large"
|
size="large"
|
||||||
spotlightDisabled={spotlightDisabled}
|
spotlightDisabled={spotlightDisabled}
|
||||||
|
spotlightId={SpotlightIds.PLAYER_PLAY_BUTTON}
|
||||||
onSpotlightRight={onSpotlightRight}
|
onSpotlightRight={onSpotlightRight}
|
||||||
onSpotlightUp={onSpotlightUp}
|
onSpotlightUp={onSpotlightUp}
|
||||||
/>
|
/>
|
||||||
@@ -398,9 +395,7 @@ const MediaControlsBase = kind({
|
|||||||
{actionGuideShowing ? (
|
{actionGuideShowing ? (
|
||||||
<ActionGuide
|
<ActionGuide
|
||||||
id={`${id}_actionGuide`}
|
id={`${id}_actionGuide`}
|
||||||
aria-label={
|
aria-label={actionGuideAriaLabel != null ? actionGuideAriaLabel : null}
|
||||||
actionGuideAriaLabel != null ? actionGuideAriaLabel : null
|
|
||||||
}
|
|
||||||
css={css}
|
css={css}
|
||||||
className={actionGuideClassName}
|
className={actionGuideClassName}
|
||||||
icon="arrowsmalldown"
|
icon="arrowsmalldown"
|
||||||
@@ -438,10 +433,9 @@ const MediaControlsBase = kind({
|
|||||||
const MediaControlsDecorator = hoc((config, Wrapped) => {
|
const MediaControlsDecorator = hoc((config, Wrapped) => {
|
||||||
// eslint-disable-line no-unused-vars
|
// eslint-disable-line no-unused-vars
|
||||||
class MediaControlsDecoratorHOC extends React.Component {
|
class MediaControlsDecoratorHOC extends React.Component {
|
||||||
static displayName = "MediaControlsDecorator";
|
static displayName = 'MediaControlsDecorator';
|
||||||
|
|
||||||
static propTypes =
|
static propTypes = /** @lends sandstone/MediaPlayer.MediaControlsDecorator.prototype */ {
|
||||||
/** @lends sandstone/MediaPlayer.MediaControlsDecorator.prototype */ {
|
|
||||||
/**
|
/**
|
||||||
* The label for the action guide.
|
* The label for the action guide.
|
||||||
*
|
*
|
||||||
@@ -614,7 +608,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
this.keyLoop = null;
|
this.keyLoop = null;
|
||||||
this.pulsingKeyCode = null;
|
this.pulsingKeyCode = null;
|
||||||
this.pulsing = null;
|
this.pulsing = null;
|
||||||
this.paused = new Pause("MediaPlayer");
|
this.paused = new Pause('MediaPlayer');
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
showMoreComponents: false,
|
showMoreComponents: false,
|
||||||
@@ -636,10 +630,10 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
on("keydown", this.handleKeyDown);
|
on('keydown', this.handleKeyDown);
|
||||||
on("keyup", this.handleKeyUp);
|
on('keyup', this.handleKeyUp);
|
||||||
on("blur", this.handleBlur, window);
|
on('blur', this.handleBlur, window);
|
||||||
on("wheel", this.handleWheel);
|
on('wheel', this.handleWheel);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
@@ -650,16 +644,12 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
!this.state.moreComponentsRendered
|
!this.state.moreComponentsRendered
|
||||||
) {
|
) {
|
||||||
this.moreComponentsRenderingJob.startRafAfter();
|
this.moreComponentsRenderingJob.startRafAfter();
|
||||||
} else if (
|
} else if (prevState.showMoreComponents && !this.state.showMoreComponents) {
|
||||||
prevState.showMoreComponents &&
|
|
||||||
!this.state.showMoreComponents
|
|
||||||
) {
|
|
||||||
this.moreComponentsRenderingJob.stop();
|
this.moreComponentsRenderingJob.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!prevState.moreComponentsRendered &&
|
(!prevState.moreComponentsRendered && this.state.moreComponentsRendered) ||
|
||||||
this.state.moreComponentsRendered) ||
|
|
||||||
(this.state.moreComponentsRendered &&
|
(this.state.moreComponentsRendered &&
|
||||||
prevProps.bottomComponents !== this.props.bottomComponents) ||
|
prevProps.bottomComponents !== this.props.bottomComponents) ||
|
||||||
!compareChildren(this.props.children, prevProps.children)
|
!compareChildren(this.props.children, prevProps.children)
|
||||||
@@ -676,7 +666,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
) {
|
) {
|
||||||
forwardToggleMore(
|
forwardToggleMore(
|
||||||
{
|
{
|
||||||
type: "onToggleMore",
|
type: 'onToggleMore',
|
||||||
showMoreComponents: this.state.showMoreComponents,
|
showMoreComponents: this.state.showMoreComponents,
|
||||||
liftDistance: this.bottomComponentsHeight - this.actionGuideHeight,
|
liftDistance: this.bottomComponentsHeight - this.actionGuideHeight,
|
||||||
},
|
},
|
||||||
@@ -690,7 +680,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
this.paused.pause();
|
this.paused.pause();
|
||||||
this.animation = this.moreComponentsNode.animate(
|
this.animation = this.moreComponentsNode.animate(
|
||||||
[
|
[
|
||||||
{ transform: "none", opacity: 0, offset: 0 },
|
{ transform: 'none', opacity: 0, offset: 0 },
|
||||||
{
|
{
|
||||||
transform: `translateY(${-this.actionGuideHeight}px)`,
|
transform: `translateY(${-this.actionGuideHeight}px)`,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -699,7 +689,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
],
|
],
|
||||||
{
|
{
|
||||||
duration: animationDuration,
|
duration: animationDuration,
|
||||||
fill: "forwards",
|
fill: 'forwards',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.animation.onfinish = this.handleFinish;
|
this.animation.onfinish = this.handleFinish;
|
||||||
@@ -717,10 +707,10 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
off("keydown", this.handleKeyDown);
|
off('keydown', this.handleKeyDown);
|
||||||
off("keyup", this.handleKeyUp);
|
off('keyup', this.handleKeyUp);
|
||||||
off("blur", this.handleBlur, window);
|
off('blur', this.handleBlur, window);
|
||||||
off("wheel", this.handleWheel);
|
off('wheel', this.handleWheel);
|
||||||
this.stopListeningForPulses();
|
this.stopListeningForPulses();
|
||||||
this.moreComponentsRenderingJob.stop();
|
this.moreComponentsRenderingJob.stop();
|
||||||
if (this.animation) {
|
if (this.animation) {
|
||||||
@@ -740,17 +730,13 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bottomElement = this.mediaControlsNode.querySelector(
|
const bottomElement = this.mediaControlsNode.querySelector(`.${css.moreComponents}`);
|
||||||
`.${css.moreComponents}`
|
this.bottomComponentsHeight = bottomElement ? bottomElement.scrollHeight : 0;
|
||||||
);
|
|
||||||
this.bottomComponentsHeight = bottomElement
|
|
||||||
? bottomElement.scrollHeight
|
|
||||||
: 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleKeyDownFromMediaButtons = (ev) => {
|
handleKeyDownFromMediaButtons = (ev) => {
|
||||||
if (
|
if (
|
||||||
is("down", ev.keyCode) &&
|
is('down', ev.keyCode) &&
|
||||||
!this.state.showMoreComponents &&
|
!this.state.showMoreComponents &&
|
||||||
!this.props.moreActionDisabled
|
!this.props.moreActionDisabled
|
||||||
) {
|
) {
|
||||||
@@ -769,7 +755,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
!visible &&
|
!visible &&
|
||||||
!mediaDisabled &&
|
!mediaDisabled &&
|
||||||
!current &&
|
!current &&
|
||||||
(is("left", ev.keyCode) || is("right", ev.keyCode))
|
(is('left', ev.keyCode) || is('right', ev.keyCode))
|
||||||
) {
|
) {
|
||||||
this.paused.pause();
|
this.paused.pause();
|
||||||
this.startListeningForPulses(ev.keyCode);
|
this.startListeningForPulses(ev.keyCode);
|
||||||
@@ -777,33 +763,28 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleKeyUp = (ev) => {
|
handleKeyUp = (ev) => {
|
||||||
const {
|
const { mediaDisabled, no5WayJump, rateChangeDisabled, playPauseButtonDisabled } = this.props;
|
||||||
mediaDisabled,
|
|
||||||
no5WayJump,
|
|
||||||
rateChangeDisabled,
|
|
||||||
playPauseButtonDisabled,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (mediaDisabled) return;
|
if (mediaDisabled) return;
|
||||||
|
|
||||||
if (!playPauseButtonDisabled) {
|
if (!playPauseButtonDisabled) {
|
||||||
if (is("play", ev.keyCode)) {
|
if (is('play', ev.keyCode)) {
|
||||||
forward("onPlay", ev, this.props);
|
forward('onPlay', ev, this.props);
|
||||||
} else if (is("pause", ev.keyCode)) {
|
} else if (is('pause', ev.keyCode)) {
|
||||||
forward("onPause", ev, this.props);
|
forward('onPause', ev, this.props);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!no5WayJump && (is("left", ev.keyCode) || is("right", ev.keyCode))) {
|
if (!no5WayJump && (is('left', ev.keyCode) || is('right', ev.keyCode))) {
|
||||||
this.stopListeningForPulses();
|
this.stopListeningForPulses();
|
||||||
this.paused.resume();
|
this.paused.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rateChangeDisabled) {
|
if (!rateChangeDisabled) {
|
||||||
if (is("rewind", ev.keyCode)) {
|
if (is('rewind', ev.keyCode)) {
|
||||||
forward("onRewind", ev, this.props);
|
forward('onRewind', ev, this.props);
|
||||||
} else if (is("fastForward", ev.keyCode)) {
|
} else if (is('fastForward', ev.keyCode)) {
|
||||||
forward("onFastForward", ev, this.props);
|
forward('onFastForward', ev, this.props);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -832,25 +813,22 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
if (!this.pulsing) {
|
if (!this.pulsing) {
|
||||||
this.pulsingKeyCode = keyCode;
|
this.pulsingKeyCode = keyCode;
|
||||||
this.pulsing = true;
|
this.pulsing = true;
|
||||||
this.keyLoop = setTimeout(
|
this.keyLoop = setTimeout(this.handlePulse, this.props.initialJumpDelay);
|
||||||
this.handlePulse,
|
forward('onJump', { keyCode }, this.props);
|
||||||
this.props.initialJumpDelay
|
|
||||||
);
|
|
||||||
forward("onJump", { keyCode }, this.props);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePulse = () => {
|
handlePulse = () => {
|
||||||
forward("onJump", { keyCode: this.pulsingKeyCode }, this.props);
|
forward('onJump', { keyCode: this.pulsingKeyCode }, this.props);
|
||||||
this.keyLoop = setTimeout(this.handlePulse, this.props.jumpDelay);
|
this.keyLoop = setTimeout(this.handlePulse, this.props.jumpDelay);
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePlayButtonClick = (ev) => {
|
handlePlayButtonClick = (ev) => {
|
||||||
forward("onPlayButtonClick", ev, this.props);
|
forward('onPlayButtonClick', ev, this.props);
|
||||||
if (this.props.paused) {
|
if (this.props.paused) {
|
||||||
forward("onPlay", ev, this.props);
|
forward('onPlay', ev, this.props);
|
||||||
} else {
|
} else {
|
||||||
forward("onPause", ev, this.props);
|
forward('onPause', ev, this.props);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -869,9 +847,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
}
|
}
|
||||||
this.mediaControlsNode = ReactDOM.findDOMNode(node); // eslint-disable-line react/no-find-dom-node
|
this.mediaControlsNode = ReactDOM.findDOMNode(node); // eslint-disable-line react/no-find-dom-node
|
||||||
|
|
||||||
const guideElement = this.mediaControlsNode.querySelector(
|
const guideElement = this.mediaControlsNode.querySelector(`.${css.actionGuide}`);
|
||||||
`.${css.actionGuide}`
|
|
||||||
);
|
|
||||||
this.actionGuideHeight = guideElement ? guideElement.scrollHeight : 0;
|
this.actionGuideHeight = guideElement ? guideElement.scrollHeight : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -897,7 +873,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
|
|
||||||
handleClose = (ev) => {
|
handleClose = (ev) => {
|
||||||
if (this.props.visible) {
|
if (this.props.visible) {
|
||||||
forward("onClose", ev, this.props);
|
forward('onClose', ev, this.props);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -905,7 +881,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
if (this.state.showMoreComponents) {
|
if (this.state.showMoreComponents) {
|
||||||
this.paused.resume();
|
this.paused.resume();
|
||||||
if (!Spotlight.getPointerMode()) {
|
if (!Spotlight.getPointerMode()) {
|
||||||
Spotlight.move("down");
|
Spotlight.move('down');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -947,7 +923,7 @@ const MediaControlsDecorator = hoc((config, Wrapped) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Slottable({ slots: ["bottomComponents"] }, MediaControlsDecoratorHOC);
|
return Slottable({ slots: ['bottomComponents'] }, MediaControlsDecoratorHOC);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCancel = (ev, { onClose }) => {
|
const handleCancel = (ev, { onClose }) => {
|
||||||
@@ -972,18 +948,12 @@ const handleCancel = (ev, { onClose }) => {
|
|||||||
*/
|
*/
|
||||||
const MediaControls = ApiDecorator(
|
const MediaControls = ApiDecorator(
|
||||||
{
|
{
|
||||||
api: [
|
api: ['areMoreComponentsAvailable', 'showMoreComponents', 'hideMoreComponents'],
|
||||||
"areMoreComponentsAvailable",
|
|
||||||
"showMoreComponents",
|
|
||||||
"hideMoreComponents",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
MediaControlsDecorator(
|
MediaControlsDecorator(Cancelable({ modal: true, onCancel: handleCancel }, MediaControlsBase))
|
||||||
Cancelable({ modal: true, onCancel: handleCancel }, MediaControlsBase)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
MediaControls.defaultSlot = "mediaControlsComponent";
|
MediaControls.defaultSlot = 'mediaControlsComponent';
|
||||||
|
|
||||||
export default MediaControls;
|
export default MediaControls;
|
||||||
export { MediaControls, MediaControlsBase, MediaControlsDecorator };
|
export { MediaControls, MediaControlsBase, MediaControlsDecorator };
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
z-index: 500;
|
||||||
|
|
||||||
.container__header {
|
.container__header {
|
||||||
position: relative;
|
position: relative;
|
||||||
&::after {
|
&::after {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
.flex(@display: flex, @justifyCenter: flex-start, @alignCenter: center);
|
.flex(@display: flex, @justifyCenter: flex-start, @alignCenter: center);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
.elip(1);
|
||||||
> * {
|
> * {
|
||||||
margin-right: 11px;
|
margin-right: 11px;
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import React, {
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import {
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
useDispatch,
|
|
||||||
useSelector,
|
|
||||||
} from 'react-redux';
|
|
||||||
|
|
||||||
|
import Spotlight from '@enact/spotlight';
|
||||||
import Spottable from '@enact/spotlight/Spottable';
|
import Spottable from '@enact/spotlight/Spottable';
|
||||||
|
|
||||||
import { changeAppStatus } from '../../actions/commonActions';
|
import { changeAppStatus } from '../../actions/commonActions';
|
||||||
import BuyOption from '../../views/DetailPanel/components/BuyOption';
|
import BuyOption from '../../views/DetailPanel/components/BuyOption';
|
||||||
|
import ThemeContents from '../../views/DetailPanel/ThemeProduct/ThemeContents';
|
||||||
import css from './TToastEnhanced.module.less';
|
import css from './TToastEnhanced.module.less';
|
||||||
|
|
||||||
const SpottableToast = Spottable('div');
|
const SpottableToast = Spottable('div');
|
||||||
@@ -37,6 +32,17 @@ export default function TToastEnhanced({
|
|||||||
productInfo, // 🚀 BuyOption에 전달할 상품 정보
|
productInfo, // 🚀 BuyOption에 전달할 상품 정보
|
||||||
selectedPatnrId, // 🚀 BuyOption에 전달할 파트너 ID
|
selectedPatnrId, // 🚀 BuyOption에 전달할 파트너 ID
|
||||||
selectedPrdtId, // 🚀 BuyOption에 전달할 상품 ID
|
selectedPrdtId, // 🚀 BuyOption에 전달할 상품 ID
|
||||||
|
// 🚀 ThemeContents 관련 props
|
||||||
|
themeItems,
|
||||||
|
setSelectedIndex,
|
||||||
|
videoVerticalVisible,
|
||||||
|
currentVideoShowId,
|
||||||
|
tabIndex,
|
||||||
|
handleItemFocus,
|
||||||
|
tabTitle,
|
||||||
|
panelInfo,
|
||||||
|
direction,
|
||||||
|
version,
|
||||||
...rest
|
...rest
|
||||||
}) {
|
}) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -60,6 +66,12 @@ export default function TToastEnhanced({
|
|||||||
// 약간의 지연을 두고 애니메이션 시작
|
// 약간의 지연을 두고 애니메이션 시작
|
||||||
const showTimer = setTimeout(() => {
|
const showTimer = setTimeout(() => {
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
|
// themeContents 타입일 때 포커스 설정
|
||||||
|
if (type === 'themeContents') {
|
||||||
|
setTimeout(() => {
|
||||||
|
Spotlight.focus('theme-contents-close-button');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
startTimer();
|
startTimer();
|
||||||
@@ -68,36 +80,40 @@ export default function TToastEnhanced({
|
|||||||
clearTimeout(showTimer);
|
clearTimeout(showTimer);
|
||||||
clearTimer();
|
clearTimer();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [type]);
|
||||||
|
|
||||||
// BuyOption 컨테이너 ref
|
// BuyOption, ThemeContents 컨테이너 ref
|
||||||
const buyOptionRef = useRef(null);
|
const buyOptionRef = useRef(null);
|
||||||
|
const themeContentsRef = useRef(null);
|
||||||
|
|
||||||
// BuyOption 타입일 때 전역 포커스 감지
|
// BuyOption, ThemeContents 타입일 때 전역 포커스 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === 'buyOption') {
|
if (type === 'buyOption' || type === 'themeContents') {
|
||||||
// BuyOption이 포커스를 받았는지 추적하는 플래그
|
// 포커스를 받았는지 추적하는 플래그
|
||||||
let hasBuyOptionReceivedFocus = false;
|
let hasComponentReceivedFocus = false;
|
||||||
|
const componentRef = type === 'buyOption' ? buyOptionRef : themeContentsRef;
|
||||||
|
|
||||||
const handleFocusChange = (e) => {
|
const handleFocusChange = (e) => {
|
||||||
// 1. BuyOption 내부로 포커스가 들어온 경우 - 플래그를 true로 설정
|
// 1. 컴포넌트 내부로 포커스가 들어온 경우 - 플래그를 true로 설정
|
||||||
if(!cursorVisible){
|
if (!cursorVisible) {
|
||||||
if (buyOptionRef.current && buyOptionRef.current.contains(e.target)) {
|
if (componentRef.current && componentRef.current.contains(e.target)) {
|
||||||
if (!hasBuyOptionReceivedFocus) {
|
if (!hasComponentReceivedFocus) {
|
||||||
hasBuyOptionReceivedFocus = true;
|
hasComponentReceivedFocus = true;
|
||||||
console.log('[TToastEnhanced] BuyOption received focus - now tracking focus leaving');
|
console.log(`[TToastEnhanced] ${type} received focus - now tracking focus leaving`);
|
||||||
}
|
}
|
||||||
return; // 내부에 포커스가 있으면 아무것도 하지 않음
|
return; // 내부에 포커스가 있으면 아무것도 하지 않음
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. BuyOption이 포커스를 받은 적이 있고, 현재 외부로 포커스가 이동한 경우 - Toast 닫기
|
// 2. 컴포넌트가 포커스를 받은 적이 있고, 현재 외부로 포커스가 이동한 경우 - Toast 닫기
|
||||||
|
// themeContents는 spotlightRestrict: 'self-only'이므로 keyboard로는 포커스가 나가지 않음
|
||||||
|
// 따라서 이는 mouse click 등으로 다른 요소를 클릭한 경우만 해당
|
||||||
if (
|
if (
|
||||||
hasBuyOptionReceivedFocus &&
|
hasComponentReceivedFocus &&
|
||||||
buyOptionRef.current &&
|
componentRef.current &&
|
||||||
!buyOptionRef.current.contains(e.target)
|
!componentRef.current.contains(e.target)
|
||||||
) {
|
) {
|
||||||
console.log(
|
console.log(
|
||||||
'[TToastEnhanced] Focus left BuyOption after receiving focus - closing toast'
|
`[TToastEnhanced] Focus left ${type} after receiving focus - closing toast`
|
||||||
);
|
);
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
@@ -195,6 +211,22 @@ export default function TToastEnhanced({
|
|||||||
selectedPrdtId={selectedPrdtId}
|
selectedPrdtId={selectedPrdtId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : type === 'themeContents' ? (
|
||||||
|
<div ref={themeContentsRef}>
|
||||||
|
<ThemeContents
|
||||||
|
themeItems={themeItems}
|
||||||
|
setSelectedIndex={setSelectedIndex}
|
||||||
|
videoVerticalVisible={videoVerticalVisible}
|
||||||
|
currentVideoShowId={currentVideoShowId}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
handleItemFocus={handleItemFocus}
|
||||||
|
tabTitle={tabTitle}
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
direction={direction}
|
||||||
|
version={version}
|
||||||
|
onThemeItemClose={handleClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={css.content}>
|
<div className={css.content}>
|
||||||
<div className={css.message}>{text}</div>
|
<div className={css.message}>{text}</div>
|
||||||
|
|||||||
@@ -115,6 +115,14 @@
|
|||||||
height: 400px;
|
height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.themeContents {
|
||||||
|
height: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.53) 0%, rgba(20.56, 4.68, 32.71, 0.53) 60%, rgba(199, 32, 84, 0) 98%), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.40) 45%, rgba(0, 0, 0, 0.40) 100%), rgba(30, 30, 30, 0.80);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: visible; // 포커스 테두리가 잘리지 않도록
|
||||||
|
}
|
||||||
|
|
||||||
// 컨텐츠 스타일 - 간단한 메시지만
|
// 컨텐츠 스타일 - 간단한 메시지만
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -588,6 +588,8 @@
|
|||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
.position(@position: absolute, @top: 0, @right: 0, @bottom: 0, @left: 0);
|
.position(@position: absolute, @top: 0, @right: 0, @bottom: 0, @left: 0);
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% {
|
0% {
|
||||||
@@ -714,6 +716,7 @@
|
|||||||
|
|
||||||
.controlsHandleAbove {
|
.controlsHandleAbove {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
.position(@position: absolute, @top: 0, @right: 0, @bottom: auto, @left: 0);
|
.position(@position: absolute, @top: 0, @right: 0, @bottom: auto, @left: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,15 @@
|
|||||||
* - 메모리 효율성 우선
|
* - 메모리 효율성 우선
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
import React, {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import DurationFmt from 'ilib/lib/DurationFmt';
|
import DurationFmt from 'ilib/lib/DurationFmt';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
@@ -20,6 +28,7 @@ import Spotlight from '@enact/spotlight';
|
|||||||
import { SpotlightContainerDecorator } from '@enact/spotlight/SpotlightContainerDecorator';
|
import { SpotlightContainerDecorator } from '@enact/spotlight/SpotlightContainerDecorator';
|
||||||
import { Spottable } from '@enact/spotlight/Spottable';
|
import { Spottable } from '@enact/spotlight/Spottable';
|
||||||
import Touchable from '@enact/ui/Touchable';
|
import Touchable from '@enact/ui/Touchable';
|
||||||
|
import { is } from '@enact/core/keymap';
|
||||||
|
|
||||||
import Loader from '../Loader/Loader';
|
import Loader from '../Loader/Loader';
|
||||||
import { MediaSlider, Times, secondsToTime } from '../MediaPlayer';
|
import { MediaSlider, Times, secondsToTime } from '../MediaPlayer';
|
||||||
@@ -33,7 +42,7 @@ import {
|
|||||||
setMediaControlToggle,
|
setMediaControlToggle,
|
||||||
startMediaAutoClose,
|
startMediaAutoClose,
|
||||||
stopMediaAutoClose,
|
stopMediaAutoClose,
|
||||||
resetMediaAutoClose
|
resetMediaAutoClose,
|
||||||
} from '../../actions/mediaOverlayActions';
|
} from '../../actions/mediaOverlayActions';
|
||||||
|
|
||||||
import css from './MediaPlayer.module.less';
|
import css from './MediaPlayer.module.less';
|
||||||
@@ -49,7 +58,8 @@ const RootContainer = SpotlightContainerDecorator(
|
|||||||
|
|
||||||
// DurationFmt memoization
|
// DurationFmt memoization
|
||||||
const memoGetDurFmt = memoize(
|
const memoGetDurFmt = memoize(
|
||||||
() => new DurationFmt({
|
() =>
|
||||||
|
new DurationFmt({
|
||||||
length: 'medium',
|
length: 'medium',
|
||||||
style: 'clock',
|
style: 'clock',
|
||||||
useNative: false,
|
useNative: false,
|
||||||
@@ -68,7 +78,7 @@ const getDurFmt = () => {
|
|||||||
format: (time) => {
|
format: (time) => {
|
||||||
if (!time || !time.millisecond) return '00:00';
|
if (!time || !time.millisecond) return '00:00';
|
||||||
return secondsToTime(Math.floor(time.millisecond / 1000));
|
return secondsToTime(Math.floor(time.millisecond / 1000));
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -112,6 +122,7 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
onLoadedData,
|
onLoadedData,
|
||||||
onLoadedMetadata,
|
onLoadedMetadata,
|
||||||
onDurationChange,
|
onDurationChange,
|
||||||
|
setApiProvider,
|
||||||
|
|
||||||
// Spotlight
|
// Spotlight
|
||||||
spotlightId = 'mediaPlayerV2',
|
spotlightId = 'mediaPlayerV2',
|
||||||
@@ -145,6 +156,9 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
|
|
||||||
// ========== Refs ==========
|
// ========== Refs ==========
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
|
const assignVideoNode = useCallback((node) => {
|
||||||
|
videoRef.current = node || null;
|
||||||
|
}, []);
|
||||||
const playerRef = useRef(null);
|
const playerRef = useRef(null);
|
||||||
const controlsTimeoutRef = useRef(null);
|
const controlsTimeoutRef = useRef(null);
|
||||||
|
|
||||||
@@ -155,7 +169,7 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
try {
|
try {
|
||||||
// URL 파싱 시도
|
// URL 파싱 시도
|
||||||
const url = new URL(src);
|
const url = new URL(src);
|
||||||
return ['youtube.com', 'youtu.be', 'm.youtube.com'].some(domain =>
|
return ['youtube.com', 'youtu.be', 'm.youtube.com'].some((domain) =>
|
||||||
url.hostname.includes(domain)
|
url.hostname.includes(domain)
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -227,7 +241,8 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
}
|
}
|
||||||
}, [ActualVideoComponent]);
|
}, [ActualVideoComponent]);
|
||||||
|
|
||||||
const handleUpdate = useCallback((ev) => {
|
const handleUpdate = useCallback(
|
||||||
|
(ev) => {
|
||||||
const el = videoRef.current;
|
const el = videoRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
@@ -242,9 +257,7 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
setError(el.error || null);
|
setError(el.error || null);
|
||||||
|
|
||||||
// 함수형 업데이트로 stale closure 방지
|
// 함수형 업데이트로 stale closure 방지
|
||||||
setSourceUnavailable((prevUnavailable) =>
|
setSourceUnavailable((prevUnavailable) => (el.loading && prevUnavailable) || el.error);
|
||||||
(el.loading && prevUnavailable) || el.error
|
|
||||||
);
|
|
||||||
|
|
||||||
// Proportion 계산
|
// Proportion 계산
|
||||||
updateProportionLoaded(); // 플랫폼별 계산 함수 호출
|
updateProportionLoaded(); // 플랫폼별 계산 함수 호출
|
||||||
@@ -263,29 +276,40 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
if (ev.type === 'durationchange' && onDurationChange) {
|
if (ev.type === 'durationchange' && onDurationChange) {
|
||||||
onDurationChange(ev);
|
onDurationChange(ev);
|
||||||
}
|
}
|
||||||
}, [onTimeUpdate, onLoadedData, onLoadedMetadata, onDurationChange, updateProportionLoaded]);
|
},
|
||||||
|
[onTimeUpdate, onLoadedData, onLoadedMetadata, onDurationChange, updateProportionLoaded]
|
||||||
|
);
|
||||||
|
|
||||||
const handleEnded = useCallback((e) => {
|
const handleEnded = useCallback(
|
||||||
|
(e) => {
|
||||||
if (onEnded) {
|
if (onEnded) {
|
||||||
onEnded(e);
|
onEnded(e);
|
||||||
}
|
}
|
||||||
}, [onEnded]);
|
},
|
||||||
|
[onEnded]
|
||||||
|
);
|
||||||
|
|
||||||
const handleErrorEvent = useCallback((e) => {
|
const handleErrorEvent = useCallback(
|
||||||
|
(e) => {
|
||||||
setError(e);
|
setError(e);
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(e);
|
onError(e);
|
||||||
}
|
}
|
||||||
}, [onError]);
|
},
|
||||||
|
[onError]
|
||||||
|
);
|
||||||
|
|
||||||
// ========== Controls Management ==========
|
// ========== Controls Management ==========
|
||||||
const showControls = useCallback((timeout = 3000) => {
|
const showControls = useCallback(
|
||||||
|
(timeout = 3000) => {
|
||||||
if (disabled || isModal) return;
|
if (disabled || isModal) return;
|
||||||
|
|
||||||
console.log('🎬 [MediaPlayer.v2] showControls called, dispatching setMediaControlShow');
|
console.log('🎬 [MediaPlayer.v2] showControls called, dispatching setMediaControlShow');
|
||||||
dispatch(setMediaControlShow());
|
dispatch(setMediaControlShow());
|
||||||
dispatch(startMediaAutoClose(timeout));
|
dispatch(startMediaAutoClose(timeout));
|
||||||
}, [disabled, isModal, dispatch]);
|
},
|
||||||
|
[disabled, isModal, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const hideControls = useCallback(() => {
|
const hideControls = useCallback(() => {
|
||||||
console.log('🎬 [MediaPlayer.v2] hideControls called, dispatching setMediaControlHide');
|
console.log('🎬 [MediaPlayer.v2] hideControls called, dispatching setMediaControlHide');
|
||||||
@@ -347,10 +371,13 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
}, [currentTime, duration, paused, loading, error, proportionLoaded]);
|
}, [currentTime, duration, paused, loading, error, proportionLoaded]);
|
||||||
|
|
||||||
// ========== Slider Event Handlers ==========
|
// ========== Slider Event Handlers ==========
|
||||||
const handleSliderChange = useCallback(({ value }) => {
|
const handleSliderChange = useCallback(
|
||||||
|
({ value }) => {
|
||||||
const time = value * duration;
|
const time = value * duration;
|
||||||
seek(time);
|
seek(time);
|
||||||
}, [duration, seek]);
|
},
|
||||||
|
[duration, seek]
|
||||||
|
);
|
||||||
|
|
||||||
const handleKnobMove = useCallback((ev) => {
|
const handleKnobMove = useCallback((ev) => {
|
||||||
if (!videoRef.current) return;
|
if (!videoRef.current) return;
|
||||||
@@ -455,7 +482,9 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// ========== Imperative Handle (API) ==========
|
// ========== Imperative Handle (API) ==========
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
seek,
|
seek,
|
||||||
@@ -465,12 +494,39 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
toggleControls,
|
toggleControls,
|
||||||
areControlsVisible: () => controlsVisible,
|
areControlsVisible: () => controlsVisible,
|
||||||
getVideoNode: () => videoRef.current,
|
getVideoNode: () => videoRef.current,
|
||||||
}), [play, pause, seek, getMediaState, showControls, hideControls, toggleControls, controlsVisible]);
|
}),
|
||||||
|
[play, pause, seek, getMediaState, showControls, hideControls, toggleControls, controlsVisible]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== setApiProvider 호출 ==========
|
||||||
|
useEffect(() => {
|
||||||
|
if (setApiProvider && typeof setApiProvider === 'function') {
|
||||||
|
setApiProvider({
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
getMediaState,
|
||||||
|
showControls,
|
||||||
|
hideControls,
|
||||||
|
toggleControls,
|
||||||
|
areControlsVisible: () => controlsVisible,
|
||||||
|
getVideoNode: () => videoRef.current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
setApiProvider,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
getMediaState,
|
||||||
|
showControls,
|
||||||
|
hideControls,
|
||||||
|
toggleControls,
|
||||||
|
]);
|
||||||
|
|
||||||
// ========== Video Props ==========
|
// ========== Video Props ==========
|
||||||
const videoProps = useMemo(() => {
|
const videoProps = useMemo(() => {
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
ref: videoRef,
|
|
||||||
autoPlay: !paused,
|
autoPlay: !paused,
|
||||||
loop,
|
loop,
|
||||||
muted,
|
muted,
|
||||||
@@ -484,6 +540,7 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
if (ActualVideoComponent === Media) {
|
if (ActualVideoComponent === Media) {
|
||||||
return {
|
return {
|
||||||
...baseProps,
|
...baseProps,
|
||||||
|
ref: assignVideoNode,
|
||||||
className: css.media,
|
className: css.media,
|
||||||
controls: false,
|
controls: false,
|
||||||
mediaComponent: 'video',
|
mediaComponent: 'video',
|
||||||
@@ -493,18 +550,40 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
// ReactPlayer (브라우저 또는 YouTube)
|
// ReactPlayer (브라우저 또는 YouTube)
|
||||||
if (ActualVideoComponent === TReactPlayer) {
|
if (ActualVideoComponent === TReactPlayer) {
|
||||||
return {
|
return {
|
||||||
...baseProps,
|
autoPlay: !paused,
|
||||||
|
loop,
|
||||||
|
muted,
|
||||||
|
onLoadStart: handleLoadStart,
|
||||||
|
onProgress: handleUpdate,
|
||||||
|
onDuration: handleUpdate,
|
||||||
|
onEnded: handleEnded,
|
||||||
|
onError: handleErrorEvent,
|
||||||
url: src,
|
url: src,
|
||||||
playing: !paused,
|
playing: !paused,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
videoRef: videoRef,
|
videoRef: assignVideoNode,
|
||||||
config: reactPlayerConfig,
|
config: reactPlayerConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseProps;
|
return {
|
||||||
}, [ActualVideoComponent, src, paused, loop, muted, handleLoadStart, handleUpdate, handleEnded, handleErrorEvent, reactPlayerConfig]);
|
...baseProps,
|
||||||
|
ref: assignVideoNode,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
ActualVideoComponent,
|
||||||
|
assignVideoNode,
|
||||||
|
src,
|
||||||
|
paused,
|
||||||
|
loop,
|
||||||
|
muted,
|
||||||
|
handleLoadStart,
|
||||||
|
handleUpdate,
|
||||||
|
handleEnded,
|
||||||
|
handleErrorEvent,
|
||||||
|
reactPlayerConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
// ========== Spotlight Handler ==========
|
// ========== Spotlight Handler ==========
|
||||||
const handleSpotlightFocus = useCallback(() => {
|
const handleSpotlightFocus = useCallback(() => {
|
||||||
@@ -517,6 +596,24 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
const shouldDisableControls = disabled || isModal;
|
const shouldDisableControls = disabled || isModal;
|
||||||
const shouldDisableSpotlight = spotlightDisabled || isModal;
|
const shouldDisableSpotlight = spotlightDisabled || isModal;
|
||||||
|
|
||||||
|
const handleModalArrowDown = useCallback(
|
||||||
|
(ev) => {
|
||||||
|
if (!isModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
is('down', ev.keyCode) ||
|
||||||
|
is('up', ev.keyCode) ||
|
||||||
|
is('left', ev.keyCode) ||
|
||||||
|
is('right', ev.keyCode)
|
||||||
|
) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isModal]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootContainer
|
<RootContainer
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -530,21 +627,17 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
spotlightDisabled={shouldDisableSpotlight}
|
spotlightDisabled={shouldDisableSpotlight}
|
||||||
spotlightId={spotlightId}
|
spotlightId={spotlightId}
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
|
onKeyDownCapture={handleModalArrowDown}
|
||||||
>
|
>
|
||||||
{/* Video Element */}
|
{/* Video Element */}
|
||||||
{ActualVideoComponent === Media ? (
|
{ActualVideoComponent === Media ? (
|
||||||
<ActualVideoComponent {...videoProps}>
|
<ActualVideoComponent {...videoProps}>{children}</ActualVideoComponent>
|
||||||
{children}
|
|
||||||
</ActualVideoComponent>
|
|
||||||
) : (
|
) : (
|
||||||
<ActualVideoComponent {...videoProps} />
|
<ActualVideoComponent {...videoProps} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<Overlay
|
<Overlay bottomControlsVisible={controlsVisible} onClick={handleVideoClick}>
|
||||||
bottomControlsVisible={controlsVisible}
|
|
||||||
onClick={handleVideoClick}
|
|
||||||
>
|
|
||||||
{/* Loading + Thumbnail */}
|
{/* Loading + Thumbnail */}
|
||||||
{loading && thumbnailUrl && (
|
{loading && thumbnailUrl && (
|
||||||
<>
|
<>
|
||||||
@@ -563,12 +656,7 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
{/* Slider Section */}
|
{/* Slider Section */}
|
||||||
<div className={css.sliderContainer}>
|
<div className={css.sliderContainer}>
|
||||||
{/* Times - Total */}
|
{/* Times - Total */}
|
||||||
<Times
|
<Times className={css.times} noCurrentTime total={duration} formatter={getDurFmt()} />
|
||||||
className={css.times}
|
|
||||||
noCurrentTime
|
|
||||||
total={duration}
|
|
||||||
formatter={getDurFmt()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Times - Current */}
|
{/* Times - Current */}
|
||||||
<Times
|
<Times
|
||||||
@@ -632,7 +720,7 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
|
|||||||
onSpotlightRight={handleSpotlightFocus}
|
onSpotlightRight={handleSpotlightFocus}
|
||||||
onSpotlightLeft={handleSpotlightFocus}
|
onSpotlightLeft={handleSpotlightFocus}
|
||||||
onClick={handleSpotlightFocus}
|
onClick={handleSpotlightFocus}
|
||||||
spotlightDisabled={controlsVisible || shouldDisableSpotlight}
|
spotlightDisabled={controlsVisible || shouldDisableSpotlight || !isModal}
|
||||||
/>
|
/>
|
||||||
</RootContainer>
|
</RootContainer>
|
||||||
);
|
);
|
||||||
@@ -658,11 +746,13 @@ MediaPlayerV2.propTypes = {
|
|||||||
style: PropTypes.object,
|
style: PropTypes.object,
|
||||||
modalClassName: PropTypes.string,
|
modalClassName: PropTypes.string,
|
||||||
modalScale: PropTypes.number,
|
modalScale: PropTypes.number,
|
||||||
|
setApiProvider: PropTypes.func,
|
||||||
|
|
||||||
// 패널 정보
|
// 패널 정보
|
||||||
panelInfo: PropTypes.shape({
|
panelInfo: PropTypes.shape({
|
||||||
modal: PropTypes.bool,
|
modal: PropTypes.bool,
|
||||||
modalContainerId: PropTypes.string,
|
modalContainerId: PropTypes.string,
|
||||||
|
modalClassName: PropTypes.string,
|
||||||
isPaused: PropTypes.bool,
|
isPaused: PropTypes.bool,
|
||||||
showUrl: PropTypes.string,
|
showUrl: PropTypes.string,
|
||||||
thumbnailUrl: PropTypes.string,
|
thumbnailUrl: PropTypes.string,
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import React, { useCallback, useMemo, useRef, useEffect } from "react";
|
import React, { useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
import ReactPlayer from "react-player";
|
import ReactPlayer from 'react-player';
|
||||||
|
|
||||||
import handle from "@enact/core/handle";
|
import handle from '@enact/core/handle';
|
||||||
|
|
||||||
var handledMediaEventsMap = [
|
var handledMediaEventsMap = [
|
||||||
"onReady",
|
'onReady',
|
||||||
"onStart",
|
'onStart',
|
||||||
"onPlay",
|
'onPlay',
|
||||||
"onProgress",
|
'onProgress',
|
||||||
"onDuration",
|
'onDuration',
|
||||||
"onPause",
|
'onPause',
|
||||||
"onBuffer",
|
'onBuffer',
|
||||||
"onBufferEnd",
|
'onBufferEnd',
|
||||||
"onSeek",
|
'onSeek',
|
||||||
"onEnded",
|
'onEnded',
|
||||||
"onError",
|
'onError',
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function TReactPlayer({
|
export default function TReactPlayer({
|
||||||
@@ -27,23 +27,27 @@ export default function TReactPlayer({
|
|||||||
}) {
|
}) {
|
||||||
const playerRef = useRef(null);
|
const playerRef = useRef(null);
|
||||||
|
|
||||||
|
// 🔽 [최적화] handleEvent의 주요 의존성 추출
|
||||||
|
const playing = rest?.playing;
|
||||||
|
const config = rest?.config;
|
||||||
|
|
||||||
const handleEvent = useCallback(
|
const handleEvent = useCallback(
|
||||||
(type) => (ev) => {
|
(type) => (ev) => {
|
||||||
if (type === "onReady") {
|
if (type === 'onReady') {
|
||||||
if (videoRef) {
|
if (videoRef) {
|
||||||
const videoNode = playerRef.current.getInternalPlayer();
|
const videoNode = playerRef.current.getInternalPlayer();
|
||||||
videoRef(videoNode);
|
videoRef(videoNode);
|
||||||
const iframeEl =
|
const iframeEl =
|
||||||
typeof playerRef.current?.getInternalPlayer === "function"
|
typeof playerRef.current?.getInternalPlayer === 'function'
|
||||||
? playerRef.current.getInternalPlayer("iframe")
|
? playerRef.current.getInternalPlayer('iframe')
|
||||||
: null;
|
: null;
|
||||||
if (iframeEl) {
|
if (iframeEl) {
|
||||||
iframeEl.setAttribute("tabIndex", "-1");
|
iframeEl.setAttribute('tabIndex', '-1');
|
||||||
iframeEl.setAttribute("aria-hidden", "true");
|
iframeEl.setAttribute('aria-hidden', 'true');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
videoNode.tagName &&
|
videoNode.tagName &&
|
||||||
!Object.prototype.hasOwnProperty.call(videoNode, "proportionPlayed")
|
!Object.prototype.hasOwnProperty.call(videoNode, 'proportionPlayed')
|
||||||
) {
|
) {
|
||||||
Object.defineProperties(videoNode, {
|
Object.defineProperties(videoNode, {
|
||||||
error: {
|
error: {
|
||||||
@@ -60,8 +64,7 @@ export default function TReactPlayer({
|
|||||||
get: function () {
|
get: function () {
|
||||||
return (
|
return (
|
||||||
videoNode.buffered.length &&
|
videoNode.buffered.length &&
|
||||||
videoNode.buffered.end(videoNode.buffered.length - 1) /
|
videoNode.buffered.end(videoNode.buffered.length - 1) / videoNode.duration
|
||||||
videoNode.duration
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -71,9 +74,7 @@ export default function TReactPlayer({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (!Object.prototype.hasOwnProperty.call(videoNode, 'proportionPlayed')) {
|
||||||
!Object.prototype.hasOwnProperty.call(videoNode, "proportionPlayed")
|
|
||||||
) {
|
|
||||||
videoNode.play = videoNode.playVideo;
|
videoNode.play = videoNode.playVideo;
|
||||||
videoNode.pause = videoNode.pauseVideo;
|
videoNode.pause = videoNode.pauseVideo;
|
||||||
videoNode.seek = videoNode.seekTo;
|
videoNode.seek = videoNode.seekTo;
|
||||||
@@ -130,11 +131,19 @@ export default function TReactPlayer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle.forward("onLoadStart", { type, ev }, rest);
|
handle.forward('onLoadStart', { type, ev }, rest);
|
||||||
}
|
}
|
||||||
handle.forward("onUpdate", { type, ev }, rest);
|
if (type === 'onEnded' && rest?.isYoutube && rest?.type === 'VOD') {
|
||||||
|
// YouTube 재생 종료 시 iframe이 포커스를 가져가는 문제를 방지
|
||||||
|
const iframeEl =
|
||||||
|
typeof playerRef.current?.getInternalPlayer === 'function'
|
||||||
|
? playerRef.current.getInternalPlayer('iframe')
|
||||||
|
: null;
|
||||||
|
iframeEl?.blur();
|
||||||
|
}
|
||||||
|
handle.forward('onUpdate', { type, ev }, rest);
|
||||||
},
|
},
|
||||||
[videoRef]
|
[videoRef, playing, config] // ✅ 주요 의존성 추가
|
||||||
);
|
);
|
||||||
|
|
||||||
const handledMediaEvents = useMemo(() => {
|
const handledMediaEvents = useMemo(() => {
|
||||||
@@ -146,22 +155,25 @@ export default function TReactPlayer({
|
|||||||
return events;
|
return events;
|
||||||
}, [handleEvent, mediaEventsMap]);
|
}, [handleEvent, mediaEventsMap]);
|
||||||
|
|
||||||
|
// 🔽 [최적화] URL 변경 또는 언마운트 시 이전 비디오 정리 (메모리 누수 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
const videoNode = playerRef.current?.getInternalPlayer();
|
const videoNode = playerRef.current?.getInternalPlayer();
|
||||||
if (videoNode && videoNode.pause) {
|
if (videoNode && videoNode.pause) {
|
||||||
|
console.log('[VIDEO-DEBUG] 🧹 비디오 정리 (URL 변경 또는 언마운트)');
|
||||||
videoNode.pause();
|
videoNode.pause();
|
||||||
videoNode.src = "";
|
videoNode.src = '';
|
||||||
videoNode.srcObject = null;
|
videoNode.srcObject = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [url]); // ✅ URL 변경 시에도 정리 로직 실행
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactPlayer
|
<ReactPlayer
|
||||||
ref={playerRef}
|
ref={playerRef}
|
||||||
url={url}
|
url={url}
|
||||||
progressInterval={1000}
|
progressInterval={1000}
|
||||||
|
config={rest.config}
|
||||||
{...handledMediaEvents}
|
{...handledMediaEvents}
|
||||||
{...rest}
|
{...rest}
|
||||||
playsinline // Add playsinline attribute here
|
playsinline // Add playsinline attribute here
|
||||||
|
|||||||
@@ -886,6 +886,18 @@ const VideoPlayerBase = class extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TabContainerV2(하단 탭)와 상단 오버레이(뒤로가기 아이콘 등) 동기화
|
||||||
|
// mediaControlsVisible이 다른 경로로 토글될 때 belowContentsVisible도 맞춰 줘서
|
||||||
|
// 두 오버레이가 따로 놀지 않도록 한다.
|
||||||
|
if (
|
||||||
|
this.props.tabContainerVersion === 2 &&
|
||||||
|
typeof this.props.setBelowContentsVisible === 'function' &&
|
||||||
|
this.state.mediaControlsVisible !== prevState.mediaControlsVisible &&
|
||||||
|
this.props.belowContentsVisible !== this.state.mediaControlsVisible
|
||||||
|
) {
|
||||||
|
this.props.setBelowContentsVisible(this.state.mediaControlsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!this.state.mediaControlsVisible &&
|
(!this.state.mediaControlsVisible &&
|
||||||
prevState.mediaControlsVisible !== this.state.mediaControlsVisible) ||
|
prevState.mediaControlsVisible !== this.state.mediaControlsVisible) ||
|
||||||
@@ -2089,7 +2101,24 @@ const VideoPlayerBase = class extends React.Component {
|
|||||||
} else if (is('down', keyCode)) {
|
} else if (is('down', keyCode)) {
|
||||||
Spotlight.setPointerMode(false);
|
Spotlight.setPointerMode(false);
|
||||||
|
|
||||||
if (Spotlight.focus(this.mediaControlsSpotlightId)) {
|
// TabContainerV2의 tabIndex=2일 때 버튼들로 포커스 이동
|
||||||
|
if (this.props.tabContainerVersion === 2 && this.props.tabIndexV2 === 2) {
|
||||||
|
let focusSuccessful = false;
|
||||||
|
|
||||||
|
// 먼저 LiveChannelNext 버튼으로 시도
|
||||||
|
if (Spotlight.focus('live-channel-next-button')) {
|
||||||
|
focusSuccessful = true;
|
||||||
|
}
|
||||||
|
// 실패하면 ShopNowButton으로 시도
|
||||||
|
else if (Spotlight.focus('below-tab-shop-now-button')) {
|
||||||
|
focusSuccessful = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusSuccessful) {
|
||||||
|
preventDefault(ev);
|
||||||
|
stopImmediate(ev);
|
||||||
|
}
|
||||||
|
} else if (Spotlight.focus(this.mediaControlsSpotlightId)) {
|
||||||
preventDefault(ev);
|
preventDefault(ev);
|
||||||
stopImmediate(ev);
|
stopImmediate(ev);
|
||||||
}
|
}
|
||||||
|
|||||||
2715
com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.v3.js
Normal file
@@ -0,0 +1,865 @@
|
|||||||
|
// VideoPlayer.module.less
|
||||||
|
//
|
||||||
|
@import "~@enact/sandstone/styles/variables.less";
|
||||||
|
@import "~@enact/sandstone/styles/mixins.less";
|
||||||
|
@import "~@enact/sandstone/styles/skin.less";
|
||||||
|
@import "../../style/utils.module.less";
|
||||||
|
@import "../../style/CommonStyle.module.less";
|
||||||
|
.fullscreen .videoPlayer,
|
||||||
|
.videoPlayer {
|
||||||
|
// Set by counting the IconButtons inside the side components.
|
||||||
|
--liftDistance: 0px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 2px;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
:focus {
|
||||||
|
outline: none !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fullscreen {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
height: var(--media-height, calc(100% - 4px));
|
||||||
|
width: var(--media-width, calc(100% - 4px));
|
||||||
|
background: #000;
|
||||||
|
|
||||||
|
&.mediaBackground {
|
||||||
|
&:after {
|
||||||
|
width: 560px;
|
||||||
|
height: 200px;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
content: "";
|
||||||
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgba(255, 255, 255, 1),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fullscreen {
|
||||||
|
--media-width: 100vw;
|
||||||
|
--media-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen .videoPlayer .media {
|
||||||
|
--media-width: 100vw;
|
||||||
|
--media-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preloadVideo {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
> img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
&.noRadiusThumbnail {
|
||||||
|
> img {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.verticalThumbnail {
|
||||||
|
> img {
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.smallThumbnail {
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow:0, @borderRadius: 12px);
|
||||||
|
border: 6px solid @PRIMARY_COLOR_RED;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
bottom: -4px;
|
||||||
|
left: -4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disclaimer {
|
||||||
|
.size(@w: 100% , @h: 54px);
|
||||||
|
display: flex;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
> span {
|
||||||
|
.size(@w: 18px , @h: 18px);
|
||||||
|
background-image: url("../../../assets/images/icons/ic-alert-20@3x.png");
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
margin: 0 12px 0 20px;
|
||||||
|
}
|
||||||
|
> h3 {
|
||||||
|
.size(@w: 100% , @h: 54px);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 54px;
|
||||||
|
.elip(@clamp:1);
|
||||||
|
.marquee {
|
||||||
|
width: 100%;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.videoOverlayWithPhoneNumberFull {
|
||||||
|
bottom: 59px;
|
||||||
|
left: 141px;
|
||||||
|
}
|
||||||
|
.videoOverlayWithPhoneNumber {
|
||||||
|
display: none;
|
||||||
|
&.ru {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
width: 22%;
|
||||||
|
height: 12.5%;
|
||||||
|
bottom: 6.5% !important;
|
||||||
|
left: 48% !important;
|
||||||
|
padding: 4px !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
> div:first-child {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
> div:last-child {
|
||||||
|
margin-top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
> img {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
> span {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.us {
|
||||||
|
&.vertical {
|
||||||
|
width: 105px;
|
||||||
|
height: 66px;
|
||||||
|
bottom: 225px !important;
|
||||||
|
left: -96px !important;
|
||||||
|
}
|
||||||
|
&.horizontal {
|
||||||
|
> div:first-child {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
> div:last-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
> img {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
> span {
|
||||||
|
font-size: 16px;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.qvc {
|
||||||
|
display: flex;
|
||||||
|
width: 18%;
|
||||||
|
height: 22%;
|
||||||
|
padding: 1%;
|
||||||
|
bottom: 4% !important;
|
||||||
|
left: 4.5% !important;
|
||||||
|
> div:first-child {
|
||||||
|
font-size: 48%;
|
||||||
|
}
|
||||||
|
> div:last-child {
|
||||||
|
> img {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
> span {
|
||||||
|
font-size: 48%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.hsn {
|
||||||
|
display: flex;
|
||||||
|
width: 18.5%;
|
||||||
|
height: 22%;
|
||||||
|
padding: 1%;
|
||||||
|
bottom: 4% !important;
|
||||||
|
left: 7% !important;
|
||||||
|
> div:first-child {
|
||||||
|
font-size: 48%;
|
||||||
|
}
|
||||||
|
> div:last-child {
|
||||||
|
> img {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
> span {
|
||||||
|
font-size: 48%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.verticalModal {
|
||||||
|
> div:last-child {
|
||||||
|
> img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
> span {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
> img {
|
||||||
|
width: 102px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div > img {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoOverlayMedia {
|
||||||
|
bottom: 24% !important;
|
||||||
|
left: 7% !important;
|
||||||
|
&.callToOrderHide {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
&.qvc {
|
||||||
|
display: flex;
|
||||||
|
width: 23%;
|
||||||
|
height: 15%;
|
||||||
|
padding: 30px 5px;
|
||||||
|
> div:first-child {
|
||||||
|
flex: none;
|
||||||
|
font-size: 13px !important;
|
||||||
|
line-height: 13px !important;
|
||||||
|
padding: 3px 5px;
|
||||||
|
}
|
||||||
|
> div:last-child {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
height: 4px;
|
||||||
|
> img {
|
||||||
|
flex: none;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
> span {
|
||||||
|
flex: none;
|
||||||
|
font-size: 15px;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.hsn {
|
||||||
|
display: flex;
|
||||||
|
width: 23%;
|
||||||
|
height: 15%;
|
||||||
|
padding: 30px 5px;
|
||||||
|
> div:first-child {
|
||||||
|
flex: none;
|
||||||
|
font-size: 13px !important;
|
||||||
|
line-height: 13px !important;
|
||||||
|
padding: 3px 5px;
|
||||||
|
}
|
||||||
|
> div:last-child {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
height: 4px;
|
||||||
|
> img {
|
||||||
|
flex: none;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
> span {
|
||||||
|
flex: none;
|
||||||
|
font-size: 15px;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaderWrap {
|
||||||
|
height: 100%;
|
||||||
|
> div {
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
left: calc(50% - 110px);
|
||||||
|
top: calc(50% - 110px);
|
||||||
|
background-color: transparent;
|
||||||
|
> div {
|
||||||
|
> div {
|
||||||
|
-webkit-animation: mulShdSpinWhite 1.2s infinite ease !important;
|
||||||
|
animation: mulShdSpinWhite 1.2s infinite ease !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@-webkit-keyframes mulShdSpinWhite {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em #fff,
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.7);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em #fff,
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
12.5% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.7),
|
||||||
|
1.8em -1.8em 0 0em #fff, 2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.5);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.7),
|
||||||
|
1.8em -1.8em 0 0em #fff, 2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.5),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.7), 2.5em 0em 0 0em #fff,
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.5),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.7), 2.5em 0em 0 0em #fff,
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
37.5% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.7), 1.75em 1.75em 0 0em #fff,
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.7), 1.75em 1.75em 0 0em #fff,
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.7), 0em 2.5em 0 0em #fff,
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.7), 0em 2.5em 0 0em #fff,
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
62.5% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.7), -1.8em 1.8em 0 0em #fff,
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.7), -1.8em 1.8em 0 0em #fff,
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.7), -2.6em 0em 0 0em #fff,
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.7), -2.6em 0em 0 0em #fff,
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
87.5% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.7), -1.8em -1.8em 0 0em #fff;
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.7), -1.8em -1.8em 0 0em #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mulShdSpinWhite {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em #fff,
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.7);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em #fff,
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
12.5% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.7),
|
||||||
|
1.8em -1.8em 0 0em #fff, 2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.5);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.7),
|
||||||
|
1.8em -1.8em 0 0em #fff, 2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.5),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.7), 2.5em 0em 0 0em #fff,
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.5),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.7), 2.5em 0em 0 0em #fff,
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
37.5% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.7), 1.75em 1.75em 0 0em #fff,
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.7), 1.75em 1.75em 0 0em #fff,
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.7), 0em 2.5em 0 0em #fff,
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.7), 0em 2.5em 0 0em #fff,
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
62.5% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.7), -1.8em 1.8em 0 0em #fff,
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.7), -1.8em 1.8em 0 0em #fff,
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.7), -2.6em 0em 0 0em #fff,
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.7), -2.6em 0em 0 0em #fff,
|
||||||
|
-1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
87.5% {
|
||||||
|
-webkit-box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.7), -1.8em -1.8em 0 0em #fff;
|
||||||
|
box-shadow: 0em -2.6em 0em 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.8em -1.8em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
2.5em 0em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
1.75em 1.75em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
0em 2.5em 0 0em rgba(255, 255, 255, 0.2),
|
||||||
|
-1.8em 1.8em 0 0em rgba(255, 255, 255, 0.5),
|
||||||
|
-2.6em 0em 0 0em rgba(255, 255, 255, 0.7), -1.8em -1.8em 0 0em #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
.position(@position: absolute, @top: 0, @right: 0, @bottom: 0, @left: 0);
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0.25turn);
|
||||||
|
}
|
||||||
|
33% {
|
||||||
|
transform: rotate(0.5turn);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: rotate(0.95turn);
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
transform: rotate(1turn);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(1.25turn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
background-image: url("../../../assets/images/player/icon_loading.png");
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
position: relative;
|
||||||
|
background-size: cover;
|
||||||
|
border-radius: 20.8125rem;
|
||||||
|
overflow: hidden;
|
||||||
|
// margin: 490px auto;
|
||||||
|
left: calc(50% - 50px);
|
||||||
|
top: calc(50% - 50px);
|
||||||
|
animation: none 1.25s linear infinite;
|
||||||
|
animation-name: spin;
|
||||||
|
// animation-play-state: paused;
|
||||||
|
}
|
||||||
|
.controlFeedbackBtnLayer {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 50;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 94px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
&.lift {
|
||||||
|
transform: translateY(~"calc(var(--liftDistance) * -1)");
|
||||||
|
transition: transform 0.3s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullscreen {
|
||||||
|
.miniFeedback {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 50;
|
||||||
|
top: 506px;
|
||||||
|
left: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 94px;
|
||||||
|
-webkit-margin-end: 0px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
&.liveFullScreen {
|
||||||
|
.bottom {
|
||||||
|
bottom: 78px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3; // Value assigned as part of the VideoPlayer API so layers may be inserted in-between
|
||||||
|
bottom: 70px;
|
||||||
|
// bottom: 78px;
|
||||||
|
// bottom: -18px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 70px;
|
||||||
|
// left: @sand-video-player-padding-side;
|
||||||
|
// right: @sand-video-player-padding-side;
|
||||||
|
|
||||||
|
&.videoVerticalBottom {
|
||||||
|
height: 54px;
|
||||||
|
}
|
||||||
|
&.lift {
|
||||||
|
transform: translateY(~"calc(var(--liftDistance) * -1)");
|
||||||
|
transition: transform 0.3s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.sliderContainer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoFrame {
|
||||||
|
display: flex;
|
||||||
|
margin-left: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderContainer {
|
||||||
|
// display: flex;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 60px;
|
||||||
|
margin-right: 59px;
|
||||||
|
height: 70px;
|
||||||
|
bottom: -20px;
|
||||||
|
> *:first-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enact-locale-rtl({
|
||||||
|
direction: ltr;
|
||||||
|
});
|
||||||
|
|
||||||
|
&.videoVertical {
|
||||||
|
margin-left: 680px;
|
||||||
|
margin-right: 673px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsHandleAbove {
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
.position(@position: absolute, @top: 0, @right: 0, @bottom: auto, @left: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skin colors
|
||||||
|
.applySkins({
|
||||||
|
.fullscreen {
|
||||||
|
.bottom {
|
||||||
|
background-color: @sand-video-player-bottom-bg-color;
|
||||||
|
|
||||||
|
.infoFrame {
|
||||||
|
text-shadow: @sand-video-player-title-text-shadow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
&.scrim::before,
|
||||||
|
&.scrim::after {
|
||||||
|
width: 1920px;
|
||||||
|
height: 50%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
content: "";
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
&.scrim::before {
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 1) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
&.scrim::after {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// &.scrim::after {
|
||||||
|
// background: @sand-video-player-scrim-gradient-color
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== MediaPlayer.v2 Controls ==========
|
||||||
|
.controlsContainer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 20px 40px 30px;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.7) 60%, transparent 100%);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.times {
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playPauseBtn {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
font-size: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
com.twin.app.shoptime/src/hooks/useDetailFocus/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './useDetailFocus';
|
||||||
120
com.twin.app.shoptime/src/hooks/useDetailFocus/useDetailFocus.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import Spotlight from '@enact/spotlight';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useDetailFocus - 포커스 이동 보정용 Hook
|
||||||
|
*
|
||||||
|
* ProductAllSection의 복잡한 조건부 렌더링으로 인한 포커스 손실을 방지하기 위해
|
||||||
|
* arrow key에 따라 다음 포커스 항목을 queue 형태로 관리하고,
|
||||||
|
* 타이머로 포커스 이동을 수행합니다.
|
||||||
|
*
|
||||||
|
* useEffect의 의존성배열에 따라 타이머가 관리되고,
|
||||||
|
* 컴포넌트 unmount 시 자동으로 cleanup됩니다.
|
||||||
|
*
|
||||||
|
* @param {number} delayMs - 포커스 이동 지연 시간 (기본값: 250ms)
|
||||||
|
* @returns {Object} { enqueueFocus }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { enqueueFocus } = useDetailFocus(250);
|
||||||
|
*
|
||||||
|
* const handleArrowDown = (e) => {
|
||||||
|
* e.stopPropagation();
|
||||||
|
* enqueueFocus('next-button-id');
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export default function useDetailFocus(delayMs = 500) {
|
||||||
|
const focusQueueRef = useRef([]);
|
||||||
|
const [queueTick, setQueueTick] = useState(0);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포커스 ID를 queue에 추가 (상태만 업데이트, 타이머는 useEffect에서 관리)
|
||||||
|
* @param {string} focusId - 포커스할 요소의 ID
|
||||||
|
*/
|
||||||
|
const enqueueFocus = useCallback(
|
||||||
|
(focusId) => {
|
||||||
|
if (!focusId) {
|
||||||
|
console.warn('[FocusDetail] ⚠️ focusId가 제공되지 않았습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 큐에 추가하고 tick을 올려 useEffect를 트리거
|
||||||
|
focusQueueRef.current.push(focusId);
|
||||||
|
console.log(`[FocusDetail] 📋 Queue에 ID 추가: ${focusId} (${delayMs}ms 후 포커스)`);
|
||||||
|
setQueueTick((tick) => tick + 1);
|
||||||
|
},
|
||||||
|
[delayMs]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* focusQueue 상태에 따라 타이머 관리
|
||||||
|
* focusQueue가 설정되면 타이머 시작
|
||||||
|
* 컴포넌트 unmount 시 useEffect cleanup에서 자동으로 타이머 정리
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
// queue에 아무것도 없으면 종료
|
||||||
|
if (!focusQueueRef.current.length) {
|
||||||
|
console.log(`[FocusDetail] 📭 Queue 비어있음 (포커스 보정 불필요)`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 타이머가 있으면 취소
|
||||||
|
if (timerRef.current) {
|
||||||
|
console.log(
|
||||||
|
`[FocusDetail] ⏹️ 기존 타이머 취소 - 대기 중인 Queue: ${focusQueueRef.current.join(',')}`
|
||||||
|
);
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 타이머 설정
|
||||||
|
const targetId = focusQueueRef.current[focusQueueRef.current.length - 1]; // 마지막 ID
|
||||||
|
console.log(`[FocusDetail] ⏱️ 타이머 시작 - ${delayMs}ms 후 포커스 이동: ${targetId}`);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
console.log(`[FocusDetail] ⏱️ 타이머 만료 - 포커스 이동 시도: ${targetId}`);
|
||||||
|
|
||||||
|
// 현재 포커스된 요소 확인
|
||||||
|
const currentElement = Spotlight.getCurrent();
|
||||||
|
const currentId = currentElement?.dataset?.spotlightId || currentElement?.id || 'unknown';
|
||||||
|
console.log(`[FocusDetail] 📌 현재 포커스 상태: ${currentId}, 타깃: ${targetId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = Spotlight.focus(targetId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
const afterElement = Spotlight.getCurrent();
|
||||||
|
const afterId = afterElement?.dataset?.spotlightId || afterElement?.id || 'unknown';
|
||||||
|
console.warn(`[FocusDetail] ❌ 포커스 이동 실패: ${targetId} (현재: ${afterId})`);
|
||||||
|
console.warn(
|
||||||
|
`[FocusDetail] 📋 요소 존재 확인: ${document.querySelector(`[data-spotlight-id="${targetId}"]`) ? '✅ 존재' : '❌ 없음'}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`[FocusDetail] ✅ 포커스 이동 성공: ${targetId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[FocusDetail] 💥 포커스 이동 중 에러:', error);
|
||||||
|
} finally {
|
||||||
|
// 타이머 정리
|
||||||
|
console.log(`[FocusDetail] 🧹 타이머 정리 - 처리 완료: ${targetId}`);
|
||||||
|
timerRef.current = null;
|
||||||
|
// Queue 초기화
|
||||||
|
focusQueueRef.current = [];
|
||||||
|
}
|
||||||
|
}, delayMs);
|
||||||
|
|
||||||
|
// cleanup: 의존성배열 변경 또는 컴포넌트 unmount 시 타이머 정리
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
console.log(
|
||||||
|
`[FocusDetail] 🧹 useEffect cleanup - 대기 중인 타이머 취소 (Queue: ${focusQueueRef.current.join(',')})`
|
||||||
|
);
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [queueTick, delayMs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueueFocus,
|
||||||
|
focusQueue: focusQueueRef.current, // 디버깅용
|
||||||
|
};
|
||||||
|
}
|
||||||
15
com.twin.app.shoptime/src/hooks/useMediaPanelController.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React, { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
const MediaPanelControllerContext = createContext(null);
|
||||||
|
|
||||||
|
export function MediaPanelControllerProvider({ controller, children }) {
|
||||||
|
return (
|
||||||
|
<MediaPanelControllerContext.Provider value={controller}>
|
||||||
|
{children}
|
||||||
|
</MediaPanelControllerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMediaPanelController() {
|
||||||
|
return useContext(MediaPanelControllerContext);
|
||||||
|
}
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
import {useRef, useEffect} from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
function usePrevious (value) {
|
/**
|
||||||
|
* usePrevious - React 16.7 전용
|
||||||
|
* @param {*} value – 현재값 (배열, 객체, 프롭 등)
|
||||||
|
* @return {*} – useRef 객체 { current: previousValue }
|
||||||
|
*
|
||||||
|
* 사용 예시
|
||||||
|
* const prevRef = usePrevious(value);
|
||||||
|
* if (prevRef.current !== value) { … }
|
||||||
|
*/
|
||||||
|
export default function usePrevious(value) {
|
||||||
|
// useRef 가 저장한 객체는 렌더 사이에서도 동일합니다.
|
||||||
const ref = useRef();
|
const ref = useRef();
|
||||||
|
|
||||||
|
// value 가 바뀔 때마다 ref 를 갱신
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ref.current = value;
|
ref.current = value;
|
||||||
}, [value]);
|
}, [value]); // value 의 변경만 감지
|
||||||
|
|
||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default usePrevious;
|
|
||||||
|
|||||||
307
com.twin.app.shoptime/src/hooks/usePrevious.test.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import usePrevious from './usePrevious';
|
||||||
|
|
||||||
|
describe('usePrevious', () => {
|
||||||
|
// 단일 값 테스트
|
||||||
|
describe('단일 값 추적', () => {
|
||||||
|
it('초기 렌더링에서 ref 객체를 반환하고 ref.current는 undefined여야 한다', () => {
|
||||||
|
const { result } = renderHook(() => usePrevious(0));
|
||||||
|
expect(result.current).toBeDefined();
|
||||||
|
expect(result.current.current).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('값이 변경되면 ref.current에 이전 값이 저장되어야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
|
||||||
|
initialProps: { value: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.current).toBeUndefined();
|
||||||
|
|
||||||
|
rerender({ value: 1 });
|
||||||
|
expect(result.current.current).toBe(0);
|
||||||
|
|
||||||
|
rerender({ value: 2 });
|
||||||
|
expect(result.current.current).toBe(1);
|
||||||
|
|
||||||
|
rerender({ value: 3 });
|
||||||
|
expect(result.current.current).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('숫자 값을 정확히 추적해야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(({ num }) => usePrevious(num), {
|
||||||
|
initialProps: { num: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ num: 200 });
|
||||||
|
expect(result.current.current).toBe(100);
|
||||||
|
|
||||||
|
rerender({ num: 300 });
|
||||||
|
expect(result.current.current).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('문자열 값을 정확히 추적해야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(({ str }) => usePrevious(str), {
|
||||||
|
initialProps: { str: 'hello' },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ str: 'world' });
|
||||||
|
expect(result.current.current).toBe('hello');
|
||||||
|
|
||||||
|
rerender({ str: 'react' });
|
||||||
|
expect(result.current.current).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boolean 값을 정확히 추적해야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(({ bool }) => usePrevious(bool), {
|
||||||
|
initialProps: { bool: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ bool: false });
|
||||||
|
expect(result.current.current).toBe(true);
|
||||||
|
|
||||||
|
rerender({ bool: true });
|
||||||
|
expect(result.current.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null 값을 추적할 수 있어야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(({ val }) => usePrevious(val), {
|
||||||
|
initialProps: { val: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ val: 'something' });
|
||||||
|
expect(result.current.current).toBeNull();
|
||||||
|
|
||||||
|
rerender({ val: null });
|
||||||
|
expect(result.current.current).toBe('something');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 객체 테스트
|
||||||
|
describe('객체 값 추적', () => {
|
||||||
|
it('객체를 추적하고 이전 객체를 반환해야 한다', () => {
|
||||||
|
const obj1 = { name: 'John', age: 30 };
|
||||||
|
const obj2 = { name: 'Jane', age: 25 };
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(({ obj }) => usePrevious(obj), {
|
||||||
|
initialProps: { obj: obj1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.current).toBeUndefined();
|
||||||
|
|
||||||
|
rerender({ obj: obj2 });
|
||||||
|
expect(result.current.current).toBe(obj1);
|
||||||
|
expect(result.current.current.name).toBe('John');
|
||||||
|
expect(result.current.current.age).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('중첩된 객체를 추적할 수 있어야 한다', () => {
|
||||||
|
const obj1 = { user: { name: 'John', profile: { age: 30 } } };
|
||||||
|
const obj2 = { user: { name: 'Jane', profile: { age: 25 } } };
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(({ obj }) => usePrevious(obj), {
|
||||||
|
initialProps: { obj: obj1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ obj: obj2 });
|
||||||
|
expect(result.current.current.user.name).toBe('John');
|
||||||
|
expect(result.current.current.user.profile.age).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('변동 감지에 사용할 수 있어야 한다', () => {
|
||||||
|
const obj1 = { name: 'John', age: 30 };
|
||||||
|
const obj2 = { name: 'Jane', age: 30 };
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ obj }) => {
|
||||||
|
const prevRef = usePrevious(obj);
|
||||||
|
return {
|
||||||
|
prev: prevRef.current,
|
||||||
|
nameChanged: prevRef.current?.name !== obj.name,
|
||||||
|
ageChanged: prevRef.current?.age !== obj.age,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ initialProps: { obj: obj1 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.nameChanged).toBeUndefined();
|
||||||
|
|
||||||
|
rerender({ obj: obj2 });
|
||||||
|
expect(result.current.nameChanged).toBe(true);
|
||||||
|
expect(result.current.ageChanged).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 배열 테스트
|
||||||
|
describe('배열 값 추적', () => {
|
||||||
|
it('배열을 추적하고 이전 배열을 반환해야 한다', () => {
|
||||||
|
const arr1 = [1, 2, 3];
|
||||||
|
const arr2 = [4, 5, 6];
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(({ arr }) => usePrevious(arr), {
|
||||||
|
initialProps: { arr: arr1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.current).toBeUndefined();
|
||||||
|
|
||||||
|
rerender({ arr: arr2 });
|
||||||
|
expect(result.current.current).toEqual([1, 2, 3]);
|
||||||
|
expect(result.current.current).not.toBe(arr2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('배열 요소의 이전 값을 추적할 수 있어야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ a, b }) => {
|
||||||
|
const prevRef = usePrevious([a, b]);
|
||||||
|
const [prevA, prevB] = prevRef.current || [];
|
||||||
|
return { prevA, prevB };
|
||||||
|
},
|
||||||
|
{ initialProps: { a: 1, b: 2 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.prevA).toBeUndefined();
|
||||||
|
|
||||||
|
rerender({ a: 10, b: 20 });
|
||||||
|
expect(result.current.prevA).toBe(1);
|
||||||
|
expect(result.current.prevB).toBe(2);
|
||||||
|
|
||||||
|
rerender({ a: 100, b: 200 });
|
||||||
|
expect(result.current.prevA).toBe(10);
|
||||||
|
expect(result.current.prevB).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('복잡한 배열 요소를 추적할 수 있어야 한다', () => {
|
||||||
|
const arr1 = [{ id: 1 }, { id: 2 }];
|
||||||
|
const arr2 = [{ id: 3 }, { id: 4 }];
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(({ arr }) => usePrevious(arr), {
|
||||||
|
initialProps: { arr: arr1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ arr: arr2 });
|
||||||
|
expect(result.current.current[0].id).toBe(1);
|
||||||
|
expect(result.current.current[1].id).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 변동 감지 테스트
|
||||||
|
describe('변동 감지 활용', () => {
|
||||||
|
it('값이 변경되었는지 감지할 수 있어야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => {
|
||||||
|
const prevRef = usePrevious(value);
|
||||||
|
return prevRef.current !== value;
|
||||||
|
},
|
||||||
|
{ initialProps: { value: 'initial' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
|
||||||
|
rerender({ value: 'changed' });
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
|
||||||
|
rerender({ value: 'changed' });
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('깊은 비교 없이도 객체 필드 변경을 감지할 수 있어야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ obj }) => {
|
||||||
|
const prevRef = usePrevious(obj);
|
||||||
|
return {
|
||||||
|
prevValue: prevRef.current?.value,
|
||||||
|
changed: prevRef.current?.value !== obj.value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ initialProps: { obj: { value: 100 } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ obj: { value: 200 } });
|
||||||
|
expect(result.current.prevValue).toBe(100);
|
||||||
|
expect(result.current.changed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('여러 필드 변경을 각각 추적할 수 있어야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ name, age, email }) => {
|
||||||
|
const prevRef = usePrevious({ name, age, email });
|
||||||
|
return {
|
||||||
|
nameChanged: prevRef.current?.name !== name,
|
||||||
|
ageChanged: prevRef.current?.age !== age,
|
||||||
|
emailChanged: prevRef.current?.email !== email,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ initialProps: { name: 'John', age: 30, email: 'john@example.com' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ name: 'Jane', age: 30, email: 'john@example.com' });
|
||||||
|
expect(result.current.nameChanged).toBe(true);
|
||||||
|
expect(result.current.ageChanged).toBe(false);
|
||||||
|
expect(result.current.emailChanged).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 엣지 케이스
|
||||||
|
describe('엣지 케이스', () => {
|
||||||
|
it('동일한 ref 객체를 반환해야 한다 (참조 안정성)', () => {
|
||||||
|
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
|
||||||
|
initialProps: { value: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRef = result.current;
|
||||||
|
|
||||||
|
rerender({ value: 10 });
|
||||||
|
const secondRef = result.current;
|
||||||
|
|
||||||
|
expect(firstRef).toBe(secondRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undefined를 값으로 전달할 수 있어야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
|
||||||
|
initialProps: { value: undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.current).toBeUndefined();
|
||||||
|
|
||||||
|
rerender({ value: 'something' });
|
||||||
|
expect(result.current.current).toBeUndefined();
|
||||||
|
|
||||||
|
rerender({ value: undefined });
|
||||||
|
expect(result.current.current).toBe('something');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('0과 false를 정확히 추적해야 한다', () => {
|
||||||
|
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
|
||||||
|
initialProps: { value: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ value: false });
|
||||||
|
expect(result.current.current).toBe(0);
|
||||||
|
|
||||||
|
rerender({ value: 0 });
|
||||||
|
expect(result.current.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈 배열과 객체를 추적할 수 있어야 한다', () => {
|
||||||
|
const emptyArr = [];
|
||||||
|
const emptyObj = {};
|
||||||
|
|
||||||
|
const { result: arrResult, rerender: arrRerender } = renderHook(
|
||||||
|
({ arr }) => usePrevious(arr),
|
||||||
|
{ initialProps: { arr: emptyArr } }
|
||||||
|
);
|
||||||
|
|
||||||
|
arrRerender({ arr: [1, 2, 3] });
|
||||||
|
expect(arrResult.current.current).toEqual([]);
|
||||||
|
expect(arrResult.current.current).toBe(emptyArr);
|
||||||
|
|
||||||
|
const { result: objResult, rerender: objRerender } = renderHook(
|
||||||
|
({ obj }) => usePrevious(obj),
|
||||||
|
{ initialProps: { obj: emptyObj } }
|
||||||
|
);
|
||||||
|
|
||||||
|
objRerender({ obj: { name: 'test' } });
|
||||||
|
expect(objResult.current.current).toEqual({});
|
||||||
|
expect(objResult.current.current).toBe(emptyObj);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
245
com.twin.app.shoptime/src/hooks/usePreviousExample.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import usePrevious from './usePrevious';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePrevious 훅의 다양한 사용 예시를 보여주는 컴포넌트
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 예시 1: 단일 값 추적 - 카운터
|
||||||
|
export function CounterExample() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const prevCountRef = usePrevious(count);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', border: '1px solid #ccc', marginBottom: '20px' }}>
|
||||||
|
<h3>예시 1: 단일 값 추적 (카운터)</h3>
|
||||||
|
<p>현재값: {count}</p>
|
||||||
|
<p>이전값: {prevCountRef.current !== undefined ? prevCountRef.current : '초기값'}</p>
|
||||||
|
<button onClick={() => setCount(count + 1)}>증가</button>
|
||||||
|
<button onClick={() => setCount(count - 1)}>감소</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예시 2: 다중 값 추적 (객체) - 사용자 정보
|
||||||
|
export function UserFormExample() {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [age, setAge] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
|
const prevRef = usePrevious({ name, age, email });
|
||||||
|
const prev = prevRef.current;
|
||||||
|
|
||||||
|
const hasNameChanged = prev?.name !== name;
|
||||||
|
const hasAgeChanged = prev?.age !== age;
|
||||||
|
const hasEmailChanged = prev?.email !== email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', border: '1px solid #ccc', marginBottom: '20px' }}>
|
||||||
|
<h3>예시 2: 다중 값 추적 (객체) - 사용자 정보</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<label>
|
||||||
|
이름:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
style={{ marginLeft: '10px' }}
|
||||||
|
/>
|
||||||
|
{hasNameChanged && <span style={{ color: 'red', marginLeft: '10px' }}>변경됨</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<label>
|
||||||
|
나이:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={age}
|
||||||
|
onChange={(e) => setAge(e.target.value)}
|
||||||
|
style={{ marginLeft: '10px' }}
|
||||||
|
/>
|
||||||
|
{hasAgeChanged && <span style={{ color: 'red', marginLeft: '10px' }}>변경됨</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<label>
|
||||||
|
이메일:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
style={{ marginLeft: '10px' }}
|
||||||
|
/>
|
||||||
|
{hasEmailChanged && <span style={{ color: 'red', marginLeft: '10px' }}>변경됨</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '15px', padding: '10px', backgroundColor: '#f5f5f5' }}>
|
||||||
|
<h4>이전 값</h4>
|
||||||
|
<p>이름: {prev?.name || '(없음)'}</p>
|
||||||
|
<p>나이: {prev?.age || '(없음)'}</p>
|
||||||
|
<p>이메일: {prev?.email || '(없음)'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예시 3: 배열 값 추적 - 다중 숫자
|
||||||
|
export function ArrayValuesExample() {
|
||||||
|
const [a, setA] = useState(0);
|
||||||
|
const [b, setB] = useState(0);
|
||||||
|
const [c, setC] = useState(0);
|
||||||
|
|
||||||
|
const prevRef = usePrevious([a, b, c]);
|
||||||
|
const [prevA, prevB, prevC] = prevRef.current || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', border: '1px solid #ccc', marginBottom: '20px' }}>
|
||||||
|
<h3>예시 3: 배열 값 추적 - 다중 숫자</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
A:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={a}
|
||||||
|
onChange={(e) => setA(Number(e.target.value))}
|
||||||
|
style={{ marginLeft: '10px' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span style={{ marginLeft: '20px' }}>이전: {prevA !== undefined ? prevA : '초기값'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<label>
|
||||||
|
B:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={b}
|
||||||
|
onChange={(e) => setB(Number(e.target.value))}
|
||||||
|
style={{ marginLeft: '10px' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span style={{ marginLeft: '20px' }}>이전: {prevB !== undefined ? prevB : '초기값'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<label>
|
||||||
|
C:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={c}
|
||||||
|
onChange={(e) => setC(Number(e.target.value))}
|
||||||
|
style={{ marginLeft: '10px' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span style={{ marginLeft: '20px' }}>이전: {prevC !== undefined ? prevC : '초기값'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예시 4: 변동 감지 - 데이터 수정 여부 추적
|
||||||
|
export function ChangeDetectionExample() {
|
||||||
|
const [data, setData] = useState({
|
||||||
|
title: 'React Hook Guide',
|
||||||
|
description: 'usePrevious 훅 사용법',
|
||||||
|
views: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevDataRef = usePrevious(data);
|
||||||
|
const prevData = prevDataRef.current;
|
||||||
|
|
||||||
|
const changes = {
|
||||||
|
titleChanged: prevData?.title !== data.title,
|
||||||
|
descriptionChanged: prevData?.description !== data.description,
|
||||||
|
viewsChanged: prevData?.views !== data.views,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnyChanges = Object.values(changes).some((v) => v);
|
||||||
|
|
||||||
|
const handleUpdate = (field, value) => {
|
||||||
|
setData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', border: '1px solid #ccc', marginBottom: '20px' }}>
|
||||||
|
<h3>예시 4: 변동 감지 - 데이터 수정 여부 추적</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<label>
|
||||||
|
제목:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(e) => handleUpdate('title', e.target.value)}
|
||||||
|
style={{ marginLeft: '10px', width: '200px' }}
|
||||||
|
/>
|
||||||
|
{changes.titleChanged && (
|
||||||
|
<span style={{ color: 'orange', marginLeft: '10px' }}>수정됨</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<label>
|
||||||
|
설명:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.description}
|
||||||
|
onChange={(e) => handleUpdate('description', e.target.value)}
|
||||||
|
style={{ marginLeft: '10px', width: '200px' }}
|
||||||
|
/>
|
||||||
|
{changes.descriptionChanged && (
|
||||||
|
<span style={{ color: 'orange', marginLeft: '10px' }}>수정됨</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<label>
|
||||||
|
조회수:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={data.views}
|
||||||
|
onChange={(e) => handleUpdate('views', Number(e.target.value))}
|
||||||
|
style={{ marginLeft: '10px' }}
|
||||||
|
/>
|
||||||
|
{changes.viewsChanged && (
|
||||||
|
<span style={{ color: 'orange', marginLeft: '10px' }}>수정됨</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '10px', backgroundColor: hasAnyChanges ? '#fff3cd' : '#e8f5e9' }}>
|
||||||
|
<p style={{ margin: '0' }}>
|
||||||
|
<strong>
|
||||||
|
상태: {hasAnyChanges ? '데이터가 변경되었습니다' : '모든 데이터가 저장되었습니다'}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 예시 조합
|
||||||
|
export function UsePreviousExamples() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>
|
||||||
|
<h1>usePrevious 훅 사용 예시</h1>
|
||||||
|
<p>이전 값을 추적하는 다양한 방법을 보여주는 예시들입니다.</p>
|
||||||
|
|
||||||
|
<CounterExample />
|
||||||
|
<UserFormExample />
|
||||||
|
<ArrayValuesExample />
|
||||||
|
<ChangeDetectionExample />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsePreviousExamples;
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
startBannerVideo,
|
startVideoPlayerNew,
|
||||||
stopBannerVideo,
|
stopBannerVideo,
|
||||||
stopAndHideVideo,
|
stopAndHideVideo,
|
||||||
hidePlayerVideo,
|
hidePlayerVideo,
|
||||||
@@ -143,7 +143,14 @@ export const useVideoPlay = (options = {}) => {
|
|||||||
videoState.setCurrentPlaying(bannerId);
|
videoState.setCurrentPlaying(bannerId);
|
||||||
|
|
||||||
// Redux 액션 dispatch - bannerId를 modalContainerId로 사용
|
// Redux 액션 dispatch - bannerId를 modalContainerId로 사용
|
||||||
dispatch(startBannerVideo(bannerId, { modalContainerId: bannerId, force }));
|
dispatch(
|
||||||
|
startVideoPlayerNew({
|
||||||
|
bannerId,
|
||||||
|
modal: true,
|
||||||
|
modalContainerId: bannerId,
|
||||||
|
force,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// 성공 상태 업데이트
|
// 성공 상태 업데이트
|
||||||
setErrorCount(0); // 성공 시 오류 카운트 초기화
|
setErrorCount(0); // 성공 시 오류 카운트 초기화
|
||||||
@@ -334,6 +341,15 @@ export const useVideoPlay = (options = {}) => {
|
|||||||
const isPlaying = currentOwnerId && currentOwnerId.endsWith('_player');
|
const isPlaying = currentOwnerId && currentOwnerId.endsWith('_player');
|
||||||
const currentBanner = currentOwnerId ? currentOwnerId.replace('_player', '') : null;
|
const currentBanner = currentOwnerId ? currentOwnerId.replace('_player', '') : null;
|
||||||
|
|
||||||
|
// 🔽 [최적화] 배너 가용성 메모이제이션 (반복 계산 방지)
|
||||||
|
const bannerAvailability = useMemo(
|
||||||
|
() => ({
|
||||||
|
banner0: isBannerAvailable('banner0'),
|
||||||
|
banner1: isBannerAvailable('banner1'),
|
||||||
|
}),
|
||||||
|
[isBannerAvailable]
|
||||||
|
);
|
||||||
|
|
||||||
// 🔽 디버그 정보 (단순화)
|
// 🔽 디버그 정보 (단순화)
|
||||||
const getDebugInfo = useCallback(
|
const getDebugInfo = useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -342,12 +358,9 @@ export const useVideoPlay = (options = {}) => {
|
|||||||
isPlaying,
|
isPlaying,
|
||||||
errorCount,
|
errorCount,
|
||||||
videoState: videoState.getDebugInfo(),
|
videoState: videoState.getDebugInfo(),
|
||||||
bannerAvailability: {
|
bannerAvailability,
|
||||||
banner0: isBannerAvailable('banner0'),
|
|
||||||
banner1: isBannerAvailable('banner1'),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[currentOwnerId, currentBanner, isPlaying, errorCount, isBannerAvailable]
|
[currentOwnerId, currentBanner, isPlaying, errorCount, bannerAvailability]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -364,6 +377,7 @@ export const useVideoPlay = (options = {}) => {
|
|||||||
isPlaying,
|
isPlaying,
|
||||||
currentBanner,
|
currentBanner,
|
||||||
bannerVisibility,
|
bannerVisibility,
|
||||||
|
bannerAvailability, // ✅ [최적화] 메모이제이션된 배너 가용성
|
||||||
|
|
||||||
// 🔍 유틸리티
|
// 🔍 유틸리티
|
||||||
isBannerAvailable,
|
isBannerAvailable,
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ const useVideoMove = (options = {}) => {
|
|||||||
} else if (q[0] === 'icons') {
|
} else if (q[0] === 'icons') {
|
||||||
log('icons 케이스: 비디오 숨김 (소리 유지)');
|
log('icons 케이스: 비디오 숨김 (소리 유지)');
|
||||||
|
|
||||||
if (window.shrinkVideoTo1px) {
|
if (window.hideModalVideo) {
|
||||||
window.shrinkVideoTo1px();
|
window.hideModalVideo();
|
||||||
}
|
}
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ const useVideoMove = (options = {}) => {
|
|||||||
} else if (q[0] === 'icons') {
|
} else if (q[0] === 'icons') {
|
||||||
log('icons 케이스: 비디오 숨김 (소리 유지)');
|
log('icons 케이스: 비디오 숨김 (소리 유지)');
|
||||||
|
|
||||||
if (window.shrinkVideoTo1px) {
|
if (window.hideModalVideo) {
|
||||||
window.shrinkVideoTo1px();
|
window.hideModalVideo();
|
||||||
}
|
}
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
*
|
*
|
||||||
* Panel action (PUSH, POP, UPDATE, RESET)을 감지하고
|
* Panel action (PUSH, POP, UPDATE, RESET)을 감지하고
|
||||||
* 자동으로 panel history에 기록
|
* 자동으로 panel history에 기록
|
||||||
|
*
|
||||||
|
* ⚠️ [251122] DEBUG_MODE = false로 설정되어 모든 로그 출력 비활성화됨
|
||||||
|
* - 로그가 필요하면 DEBUG_MODE를 true로 변경하면 됨
|
||||||
|
* - middleware 동작 자체는 영향받지 않음 (로그만 출력 안됨)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { types } from '../actions/actionTypes';
|
import { types } from '../actions/actionTypes';
|
||||||
@@ -11,7 +15,8 @@ import { enqueuePanelHistory, clearPanelHistory } from '../actions/panelHistoryA
|
|||||||
import { calculateIsPanelOnTop } from '../utils/panelUtils'; // 🎯 isOnTop 유틸리티 함수 import
|
import { calculateIsPanelOnTop } from '../utils/panelUtils'; // 🎯 isOnTop 유틸리티 함수 import
|
||||||
|
|
||||||
// DEBUG_MODE - true인 경우에만 로그 출력
|
// DEBUG_MODE - true인 경우에만 로그 출력
|
||||||
const DEBUG_MODE = true;
|
// ⚠️ [251122] panelHistory 로그 비활성화 - 로그 생성 차단
|
||||||
|
const DEBUG_MODE = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Panel history middleware
|
* Panel history middleware
|
||||||
@@ -22,10 +27,11 @@ const DEBUG_MODE = true;
|
|||||||
*/
|
*/
|
||||||
export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
||||||
// 모든 PANEL 관련 액션 로깅
|
// 모든 PANEL 관련 액션 로깅
|
||||||
if (DEBUG_MODE && action.type && (
|
if (
|
||||||
action.type.includes('PANEL') ||
|
DEBUG_MODE &&
|
||||||
action.type === 'CLEAR_PANEL_HISTORY'
|
action.type &&
|
||||||
)) {
|
(action.type.includes('PANEL') || action.type === 'CLEAR_PANEL_HISTORY')
|
||||||
|
) {
|
||||||
const caller = new Error().stack.split('\n')[1]?.trim();
|
const caller = new Error().stack.split('\n')[1]?.trim();
|
||||||
console.log(`[PANEL DEBUG] ${action.type} from: ${caller}`);
|
console.log(`[PANEL DEBUG] ${action.type} from: ${caller}`);
|
||||||
console.log(' Payload:', action.payload);
|
console.log(' Payload:', action.payload);
|
||||||
@@ -38,8 +44,7 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
|
|
||||||
const stackLines = stack.split('\n');
|
const stackLines = stack.split('\n');
|
||||||
for (const line of stackLines) {
|
for (const line of stackLines) {
|
||||||
if (line.includes('TabLayout.jsx') ||
|
if (line.includes('TabLayout.jsx') || line.includes('TIconButton.jsx')) {
|
||||||
line.includes('TIconButton.jsx')) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,15 +80,36 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
if (panelName) {
|
if (panelName) {
|
||||||
const isGNB = isGNBCall();
|
const isGNB = isGNBCall();
|
||||||
const isOnTop = calculateIsOnTop(panelName); // 🎯 isOnTop 계산
|
const isOnTop = calculateIsOnTop(panelName); // 🎯 isOnTop 계산
|
||||||
if (DEBUG_MODE) console.log('[PANEL] PUSH_PANEL:', { panelName, panelInfo, isGNB, isOnTop, timestamp: new Date().toISOString() });
|
if (DEBUG_MODE)
|
||||||
store.dispatch(enqueuePanelHistory(panelName, panelInfo, 'PUSH', new Date().toISOString(), isGNB, false, isOnTop));
|
console.log('[PANEL] PUSH_PANEL:', {
|
||||||
|
panelName,
|
||||||
|
panelInfo,
|
||||||
|
isGNB,
|
||||||
|
isOnTop,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
store.dispatch(
|
||||||
|
enqueuePanelHistory(
|
||||||
|
panelName,
|
||||||
|
panelInfo,
|
||||||
|
'PUSH',
|
||||||
|
new Date().toISOString(),
|
||||||
|
isGNB,
|
||||||
|
false,
|
||||||
|
isOnTop
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// PanelHistory 상태 로그 (state 업데이트 후)
|
// PanelHistory 상태 로그 (state 업데이트 후)
|
||||||
const logPanelHistoryAfter = () => {
|
const logPanelHistoryAfter = () => {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
const stateAfter = store.getState();
|
const stateAfter = store.getState();
|
||||||
const panelHistoryAfter = stateAfter.panelHistory;
|
const panelHistoryAfter = stateAfter.panelHistory;
|
||||||
console.log('[PANEL_HISTORY] After PUSH_PANEL:', panelHistoryAfter);
|
const panelsAfter = stateAfter.panels.panels;
|
||||||
|
console.log('[PANEL_HISTORY] After PUSH_PANEL:', {
|
||||||
|
panelHistory: panelHistoryAfter,
|
||||||
|
panels: panelsAfter,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,15 +128,36 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
if (topPanel && topPanel.name) {
|
if (topPanel && topPanel.name) {
|
||||||
const isGNB = isGNBCall();
|
const isGNB = isGNBCall();
|
||||||
const isOnTop = calculateIsOnTop(topPanel.name); // 🎯 isOnTop 계산
|
const isOnTop = calculateIsOnTop(topPanel.name); // 🎯 isOnTop 계산
|
||||||
if (DEBUG_MODE) console.log('[PANEL] POP_PANEL:', { panelName: topPanel.name, panelInfo: topPanel.panelInfo || {}, isGNB, isOnTop, timestamp: new Date().toISOString() });
|
if (DEBUG_MODE)
|
||||||
store.dispatch(enqueuePanelHistory(topPanel.name, topPanel.panelInfo || {}, 'POP', new Date().toISOString(), isGNB, false, isOnTop));
|
console.log('[PANEL] POP_PANEL:', {
|
||||||
|
panelName: topPanel.name,
|
||||||
|
panelInfo: topPanel.panelInfo || {},
|
||||||
|
isGNB,
|
||||||
|
isOnTop,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
store.dispatch(
|
||||||
|
enqueuePanelHistory(
|
||||||
|
topPanel.name,
|
||||||
|
topPanel.panelInfo || {},
|
||||||
|
'POP',
|
||||||
|
new Date().toISOString(),
|
||||||
|
isGNB,
|
||||||
|
false,
|
||||||
|
isOnTop
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// PanelHistory 상태 로그 (state 업데이트 후)
|
// PanelHistory 상태 로그 (state 업데이트 후)
|
||||||
const logPanelHistoryAfter = () => {
|
const logPanelHistoryAfter = () => {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
const stateAfter = store.getState();
|
const stateAfter = store.getState();
|
||||||
const panelHistoryAfter = stateAfter.panelHistory;
|
const panelHistoryAfter = stateAfter.panelHistory;
|
||||||
console.log('[PANEL_HISTORY] After POP_PANEL:', panelHistoryAfter);
|
const panelsAfter = stateAfter.panels.panels;
|
||||||
|
console.log('[PANEL_HISTORY] After POP_PANEL:', {
|
||||||
|
panelHistory: panelHistoryAfter,
|
||||||
|
panels: panelsAfter,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,15 +176,36 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
if (panelName) {
|
if (panelName) {
|
||||||
const isGNB = isGNBCall();
|
const isGNB = isGNBCall();
|
||||||
const isOnTop = calculateIsOnTop(panelName); // 🎯 isOnTop 계산
|
const isOnTop = calculateIsOnTop(panelName); // 🎯 isOnTop 계산
|
||||||
if (DEBUG_MODE) console.log('[PANEL] UPDATE_PANEL:', { panelName, panelInfo, isGNB, isOnTop, timestamp: new Date().toISOString() });
|
if (DEBUG_MODE)
|
||||||
store.dispatch(enqueuePanelHistory(panelName, panelInfo, 'UPDATE', new Date().toISOString(), isGNB, false, isOnTop));
|
console.log('[PANEL] UPDATE_PANEL:', {
|
||||||
|
panelName,
|
||||||
|
panelInfo,
|
||||||
|
isGNB,
|
||||||
|
isOnTop,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
store.dispatch(
|
||||||
|
enqueuePanelHistory(
|
||||||
|
panelName,
|
||||||
|
panelInfo,
|
||||||
|
'UPDATE',
|
||||||
|
new Date().toISOString(),
|
||||||
|
isGNB,
|
||||||
|
false,
|
||||||
|
isOnTop
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// PanelHistory 상태 로그 (state 업데이트 후)
|
// PanelHistory 상태 로그 (state 업데이트 후)
|
||||||
const logPanelHistoryAfter = () => {
|
const logPanelHistoryAfter = () => {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
const stateAfter = store.getState();
|
const stateAfter = store.getState();
|
||||||
const panelHistoryAfter = stateAfter.panelHistory;
|
const panelHistoryAfter = stateAfter.panelHistory;
|
||||||
console.log('[PANEL_HISTORY] After UPDATE_PANEL:', panelHistoryAfter);
|
const panelsAfter = stateAfter.panels.panels;
|
||||||
|
console.log('[PANEL_HISTORY] After UPDATE_PANEL:', {
|
||||||
|
panelHistory: panelHistoryAfter,
|
||||||
|
panels: panelsAfter,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,11 +218,13 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
|
|
||||||
// RESET_PANELS: GNB 네비게이션 또는 완전 초기화
|
// RESET_PANELS: GNB 네비게이션 또는 완전 초기화
|
||||||
case types.RESET_PANELS: {
|
case types.RESET_PANELS: {
|
||||||
if (DEBUG_MODE) console.log('[PANEL] RESET_PANELS:', {
|
if (DEBUG_MODE)
|
||||||
|
console.log('[PANEL] RESET_PANELS:', {
|
||||||
payload: action.payload,
|
payload: action.payload,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
if (DEBUG_MODE) console.log('[PANEL_HISTORY] Before RESET_PANELS:', store.getState().panelHistory);
|
if (DEBUG_MODE)
|
||||||
|
console.log('[PANEL_HISTORY] Before RESET_PANELS:', store.getState().panelHistory);
|
||||||
|
|
||||||
// 모든 RESET_PANELS를 기록
|
// 모든 RESET_PANELS를 기록
|
||||||
const isGNB = isGNBCall();
|
const isGNB = isGNBCall();
|
||||||
@@ -164,9 +234,15 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
const firstPanel = action.payload[0];
|
const firstPanel = action.payload[0];
|
||||||
if (firstPanel && firstPanel.name) {
|
if (firstPanel && firstPanel.name) {
|
||||||
const isOnTop = calculateIsOnTop(firstPanel.name); // 🎯 isOnTop 계산
|
const isOnTop = calculateIsOnTop(firstPanel.name); // 🎯 isOnTop 계산
|
||||||
if (DEBUG_MODE) console.log('[PANEL_DEBUG] RESET_PANELS to:', firstPanel.name, { isGNB, isOnTop, fromResetPanel: true });
|
if (DEBUG_MODE)
|
||||||
|
console.log('[PANEL_DEBUG] RESET_PANELS to:', firstPanel.name, {
|
||||||
|
isGNB,
|
||||||
|
isOnTop,
|
||||||
|
fromResetPanel: true,
|
||||||
|
});
|
||||||
// RESET 이동을 히스토리에 기록
|
// RESET 이동을 히스토리에 기록
|
||||||
store.dispatch(enqueuePanelHistory(
|
store.dispatch(
|
||||||
|
enqueuePanelHistory(
|
||||||
firstPanel.name,
|
firstPanel.name,
|
||||||
firstPanel.panelInfo || {},
|
firstPanel.panelInfo || {},
|
||||||
'RESET',
|
'RESET',
|
||||||
@@ -174,17 +250,23 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
isGNB, // TabLayout/TIconButton이면 true
|
isGNB, // TabLayout/TIconButton이면 true
|
||||||
true, // fromResetPanel: 항상 true
|
true, // fromResetPanel: 항상 true
|
||||||
isOnTop // 🎯 isOnTop 정보 추가
|
isOnTop // 🎯 isOnTop 정보 추가
|
||||||
));
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 완전 초기화 (payload가 없는 경우 - HomePanel, 앱 초기화 등)
|
// 완전 초기화 (payload가 없는 경우 - HomePanel, 앱 초기화 등)
|
||||||
if (DEBUG_MODE) console.log('[PANEL_DEBUG] Complete panel history reset (payload empty)', { isGNB, fromResetPanel: true });
|
if (DEBUG_MODE)
|
||||||
|
console.log('[PANEL_DEBUG] Complete panel history reset (payload empty)', {
|
||||||
|
isGNB,
|
||||||
|
fromResetPanel: true,
|
||||||
|
});
|
||||||
store.dispatch(clearPanelHistory());
|
store.dispatch(clearPanelHistory());
|
||||||
|
|
||||||
// HomePanel 초기화 기록 (앱 시작 시)
|
// HomePanel 초기화 기록 (앱 시작 시)
|
||||||
if (DEBUG_MODE) console.log('[PANEL_DEBUG] Recording initial HomePanel');
|
if (DEBUG_MODE) console.log('[PANEL_DEBUG] Recording initial HomePanel');
|
||||||
const isOnTop = calculateIsOnTop('homepanel'); // 🎯 isOnTop 계산
|
const isOnTop = calculateIsOnTop('homepanel'); // 🎯 isOnTop 계산
|
||||||
store.dispatch(enqueuePanelHistory(
|
store.dispatch(
|
||||||
|
enqueuePanelHistory(
|
||||||
'homepanel',
|
'homepanel',
|
||||||
{},
|
{},
|
||||||
'APP_INITIALIZE',
|
'APP_INITIALIZE',
|
||||||
@@ -192,7 +274,8 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
isGNB, // TIconButton Home 버튼이면 true
|
isGNB, // TIconButton Home 버튼이면 true
|
||||||
true, // fromResetPanel: true
|
true, // fromResetPanel: true
|
||||||
isOnTop // 🎯 isOnTop 정보 추가
|
isOnTop // 🎯 isOnTop 정보 추가
|
||||||
));
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PanelHistory 상태 로그 (초기화 후)
|
// PanelHistory 상태 로그 (초기화 후)
|
||||||
@@ -200,7 +283,11 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
|
|||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
const stateAfter = store.getState();
|
const stateAfter = store.getState();
|
||||||
const panelHistoryAfter = stateAfter.panelHistory;
|
const panelHistoryAfter = stateAfter.panelHistory;
|
||||||
console.log('[PANEL_HISTORY] After RESET_PANELS:', panelHistoryAfter);
|
const panelsAfter = stateAfter.panels.panels;
|
||||||
|
console.log('[PANEL_HISTORY] After RESET_PANELS:', {
|
||||||
|
panelHistory: panelHistoryAfter,
|
||||||
|
panels: panelsAfter,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const initialState = {
|
|||||||
cartList: [],
|
cartList: [],
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
},
|
},
|
||||||
|
selectCart : {
|
||||||
|
cartList: [],
|
||||||
|
checkedItems: [], // ✅ 체크된 상품 정보 저장
|
||||||
|
totalCount: 0,
|
||||||
|
},
|
||||||
// 추가/수정/삭제 결과
|
// 추가/수정/삭제 결과
|
||||||
lastAction: null,
|
lastAction: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -35,13 +40,43 @@ export const cartReducer = (state = initialState, action) => {
|
|||||||
case types.INSERT_MY_INFO_CART:
|
case types.INSERT_MY_INFO_CART:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
getMyinfoCartSearch: {
|
selectCart: {
|
||||||
...state.getMyinfoCartSearch,
|
...state.selectCart,
|
||||||
cartList: [...state.getMyinfoCartSearch.cartList, action.payload],
|
cartList: [...state.selectCart.cartList, action.payload],
|
||||||
totalCount: (state.getMyinfoCartSearch.totalCount || 0) + 1,
|
totalCount: (state.getMyinfoCartSearch.totalCount || 0) + 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//체크박스 토글시 상품 처리
|
||||||
|
case types.TOGGLE_CHECK_CART: {
|
||||||
|
const checkedItem = action.payload.item;
|
||||||
|
const isChecked = action.payload.isChecked;
|
||||||
|
|
||||||
|
let updatedCheckedList = state.selectCart?.checkedItems || [];
|
||||||
|
|
||||||
|
if (isChecked) {
|
||||||
|
const itemExists = updatedCheckedList.some(
|
||||||
|
item => item.prodSno === checkedItem.prodSno
|
||||||
|
);
|
||||||
|
if (!itemExists) {
|
||||||
|
updatedCheckedList = [...updatedCheckedList, checkedItem];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatedCheckedList = updatedCheckedList.filter(
|
||||||
|
item => item.prodSno !== checkedItem.prodSno
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectCart: {
|
||||||
|
...state.selectCart,
|
||||||
|
checkedItems: updatedCheckedList,
|
||||||
|
totalCount: updatedCheckedList.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 장바구니에서 상품 삭제
|
// 장바구니에서 상품 삭제
|
||||||
case types.DELETE_MY_INFO_CART:
|
case types.DELETE_MY_INFO_CART:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const initialState = {
|
|||||||
termsIdMap: {}, // added new property to initialState
|
termsIdMap: {}, // added new property to initialState
|
||||||
optionalTermsAvailable: false, // 선택약관 존재 여부
|
optionalTermsAvailable: false, // 선택약관 존재 여부
|
||||||
persistentVideoInfo: null, // 영구재생 비디오 정보
|
persistentVideoInfo: null, // 영구재생 비디오 정보
|
||||||
|
videoTransitionLocked: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const homeReducer = (state = initialState, action) => {
|
export const homeReducer = (state = initialState, action) => {
|
||||||
@@ -192,6 +193,12 @@ export const homeReducer = (state = initialState, action) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case types.SET_VIDEO_TRANSITION_LOCK:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoTransitionLocked: action.payload,
|
||||||
|
};
|
||||||
|
|
||||||
case types.CHECK_ENTER_THROUGH_GNB: {
|
case types.CHECK_ENTER_THROUGH_GNB: {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const initialState = {
|
|||||||
// 기존 상태 - 완전히 호환됨
|
// 기존 상태 - 완전히 호환됨
|
||||||
panels: [],
|
panels: [],
|
||||||
lastPanelAction: '', //"", "push", "pop", "update", "reset", "previewPush", "previewPop", "previewUpdate"
|
lastPanelAction: '', //"", "push", "pop", "update", "reset", "previewPush", "previewPop", "previewUpdate"
|
||||||
|
lastFocusTarget: null, // [251114] 마지막 포커스 대상 (panelName + elementId)
|
||||||
|
|
||||||
// [251106] 패널 액션 큐 관련 상태 - 기존 기능에 전혀 영향 없음
|
// [251106] 패널 액션 큐 관련 상태 - 기존 기능에 전혀 영향 없음
|
||||||
panelActionQueue: [], // 처리 대기 중인 패널 액션 큐
|
panelActionQueue: [], // 처리 대기 중인 패널 액션 큐
|
||||||
@@ -13,7 +14,7 @@ const initialState = {
|
|||||||
queueStats: {
|
queueStats: {
|
||||||
totalProcessed: 0, // 총 처리된 액션 수
|
totalProcessed: 0, // 총 처리된 액션 수
|
||||||
failedCount: 0, // 실패한 액션 수
|
failedCount: 0, // 실패한 액션 수
|
||||||
averageProcessingTime: 0 // 평균 처리 시간
|
averageProcessingTime: 0, // 평균 처리 시간
|
||||||
},
|
},
|
||||||
|
|
||||||
// [251106] 비동기 액션 관련 상태
|
// [251106] 비동기 액션 관련 상태
|
||||||
@@ -30,7 +31,7 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
case types.PUSH_PANEL: {
|
case types.PUSH_PANEL: {
|
||||||
console.log('[panelReducer] 🔵 PUSH_PANEL START', {
|
console.log('[panelReducer] 🔵 PUSH_PANEL START', {
|
||||||
newPanelName: action.payload.name,
|
newPanelName: action.payload.name,
|
||||||
currentPanels: state.panels.map(p => p.name),
|
currentPanels: state.panels.map((p) => p.name),
|
||||||
duplicatable: action.duplicatable,
|
duplicatable: action.duplicatable,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[panelReducer] 🔵 PUSH_PANEL END', {
|
console.log('[panelReducer] 🔵 PUSH_PANEL END', {
|
||||||
resultPanels: newState.map(p => p.name),
|
resultPanels: newState.map((p) => p.name),
|
||||||
lastAction,
|
lastAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
case types.POP_PANEL: {
|
case types.POP_PANEL: {
|
||||||
console.log('[panelReducer] 🔴 POP_PANEL START', {
|
console.log('[panelReducer] 🔴 POP_PANEL START', {
|
||||||
targetPanel: action.payload || 'last_panel',
|
targetPanel: action.payload || 'last_panel',
|
||||||
currentPanels: state.panels.map(p => p.name),
|
currentPanels: state.panels.map((p) => p.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastAction = 'pop';
|
let lastAction = 'pop';
|
||||||
@@ -113,7 +114,7 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[panelReducer] 🔴 POP_PANEL END', {
|
console.log('[panelReducer] 🔴 POP_PANEL END', {
|
||||||
resultPanels: resultPanels.map(p => p.name),
|
resultPanels: resultPanels.map((p) => p.name),
|
||||||
lastAction,
|
lastAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,7 +160,7 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
}
|
}
|
||||||
case types.RESET_PANELS: {
|
case types.RESET_PANELS: {
|
||||||
console.log('[panelReducer] 🟢 RESET_PANELS START', {
|
console.log('[panelReducer] 🟢 RESET_PANELS START', {
|
||||||
currentPanels: state.panels.map(p => p.name),
|
currentPanels: state.panels.map((p) => p.name),
|
||||||
payloadProvided: !!action.payload,
|
payloadProvided: !!action.payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,7 +172,7 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
console.log('[panelReducer] 🟢 RESET_PANELS END', {
|
console.log('[panelReducer] 🟢 RESET_PANELS END', {
|
||||||
resultPanels: updatedPanels.map(p => p.name),
|
resultPanels: updatedPanels.map((p) => p.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -267,7 +268,8 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
const processingTime = Date.now() - startTime;
|
const processingTime = Date.now() - startTime;
|
||||||
const newTotalProcessed = state.queueStats.totalProcessed + 1;
|
const newTotalProcessed = state.queueStats.totalProcessed + 1;
|
||||||
const newAverageTime =
|
const newAverageTime =
|
||||||
(state.queueStats.averageProcessingTime * state.queueStats.totalProcessed + processingTime) /
|
(state.queueStats.averageProcessingTime * state.queueStats.totalProcessed +
|
||||||
|
processingTime) /
|
||||||
newTotalProcessed;
|
newTotalProcessed;
|
||||||
|
|
||||||
console.log('[panelReducer] ✅ QUEUE_ITEM_PROCESSED', {
|
console.log('[panelReducer] ✅ QUEUE_ITEM_PROCESSED', {
|
||||||
@@ -352,7 +354,7 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
...action.payload,
|
...action.payload,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
queueError: null, // 에러 초기화
|
queueError: null, // 에러 초기화
|
||||||
};
|
};
|
||||||
@@ -381,15 +383,18 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
result: action.payload.result,
|
result: action.payload.result,
|
||||||
executionTime,
|
executionTime,
|
||||||
completedAt: action.payload.timestamp,
|
completedAt: action.payload.timestamp,
|
||||||
}
|
},
|
||||||
].slice(-100), // 최근 100개만 유지
|
].slice(-100), // 최근 100개만 유지
|
||||||
queueError: null,
|
queueError: null,
|
||||||
queueStats: {
|
queueStats: {
|
||||||
...state.queueStats,
|
...state.queueStats,
|
||||||
totalProcessed: state.queueStats.totalProcessed + 1,
|
totalProcessed: state.queueStats.totalProcessed + 1,
|
||||||
averageProcessingTime: Math.round(
|
averageProcessingTime:
|
||||||
((state.queueStats.averageProcessingTime * state.queueStats.totalProcessed) + executionTime) /
|
Math.round(
|
||||||
(state.queueStats.totalProcessed + 1) * 100
|
((state.queueStats.averageProcessingTime * state.queueStats.totalProcessed +
|
||||||
|
executionTime) /
|
||||||
|
(state.queueStats.totalProcessed + 1)) *
|
||||||
|
100
|
||||||
) / 100,
|
) / 100,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -419,7 +424,7 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
error: action.payload.error,
|
error: action.payload.error,
|
||||||
executionTime,
|
executionTime,
|
||||||
failedAt: action.payload.timestamp,
|
failedAt: action.payload.timestamp,
|
||||||
}
|
},
|
||||||
].slice(-100), // 최근 100개만 유지
|
].slice(-100), // 최근 100개만 유지
|
||||||
queueError: {
|
queueError: {
|
||||||
actionId: action.payload.actionId,
|
actionId: action.payload.actionId,
|
||||||
@@ -433,6 +438,24 @@ export const panelsReducer = (state = initialState, action) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [251114] 명시적 포커스 이동
|
||||||
|
case types.FOCUS_PANEL: {
|
||||||
|
console.log('[panelReducer] 🎯 FOCUS_PANEL', {
|
||||||
|
panelName: action.payload.panelName,
|
||||||
|
focusTarget: action.payload.focusTarget,
|
||||||
|
timestamp: action.payload.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lastFocusTarget: {
|
||||||
|
panelName: action.payload.panelName,
|
||||||
|
focusTarget: action.payload.focusTarget,
|
||||||
|
timestamp: action.payload.timestamp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { types } from '../actions/actionTypes';
|
import { types } from '../actions/actionTypes';
|
||||||
|
import { PLAYBACK_STATUS, DISPLAY_STATUS } from '../actions/playActions';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
subTitleBlobs: {},
|
subTitleBlobs: {},
|
||||||
chatData: null,
|
chatData: null,
|
||||||
videoPlayState: {
|
videoPlayState: {
|
||||||
|
// 기존 상태들 유지
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
isPaused: true,
|
isPaused: true,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
|
|
||||||
|
// 🔽 [251116] 새로운 비디오 상태 관리 시스템
|
||||||
|
playback: PLAYBACK_STATUS.NOT_PLAYING, // 재생 상태
|
||||||
|
display: DISPLAY_STATUS.HIDDEN, // 화면 표시 상태
|
||||||
|
videoId: null, // 현재 비디오 ID
|
||||||
|
loadingProgress: 0, // 로딩 진행률 (0-100)
|
||||||
|
loadingError: null, // 로딩 에러 정보
|
||||||
|
lastUpdate: null, // 마지막 업데이트 시간
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,6 +69,193 @@ export const playReducer = (state = initialState, action) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔽 [251116] 새로운 비디오 상태 관리 시스템 - 재생 상태 액션들
|
||||||
|
case types.SET_PLAYBACK_LOADING: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
playback: PLAYBACK_STATUS.LOADING,
|
||||||
|
loadingProgress: 0,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_PLAYBACK_SUCCESS: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
playback: PLAYBACK_STATUS.LOAD_SUCCESS,
|
||||||
|
loadingProgress: 100,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_PLAYBACK_ERROR: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
playback: PLAYBACK_STATUS.LOAD_ERROR,
|
||||||
|
loadingProgress: 0,
|
||||||
|
loadingError: action.payload,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_PLAYBACK_PLAYING: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
playback: PLAYBACK_STATUS.PLAYING,
|
||||||
|
isPlaying: true,
|
||||||
|
isPaused: false,
|
||||||
|
loadingProgress: 100,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_PLAYBACK_NOT_PLAYING: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
playback: PLAYBACK_STATUS.NOT_PLAYING,
|
||||||
|
isPlaying: false,
|
||||||
|
isPaused: true,
|
||||||
|
loadingProgress: 0,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_PLAYBACK_BUFFERING: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
playback: PLAYBACK_STATUS.BUFFERING,
|
||||||
|
loadingError: null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔽 [251116] 새로운 비디오 상태 관리 시스템 - 화면 상태 액션들
|
||||||
|
case types.SET_DISPLAY_HIDDEN: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
display: DISPLAY_STATUS.HIDDEN,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_DISPLAY_VISIBLE: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
display: DISPLAY_STATUS.VISIBLE,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_DISPLAY_MINIMIZED: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
display: DISPLAY_STATUS.MINIMIZED,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_DISPLAY_FULLSCREEN: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
display: DISPLAY_STATUS.FULLSCREEN,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔽 [251116] 복합 상태 액션들
|
||||||
|
case types.SET_VIDEO_LOADING: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
...action.payload,
|
||||||
|
isPlaying: false,
|
||||||
|
isPaused: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_VIDEO_PLAYING: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
...action.payload,
|
||||||
|
isPlaying: true,
|
||||||
|
isPaused: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_VIDEO_STOPPED: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
...action.payload,
|
||||||
|
isPlaying: false,
|
||||||
|
isPaused: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_VIDEO_MINIMIZED_PLAYING: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
...action.payload,
|
||||||
|
isPlaying: true,
|
||||||
|
isPaused: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.SET_VIDEO_ERROR: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
videoPlayState: {
|
||||||
|
...state.videoPlayState,
|
||||||
|
...action.payload,
|
||||||
|
isPlaying: false,
|
||||||
|
isPaused: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const ACTIVE_POPUP = {
|
|||||||
toast: 'toast',
|
toast: 'toast',
|
||||||
optionalConfirm: 'optionalConfirm',
|
optionalConfirm: 'optionalConfirm',
|
||||||
energyPopup: 'energyPopup',
|
energyPopup: 'energyPopup',
|
||||||
|
addCartPopup: 'addCartPopup',
|
||||||
};
|
};
|
||||||
export const DEBUG_VIDEO_SUBTITLE_TEST = false;
|
export const DEBUG_VIDEO_SUBTITLE_TEST = false;
|
||||||
export const AUTO_SCROLL_DELAY = 600;
|
export const AUTO_SCROLL_DELAY = 600;
|
||||||
|
|||||||
126
com.twin.app.shoptime/src/utils/ImagePreloader.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 이미지 프리로더 유틸리티
|
||||||
|
* HomePanel에서 백그라운드로 DetailPanelBackground 이미지들을 미리 로드하여
|
||||||
|
* DetailPanel 진입 시 로딩 지연을 방지
|
||||||
|
*/
|
||||||
|
|
||||||
|
// DEBUG_MODE - true인 경우에만 로그 출력
|
||||||
|
const DEBUG_MODE = false;
|
||||||
|
|
||||||
|
class ImagePreloader {
|
||||||
|
constructor() {
|
||||||
|
this.cache = new Map(); // 로드된 이미지 캐시
|
||||||
|
this.loadPromises = new Map(); // 로딩 중인 Promise 관리
|
||||||
|
this.preloadStarted = false; // 프리로딩 시작 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 이미지 프리로드
|
||||||
|
* @param {string} src - 이미지 경로
|
||||||
|
* @returns {Promise<HTMLImageElement>}
|
||||||
|
*/
|
||||||
|
preloadImage(src) {
|
||||||
|
// 이미 캐시된 경우 즉시 반환
|
||||||
|
if (this.cache.has(src)) {
|
||||||
|
return Promise.resolve(this.cache.get(src));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 로딩 중인 Promise가 있으면 재사용
|
||||||
|
if (this.loadPromises.has(src)) {
|
||||||
|
return this.loadPromises.get(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 이미지 로드 Promise 생성
|
||||||
|
const promise = new Promise((resolve, reject) => {
|
||||||
|
const img = new window.Image(); // ESLint 해결을 위해 window.Image 사용
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
this.cache.set(src, img);
|
||||||
|
this.loadPromises.delete(src);
|
||||||
|
if (DEBUG_MODE) console.log(`[ImagePreloader] Image loaded: ${src}`);
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
this.loadPromises.delete(src);
|
||||||
|
if (DEBUG_MODE) console.error(`[ImagePreloader] Failed to load: ${src}`);
|
||||||
|
reject(new Error(`Failed to load image: ${src}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 로드 시작
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadPromises.set(src, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 이미지 한꺼번에 프리로드
|
||||||
|
* @param {Object} imageMap - { patnrId: imagePath } 형태의 맵
|
||||||
|
* @returns {Promise<Array>} - 로드 결과 배열
|
||||||
|
*/
|
||||||
|
preloadAllImages(imageMap) {
|
||||||
|
if (this.preloadStarted) {
|
||||||
|
if (DEBUG_MODE) console.log('[ImagePreloader] Preloading already started');
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.preloadStarted = true;
|
||||||
|
if (DEBUG_MODE) console.log('[ImagePreloader] Starting background preload...');
|
||||||
|
|
||||||
|
const promises = Object.values(imageMap).map((src) =>
|
||||||
|
this.preloadImage(src).catch((error) => {
|
||||||
|
// 개별 이미지 로드 실패 시 전체 작업을 중단하지 않음
|
||||||
|
if (DEBUG_MODE) console.warn('[ImagePreloader] Single image load failed:', error.message);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지가 로드되었는지 확인
|
||||||
|
* @param {string} src - 이미지 경로
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isLoaded(src) {
|
||||||
|
return this.cache.has(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시된 이미지 가져오기
|
||||||
|
* @param {string} src - 이미지 경로
|
||||||
|
* @returns {HTMLImageElement|null}
|
||||||
|
*/
|
||||||
|
getCachedImage(src) {
|
||||||
|
return this.cache.get(src) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 통계 정보
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
cached: this.cache.size,
|
||||||
|
loading: this.loadPromises.size,
|
||||||
|
preloadStarted: this.preloadStarted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 초기화 (테스트용)
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.cache.clear();
|
||||||
|
this.loadPromises.clear();
|
||||||
|
this.preloadStarted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 싱글톤 인스턴스 생성
|
||||||
|
const imagePreloader = new ImagePreloader();
|
||||||
|
|
||||||
|
export default imagePreloader;
|
||||||
@@ -1,59 +1,61 @@
|
|||||||
export const SpotlightIds = {
|
export const SpotlightIds = {
|
||||||
TPANEL: "tpanel",
|
TPANEL: 'tpanel',
|
||||||
TBODY: "tbody",
|
TBODY: 'tbody',
|
||||||
TPOPUP: "tpopup",
|
TPOPUP: 'tpopup',
|
||||||
HOME_TBODY: "home_tbody",
|
HOME_TBODY: 'home_tbody',
|
||||||
TITEM_CARD: "tItemCard",
|
TITEM_CARD: 'tItemCard',
|
||||||
TAB_LAYOUT: "tablayout",
|
TAB_LAYOUT: 'tablayout',
|
||||||
// homePanel
|
// homePanel
|
||||||
HOME_CATEGORY_NAV: "homeCategoryNav",
|
HOME_CATEGORY_NAV: 'homeCategoryNav',
|
||||||
|
|
||||||
// FeaturedBrandsPanel
|
// FeaturedBrandsPanel
|
||||||
BRAND_VERTICAL_PAGENATOR: "brandVerticalPagenator",
|
BRAND_VERTICAL_PAGENATOR: 'brandVerticalPagenator',
|
||||||
BRAND_QUICK_MENU: "brandQuickMenu",
|
BRAND_QUICK_MENU: 'brandQuickMenu',
|
||||||
BRAND_TOP_BUTTON: "brandTopButton",
|
BRAND_TOP_BUTTON: 'brandTopButton',
|
||||||
|
|
||||||
// TrendingNowPanel
|
// TrendingNowPanel
|
||||||
TRENDING_NOW_VERTICAL_PAGINATOR: "trendingNowVerticalPaginator",
|
TRENDING_NOW_VERTICAL_PAGINATOR: 'trendingNowVerticalPaginator',
|
||||||
TRENDING_NOW_POPULAR_SHOW: "trendingNowPopularShow",
|
TRENDING_NOW_POPULAR_SHOW: 'trendingNowPopularShow',
|
||||||
TRENDING_NOW_POPULAR_VIDEO: "trendingNowPopularVideo",
|
TRENDING_NOW_POPULAR_VIDEO: 'trendingNowPopularVideo',
|
||||||
TRENDING_NOW_POPULAR_GRID_LIST: "trendingNowVirtualGridList",
|
TRENDING_NOW_POPULAR_GRID_LIST: 'trendingNowVirtualGridList',
|
||||||
TRENDING_NOW_PREV_INDICATOR: "trendingNowPrevIndicator",
|
TRENDING_NOW_PREV_INDICATOR: 'trendingNowPrevIndicator',
|
||||||
TRENDING_NOW_NEXT_INDICATOR: "trendingNowNextIndicator",
|
TRENDING_NOW_NEXT_INDICATOR: 'trendingNowNextIndicator',
|
||||||
TRENDING_NOW_BEST_SELLER: "trendingNowBestSeller",
|
TRENDING_NOW_BEST_SELLER: 'trendingNowBestSeller',
|
||||||
TRENDING_NOW_TOP_BUTTON: "trendingNowTopButton",
|
TRENDING_NOW_TOP_BUTTON: 'trendingNowTopButton',
|
||||||
|
|
||||||
// myPagePanel
|
// myPagePanel
|
||||||
MY_PAGE_FAVORITES_BOX: "myPageFavoritesBox",
|
MY_PAGE_FAVORITES_BOX: 'myPageFavoritesBox',
|
||||||
MY_PAGE_REMINDRES_BOX: "myPageRemindresBox",
|
MY_PAGE_REMINDRES_BOX: 'myPageRemindresBox',
|
||||||
MY_PAGE_MY_ORDER_BOX: "myPageMyOrderBox",
|
MY_PAGE_MY_ORDER_BOX: 'myPageMyOrderBox',
|
||||||
MY_PAGE_MY_ORDER_TAB_CONTAINER: "myPageMyOrderTabContainer",
|
MY_PAGE_MY_ORDER_TAB_CONTAINER: 'myPageMyOrderTabContainer',
|
||||||
|
|
||||||
// categoryPanel
|
// categoryPanel
|
||||||
CATEGORY_CONTENTS_BOX: "categoryContentsBox",
|
CATEGORY_CONTENTS_BOX: 'categoryContentsBox',
|
||||||
CATEGORY_TAB_CONTAINER: "categorytabContainer",
|
CATEGORY_TAB_CONTAINER: 'categorytabContainer',
|
||||||
SHOW_PRODUCTS_BOX: "showProductsBox",
|
SHOW_PRODUCTS_BOX: 'showProductsBox',
|
||||||
SHOW_CONTENTS_BOX: "showContentsBox",
|
SHOW_CONTENTS_BOX: 'showContentsBox',
|
||||||
|
|
||||||
// video player
|
// video player
|
||||||
PLAYER_SKIPINTRO: "skipintro",
|
PLAYER_SKIPINTRO: 'skipintro',
|
||||||
PLAYER_TITLE_LAYER: "playerTitleLayer",
|
PLAYER_TITLE_LAYER: 'playerTitleLayer',
|
||||||
PLAYER_SLIDER: "playerslider",
|
PLAYER_SLIDER: 'playerslider',
|
||||||
PLAYER_TAB_BUTTON: "playerTabArrow",
|
PLAYER_TAB_BUTTON: 'playerTabArrow',
|
||||||
PLAYER_BACK_BUTTON: "player-back-button",
|
PLAYER_BACK_BUTTON: 'player-back-button',
|
||||||
PLAYER_SUBTITLE_BUTTON: "player-subtitlebutton",
|
PLAYER_SUBTITLE_BUTTON: 'player-subtitlebutton',
|
||||||
|
PLAYER_PLAY_BUTTON: 'player-play-button',
|
||||||
|
|
||||||
// searchPanel
|
// searchPanel
|
||||||
SEARCH_THEME: "search_theme",
|
SEARCH_THEME: 'search_theme',
|
||||||
SEARCH_SHOW: "search_show",
|
SEARCH_SHOW: 'search_show',
|
||||||
SEARCH_ITEM: "search_item",
|
SEARCH_ITEM: 'search_item',
|
||||||
SEARCH_BESTSELLER: "search_bestseller",
|
SEARCH_BESTSELLER: 'search_bestseller',
|
||||||
SEARCH_TAB_CONTAINER: "searchtabContainer",
|
SEARCH_TAB_CONTAINER: 'searchtabContainer',
|
||||||
|
|
||||||
// pin Code Popup
|
// pin Code Popup
|
||||||
PINCODE_CONTAINER: "pincodeContainer",
|
PINCODE_CONTAINER: 'pincodeContainer',
|
||||||
|
|
||||||
// detailPanel
|
// detailPanel
|
||||||
DETAIL_BUYNOW: "detail_buynow",
|
DETAIL_BUYNOW: 'detail_buynow',
|
||||||
DETAIL_SHOPBYMOBILE: "detail_shop_by_mobile",
|
DETAIL_SHOPBYMOBILE: 'detail_shop_by_mobile',
|
||||||
|
DETAIL_PRODUCTVIDEO: 'product-video-player',
|
||||||
};
|
};
|
||||||
|
|||||||
286
com.twin.app.shoptime/src/utils/focusPanelGuide.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* [251114] focusPanel 액션 사용 가이드
|
||||||
|
*
|
||||||
|
* Panel의 비동기 작업(useEffect, 타이머 등)이 포커스를 탈취하는 문제를 해결하기 위한
|
||||||
|
* 명시적 포커스 제어 시스템입니다.
|
||||||
|
*
|
||||||
|
* 문제 상황:
|
||||||
|
* - updatePanel 호출 → Panel의 useEffect 시작
|
||||||
|
* - pushPanel 호출 → 새 Panel에 포커스 설정
|
||||||
|
* - Panel의 useEffect 완료 → 기존 Panel이 포커스 탈취 (원하지 않는 동작)
|
||||||
|
*
|
||||||
|
* 해결책:
|
||||||
|
* - updatePanel, pushPanel 등으로 Panel을 제어할 때
|
||||||
|
* - focusPanel()로 명시적으로 포커스 대상을 지정
|
||||||
|
* - Panel의 useEffect는 더 이상 자동 포커스 이동 안 함
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { focusPanel } from '../actions/panelActions';
|
||||||
|
import { panel_names } from './Config';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 예시 1: 단일 Panel 포커스 제어 (가장 간단)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const example1_singlePanelFocus = (dispatch) => {
|
||||||
|
console.log('✅ 예시 1: 단일 Panel 포커스 제어');
|
||||||
|
|
||||||
|
// DETAIL_PANEL 표시 + 포커스 설정
|
||||||
|
dispatch(pushPanel({ name: panel_names.DETAIL_PANEL }));
|
||||||
|
dispatch(focusPanel(panel_names.DETAIL_PANEL, 'detail-buy-button'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 예시 2: 2중 구조 (PLAYER + DETAIL)에서 포커스 제어
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const example2_twoLayerFocus = (dispatch) => {
|
||||||
|
console.log('✅ 예시 2: 2중 구조 포커스 제어');
|
||||||
|
|
||||||
|
// 상황: [PLAYER_PANEL]이 이미 있는 상태
|
||||||
|
|
||||||
|
// 1단계: DETAIL_PANEL 추가
|
||||||
|
dispatch(pushPanel({ name: panel_names.DETAIL_PANEL }));
|
||||||
|
|
||||||
|
// 2단계: DETAIL_PANEL의 특정 요소에 포커스
|
||||||
|
dispatch(focusPanel(panel_names.DETAIL_PANEL, 'detail-buy-button'));
|
||||||
|
|
||||||
|
// 또는 PLAYER_PANEL의 플레이버튼에 포커스
|
||||||
|
// dispatch(focusPanel(panel_names.PLAYER_PANEL, 'player-play-button'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 예시 3: 3중 구조 (PLAYER + DETAIL + MEDIA)에서 포커스 제어
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const example3_threeLayerFocus = (dispatch) => {
|
||||||
|
console.log('✅ 예시 3: 3중 구조 포커스 제어');
|
||||||
|
|
||||||
|
// 상황: [PLAYER_PANEL, DETAIL_PANEL]이 이미 있는 상태
|
||||||
|
|
||||||
|
// 1단계: MEDIA_PANEL 추가 (modal=true)
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.MEDIA_PANEL,
|
||||||
|
panelInfo: { modal: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2단계: MEDIA_PANEL의 닫기 버튼에 포커스
|
||||||
|
dispatch(focusPanel(panel_names.MEDIA_PANEL, 'media-close-button'));
|
||||||
|
|
||||||
|
// 결과:
|
||||||
|
// - PLAYER_PANEL (백그라운드)
|
||||||
|
// - DETAIL_PANEL (중간, 보임)
|
||||||
|
// - MEDIA_PANEL (맨 위, 모달, 포커스 받음)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 예시 4: updatePanel과 함께 사용
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { updatePanel } from '../actions/panelActions';
|
||||||
|
|
||||||
|
export const example4_updateWithFocus = (dispatch) => {
|
||||||
|
console.log('✅ 예시 4: updatePanel과 focusPanel 함께 사용');
|
||||||
|
|
||||||
|
// ❌ 문제가 될 수 있는 방식:
|
||||||
|
// dispatch(updatePanel({
|
||||||
|
// name: 'DETAIL_PANEL',
|
||||||
|
// panelInfo: { productId: '123', isLoading: false }
|
||||||
|
// }));
|
||||||
|
// DETAIL_PANEL의 useEffect가 나중에 포커스를 탈취할 수 있음
|
||||||
|
|
||||||
|
// ✅ 올바른 방식:
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.DETAIL_PANEL,
|
||||||
|
panelInfo: { productId: '123', isLoading: false },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 명시적으로 포커스 지정
|
||||||
|
dispatch(focusPanel(panel_names.DETAIL_PANEL, 'detail-product-title'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 예시 5: API 호출 후 Panel 제어 및 포커스
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const example5_apiAndFocus = (dispatch, getState) => {
|
||||||
|
console.log('✅ 예시 5: API 호출 후 Panel 제어 및 포커스');
|
||||||
|
|
||||||
|
// API 호출 시뮬레이션
|
||||||
|
fetch('/api/products/123')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
// API 성공 → Panel 업데이트
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.DETAIL_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
product: data,
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 명시적으로 포커스 지정 (DETAIL_PANEL의 useEffect보다 먼저 실행되지만
|
||||||
|
// focusPanel은 비동기로 처리되므로 먼저 끝나더라도 안전)
|
||||||
|
dispatch(focusPanel(panel_names.DETAIL_PANEL, 'detail-add-to-cart-button'));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// API 실패 → 에러 Panel 표시
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.ERROR_PANEL,
|
||||||
|
panelInfo: { error: error.message },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 에러 Panel의 확인 버튼에 포커스
|
||||||
|
dispatch(focusPanel(panel_names.ERROR_PANEL, 'error-ok-button'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 예시 6: Queue와 함께 사용 (순서 보장)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { pushPanelQueued, updatePanelQueued } from '../actions/queuedPanelActions';
|
||||||
|
|
||||||
|
export const example6_queueWithFocus = (dispatch) => {
|
||||||
|
console.log('✅ 예시 6: Panel Queue와 focusPanel 함께 사용');
|
||||||
|
|
||||||
|
// Panel 제어는 Queue로 순서 보장
|
||||||
|
dispatch(
|
||||||
|
updatePanelQueued({
|
||||||
|
name: panel_names.DETAIL_PANEL,
|
||||||
|
panelInfo: { productId: '456', isLoading: false },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
pushPanelQueued({
|
||||||
|
name: panel_names.MEDIA_PANEL,
|
||||||
|
panelInfo: { modal: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 포커스는 명시적으로 지정
|
||||||
|
dispatch(focusPanel(panel_names.MEDIA_PANEL, 'media-close-button'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 예시 7: Panel에서 focusPanel 호출
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// DetailPanel.jsx에서:
|
||||||
|
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { focusPanel } from '../actions/panelActions';
|
||||||
|
import { panel_names } from '../utils/Config';
|
||||||
|
|
||||||
|
function DetailPanel({ panelInfo }) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const handleProductSelect = (productId) => {
|
||||||
|
// 선택한 상품으로 Panel 업데이트
|
||||||
|
dispatch(updatePanel({
|
||||||
|
name: panel_names.DETAIL_PANEL,
|
||||||
|
panelInfo: { productId }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 특정 요소에 포커스 (Panel의 useEffect가 포커스를 탈취하지 않음)
|
||||||
|
dispatch(focusPanel(
|
||||||
|
panel_names.DETAIL_PANEL,
|
||||||
|
'detail-product-description'
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => handleProductSelect('123')}>
|
||||||
|
Product 123
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 예시 8: 에러 처리
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const example8_errorHandling = (dispatch) => {
|
||||||
|
console.log('✅ 예시 8: 에러 처리');
|
||||||
|
|
||||||
|
// focusPanel은 안전성 체크를 수행합니다:
|
||||||
|
|
||||||
|
// 1. Panel이 존재하지 않으면:
|
||||||
|
// [focusPanel] ❌ Panel을 찾을 수 없음: INVALID_PANEL
|
||||||
|
|
||||||
|
// 2. 상위에 Modal이 있으면 (새로운 로직):
|
||||||
|
// [focusPanel] ⚠️ 상위에 Modal이 있음. DETAIL_PANEL(1층)에 포커스할 수 없음.
|
||||||
|
// 상단 Modal: ANOTHER_MODAL_PANEL(3층)
|
||||||
|
|
||||||
|
// 3. 요소를 찾을 수 없으면:
|
||||||
|
// [focusPanel] ❌ 요소를 찾을 수 없음: invalid-id
|
||||||
|
|
||||||
|
// 4. 요소가 숨겨져있으면:
|
||||||
|
// [focusPanel] ⚠️ 요소가 숨겨져있음: hidden-element
|
||||||
|
|
||||||
|
// ✅ 성공 로그 (새로 추가):
|
||||||
|
// [focusPanel] ✅ Panel 위치 확인: DETAIL_PANEL(1층), 전체 Panel: 3층
|
||||||
|
|
||||||
|
// 모든 경우에 console에 상세한 로그가 출력되므로 디버깅이 쉬움
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 핵심 원칙
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. updatePanel/pushPanel 호출 후 focusPanel 호출
|
||||||
|
dispatch(updatePanel(...));
|
||||||
|
dispatch(focusPanel(panelName, elementId));
|
||||||
|
|
||||||
|
2. Panel의 useEffect에서는 focusPanel을 호출하지 말 것
|
||||||
|
- Panel의 로직은 updatePanel의 panelInfo 변경만 감지
|
||||||
|
- 포커스 제어는 액션 호출처에서 명시적으로 지정
|
||||||
|
|
||||||
|
3. focusPanel은 매번 안전성을 체크함
|
||||||
|
- Panel 존재 여부
|
||||||
|
- Panel 레이어 위치 (최상단 또는 그 아래 패널만 허용)
|
||||||
|
- 상위 Modal 존재 여부 (상위에 Modal이 있으면 포커스 차단)
|
||||||
|
- 요소 존재 여부
|
||||||
|
- 요소 가시성
|
||||||
|
|
||||||
|
4. 2중/3중 구조에서도 안전함
|
||||||
|
- 최상단 또는 그 아래 레이어 패널에만 포커스 허용
|
||||||
|
- 상위에 Modal이 있는 경우에만 포커스 차단
|
||||||
|
- [PLAYER_PANEL, DETAIL_PANEL, MEDIA_PANEL] 구조에서:
|
||||||
|
• MEDIA_PANEL에 포커스: ✅ 가능 (최상단)
|
||||||
|
• DETAIL_PANEL에 포커스: ✅ 가능 (MEDIA_PANEL이 Modal이어도 하위 패널이므로 가능)
|
||||||
|
• PLAYER_PANEL에 포커스: ✅ 가능 (최하위 패널)
|
||||||
|
- 렌더링되지 않은 패널의 요소에 접근 불가
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Redux DevTools에서 확인
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
Redux DevTools를 열면:
|
||||||
|
|
||||||
|
FOCUS_PANEL 액션이 나타나고, payload에:
|
||||||
|
{
|
||||||
|
panelName: 'DETAIL_PANEL',
|
||||||
|
focusTarget: 'detail-buy-button',
|
||||||
|
timestamp: 1731014400000
|
||||||
|
}
|
||||||
|
|
||||||
|
state.panels.lastFocusTarget에도 같은 정보가 저장되어
|
||||||
|
마지막 포커스 상태를 추적할 수 있습니다.
|
||||||
|
*/
|
||||||
@@ -397,6 +397,11 @@ export const getFormattingDate = (dateString) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const removeSpecificTags = (html) => {
|
export const removeSpecificTags = (html) => {
|
||||||
|
// null 또는 undefined 체크
|
||||||
|
if (!html) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
const tagPatterns = [
|
const tagPatterns = [
|
||||||
/<a\b[^>]*>(.*?)<\/a>/gi,
|
/<a\b[^>]*>(.*?)<\/a>/gi,
|
||||||
/<script\b[^>]*>(.*?)<\/script>/gi,
|
/<script\b[^>]*>(.*?)<\/script>/gi,
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ export const isAuctionProduct = (product) => {
|
|||||||
*/
|
*/
|
||||||
export const normalizeProductDataForDisplay = (product) => {
|
export const normalizeProductDataForDisplay = (product) => {
|
||||||
// Mock Mode: product가 없어도 기본값으로 진행
|
// Mock Mode: product가 없어도 기본값으로 진행
|
||||||
console.log("###product 확인용", product)
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
console.log('[mockDataSafetyUtils] normalizeProductDataForDisplay - product is null/undefined, using defaults');
|
console.log('[mockDataSafetyUtils] normalizeProductDataForDisplay - product is null/undefined, using defaults');
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { panel_names } from './Config';
|
import { panel_names } from './Config';
|
||||||
|
|
||||||
// DEBUG_MODE - true인 경우에만 로그 출력
|
// DEBUG_MODE - true인 경우에만 로그 출력
|
||||||
const DEBUG_MODE = true;
|
const DEBUG_MODE = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🎯 [isOnTop 계산] MainView의 Panel Stack 기반으로 특정 패널의 isOnTop 상태 계산
|
* 🎯 [isOnTop 계산] MainView의 Panel Stack 기반으로 특정 패널의 isOnTop 상태 계산
|
||||||
@@ -48,7 +48,7 @@ export const calculateIsPanelOnTop = (panels, targetPanelName) => {
|
|||||||
renderingPanels = panels.slice(-3);
|
renderingPanels = panels.slice(-3);
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
console.log('[PanelUtils] 3-layer structure detected', {
|
console.log('[PanelUtils] 3-layer structure detected', {
|
||||||
renderingPanels: renderingPanels.map(p => p.name),
|
renderingPanels: renderingPanels.map((p) => p.name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
@@ -98,7 +98,7 @@ export const calculateIsPanelOnTop = (panels, targetPanelName) => {
|
|||||||
console.log('[PanelUtils] calculateIsPanelOnTop: Panel is on top', {
|
console.log('[PanelUtils] calculateIsPanelOnTop: Panel is on top', {
|
||||||
targetPanelName,
|
targetPanelName,
|
||||||
panelsLength: panels.length,
|
panelsLength: panels.length,
|
||||||
renderingPanels: renderingPanels.map(p => p.name),
|
renderingPanels: renderingPanels.map((p) => p.name),
|
||||||
foundAt: i,
|
foundAt: i,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ export const calculateIsPanelOnTop = (panels, targetPanelName) => {
|
|||||||
console.log('[PanelUtils] calculateIsPanelOnTop: Panel is NOT on top', {
|
console.log('[PanelUtils] calculateIsPanelOnTop: Panel is NOT on top', {
|
||||||
targetPanelName,
|
targetPanelName,
|
||||||
panelsLength: panels.length,
|
panelsLength: panels.length,
|
||||||
renderingPanels: renderingPanels.map(p => p.name),
|
renderingPanels: renderingPanels.map((p) => p.name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -212,7 +212,7 @@ export const getPanelStackDebugInfo = (panels, targetPanelName = null) => {
|
|||||||
topPanelName,
|
topPanelName,
|
||||||
|
|
||||||
// 렌더링 정보
|
// 렌더링 정보
|
||||||
renderingPanels: renderingInfo.renderingPanels.map(p => p.name),
|
renderingPanels: renderingInfo.renderingPanels.map((p) => p.name),
|
||||||
isThreeLayerStructure: renderingInfo.isThreeLayerStructure,
|
isThreeLayerStructure: renderingInfo.isThreeLayerStructure,
|
||||||
isTwoLayerModalStructure: renderingInfo.isTwoLayerModalStructure,
|
isTwoLayerModalStructure: renderingInfo.isTwoLayerModalStructure,
|
||||||
|
|
||||||
@@ -221,7 +221,8 @@ export const getPanelStackDebugInfo = (panels, targetPanelName = null) => {
|
|||||||
isTargetOnTop,
|
isTargetOnTop,
|
||||||
|
|
||||||
// 전체 패널 목록
|
// 전체 패널 목록
|
||||||
allPanels: panels?.map(p => ({
|
allPanels:
|
||||||
|
panels?.map((p) => ({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
hasModal: !!p.panelInfo?.modal,
|
hasModal: !!p.panelInfo?.modal,
|
||||||
})) || [],
|
})) || [],
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
} from 'react-redux';
|
} from 'react-redux';
|
||||||
|
|
||||||
import { getMyInfoCartSearch } from '../../actions/cartActions';
|
import { getMyInfoCartSearch } from '../../actions/cartActions';
|
||||||
|
import {
|
||||||
|
setHidePopup,
|
||||||
|
setShowPopup,
|
||||||
|
} from '../../actions/commonActions';
|
||||||
import {
|
import {
|
||||||
initializeMockCart,
|
initializeMockCart,
|
||||||
resetMockCart,
|
resetMockCart,
|
||||||
@@ -18,13 +22,16 @@ import {
|
|||||||
popPanel,
|
popPanel,
|
||||||
updatePanel,
|
updatePanel,
|
||||||
} from '../../actions/panelActions';
|
} from '../../actions/panelActions';
|
||||||
|
import { clearAllToasts } from '../../actions/toastActions';
|
||||||
import TBody from '../../components/TBody/TBody';
|
import TBody from '../../components/TBody/TBody';
|
||||||
import THeader from '../../components/THeader/THeader';
|
import THeader from '../../components/THeader/THeader';
|
||||||
import TPanel from '../../components/TPanel/TPanel';
|
import TPanel from '../../components/TPanel/TPanel';
|
||||||
|
import TPopUp from '../../components/TPopUp/TPopUp';
|
||||||
import TScroller from '../../components/TScroller/TScroller';
|
import TScroller from '../../components/TScroller/TScroller';
|
||||||
import useScrollTo from '../../hooks/useScrollTo';
|
import useScrollTo from '../../hooks/useScrollTo';
|
||||||
import { BUYNOW_CONFIG } from '../../utils/BuyNowConfig';
|
import { launchMembershipApp } from '../../lunaSend';
|
||||||
import * as Config from '../../utils/Config';
|
import * as Config from '../../utils/Config';
|
||||||
|
import { $L } from '../../utils/helperMethods';
|
||||||
import CartEmpty from './CartEmpty';
|
import CartEmpty from './CartEmpty';
|
||||||
import css from './CartPanel.module.less';
|
import css from './CartPanel.module.less';
|
||||||
import CartProductBar from './CartProductBar';
|
import CartProductBar from './CartProductBar';
|
||||||
@@ -44,6 +51,12 @@ export default function CartPanel({ spotlightId, scrollOptions = [], panelInfo }
|
|||||||
const { userNumber } = useSelector(
|
const { userNumber } = useSelector(
|
||||||
(state) => state.common.appStatus.loginUserData
|
(state) => state.common.appStatus.loginUserData
|
||||||
);
|
);
|
||||||
|
const { popupVisible, activePopup } = useSelector(
|
||||||
|
(state) => state.common.popup
|
||||||
|
);
|
||||||
|
const webOSVersion = useSelector(
|
||||||
|
(state) => state.common.appStatus.webOSVersion
|
||||||
|
);
|
||||||
|
|
||||||
// Mock Mode 여부 확인 및 적절한 데이터 선택
|
// Mock Mode 여부 확인 및 적절한 데이터 선택
|
||||||
// const isMockMode = BUYNOW_CONFIG.isMockMode();
|
// const isMockMode = BUYNOW_CONFIG.isMockMode();
|
||||||
@@ -166,6 +179,40 @@ export default function CartPanel({ spotlightId, scrollOptions = [], panelInfo }
|
|||||||
|
|
||||||
const { getScrollTo, scrollTop } = useScrollTo();
|
const { getScrollTo, scrollTop } = useScrollTo();
|
||||||
|
|
||||||
|
// 로그인 팝업 텍스트 로직 (SingleOption과 동일)
|
||||||
|
const loginPopupText = useMemo(() => {
|
||||||
|
return $L('Would you like to sign in?');
|
||||||
|
}, [userNumber]);
|
||||||
|
|
||||||
|
// 로그인 팝업 열기 핸들러 (SingleOption과 동일)
|
||||||
|
const handleLoginPopUpOpen = useCallback(() => {
|
||||||
|
if (!userNumber) {
|
||||||
|
if (webOSVersion >= '6.0') {
|
||||||
|
dispatch(setHidePopup());
|
||||||
|
dispatch(launchMembershipApp()); // 필요시 추가
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [dispatch, webOSVersion, userNumber]);
|
||||||
|
|
||||||
|
// 팝업 닫기 핸들러 (SingleOption과 동일)
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
dispatch(setHidePopup());
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
if(!userNumber || userNumber === ''){
|
||||||
|
dispatch(setShowPopup(Config.ACTIVE_POPUP.loginPopup));
|
||||||
|
}
|
||||||
|
},[userNumber, dispatch])
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
dispatch(clearAllToasts());
|
||||||
|
//혹시라도 넘어올 토스트 제거.
|
||||||
|
},[])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TPanel spotlightId={spotlightId} isTabActivated={true}>
|
<TPanel spotlightId={spotlightId} isTabActivated={true}>
|
||||||
<TBody className={css.tbody} cbScrollTo={getScrollTo}>
|
<TBody className={css.tbody} cbScrollTo={getScrollTo}>
|
||||||
@@ -182,7 +229,7 @@ export default function CartPanel({ spotlightId, scrollOptions = [], panelInfo }
|
|||||||
</div>
|
</div>
|
||||||
<div className={css.rightSection}>
|
<div className={css.rightSection}>
|
||||||
{/* 오른쪽 상품 영역 */}
|
{/* 오른쪽 상품 영역 */}
|
||||||
{displayCartData && displayCartData?.length > 0 ? (
|
{userNumber && displayCartData && displayCartData?.length > 0 ? (
|
||||||
<TScroller
|
<TScroller
|
||||||
className={css.tScroller}
|
className={css.tScroller}
|
||||||
{...scrollOptions}
|
{...scrollOptions}
|
||||||
@@ -191,6 +238,22 @@ export default function CartPanel({ spotlightId, scrollOptions = [], panelInfo }
|
|||||||
</TScroller>
|
</TScroller>
|
||||||
) : (
|
) : (
|
||||||
<CartEmpty />
|
<CartEmpty />
|
||||||
|
|
||||||
|
|
||||||
|
)}
|
||||||
|
{/* LOGIN POPUP */}
|
||||||
|
{(!userNumber || userNumber === '') && activePopup === Config.ACTIVE_POPUP.loginPopup && (
|
||||||
|
<TPopUp
|
||||||
|
kind="textPopup"
|
||||||
|
hasText
|
||||||
|
open={popupVisible}
|
||||||
|
text={loginPopupText}
|
||||||
|
hasButton
|
||||||
|
button1Text={$L('OK')}
|
||||||
|
button2Text={$L('CANCEL')}
|
||||||
|
onClick={handleLoginPopUpOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import defaultImage from '../../../assets/images/img-thumb-empty-144@3x.png';
|
|||||||
import {
|
import {
|
||||||
deleteMyinfoCart,
|
deleteMyinfoCart,
|
||||||
removeFromCart,
|
removeFromCart,
|
||||||
|
toggleCheckCart,
|
||||||
updateMyinfoCart,
|
updateMyinfoCart,
|
||||||
} from '../../actions/cartActions';
|
} from '../../actions/cartActions';
|
||||||
import {
|
import {
|
||||||
@@ -94,6 +95,7 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
|
|
||||||
// 항상 호출되어야 하는 Hook들
|
// 항상 호출되어야 하는 Hook들
|
||||||
const fallbackCartData = useSelector((state) => state.cart.getMyinfoCartSearch.cartInfo);
|
const fallbackCartData = useSelector((state) => state.cart.getMyinfoCartSearch.cartInfo);
|
||||||
|
const checkedItems = useSelector((state) => state.cart.selectCart.checkedItems || []); // ✅ Redux에서 체크된 아이템 가져오기
|
||||||
const selectedItems = useSelector((state) => state.mockCart.selectedItems || []);
|
const selectedItems = useSelector((state) => state.mockCart.selectedItems || []);
|
||||||
const userNumber = useSelector((state) => state.common.appStatus.loginUserData.userNumber);
|
const userNumber = useSelector((state) => state.common.appStatus.loginUserData.userNumber);
|
||||||
|
|
||||||
@@ -103,7 +105,6 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
// Mock Mode 확인
|
// Mock Mode 확인
|
||||||
const isMockMode = BUYNOW_CONFIG.isMockMode();
|
const isMockMode = BUYNOW_CONFIG.isMockMode();
|
||||||
|
|
||||||
//카트 데이타 그룹화 - 수정된 부분
|
|
||||||
const groupedCartData = useMemo(() => {
|
const groupedCartData = useMemo(() => {
|
||||||
if (!cartData || !Array.isArray(cartData)) return {};
|
if (!cartData || !Array.isArray(cartData)) return {};
|
||||||
|
|
||||||
@@ -111,7 +112,6 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
const groupKey = item.patncNm || item.patnrId || 'unknown';
|
const groupKey = item.patncNm || item.patnrId || 'unknown';
|
||||||
|
|
||||||
if (!acc[groupKey]) {
|
if (!acc[groupKey]) {
|
||||||
// 객체 구조로 초기화 (수정됨)
|
|
||||||
acc[groupKey] = {
|
acc[groupKey] = {
|
||||||
partnerInfo: {
|
partnerInfo: {
|
||||||
id: item.patnrId,
|
id: item.patnrId,
|
||||||
@@ -137,14 +137,15 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
// 파트너사별 총합 계산
|
// 파트너사별 총합 계산
|
||||||
const calculatePartnerTotal = (items) => {
|
const calculatePartnerTotal = (items) => {
|
||||||
const productTotal = items.reduce((sum, item) =>
|
const productTotal = items.reduce((sum, item) =>
|
||||||
sum + (parseFloat(Number(item.price3) !== 0 ? Number(item.price3) : Number(item.price2) !== 0 ? Number(item.price2) : 0) * item.prodQty), 0
|
sum + (Number(item.price3) !== 0 ? Number(item.price3) : Number(item.price2) !== 0 ? Number(item.price2) : 0) * item.prodQty, 0
|
||||||
);
|
);
|
||||||
const optionTotal = items.reduce((sum, item) =>
|
const optionTotal = items.reduce((sum, item) =>
|
||||||
sum + (parseFloat(Number(item.price5) !== 0 ? Number(item.price5) : Number(item.optPrc) !== 0 ? Number(item.optPrc) : 0) * item.prodQty), 0
|
//sum + (parseFloat(Number(item.price5) !== 0 ? Number(item.price5) : Number(item.optPrc) !== 0 ? Number(item.optPrc) : 0) * item.prodQty), 0
|
||||||
|
sum + (Number(item.optPrc) !== 0 && item.optPrc !== undefined) ? Number(item.optPrc) : 0 * item.prodQty, 0
|
||||||
);
|
);
|
||||||
|
|
||||||
const shippingTotal = items.reduce((sum, item) =>
|
const shippingTotal = items.reduce((sum, item) =>
|
||||||
sum + parseFloat((item.shippingCharge) * item.prodQty || 0), 0
|
sum + parseFloat(item.shippingCharge || 0), 0
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -176,7 +177,7 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dispatch, isMockMode]);
|
}, [dispatch, isMockMode, userNumber]);
|
||||||
|
|
||||||
const handleIncreseClick = useCallback((prodSno, patnrId, prdtId, currentQty) => {
|
const handleIncreseClick = useCallback((prodSno, patnrId, prdtId, currentQty) => {
|
||||||
const newQty = currentQty + 1;
|
const newQty = currentQty + 1;
|
||||||
@@ -190,7 +191,7 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
dispatch(updateMyinfoCart({ mbrNo: userNumber, patnrId, prdtId, prodSno, prodQty: newQty }));
|
dispatch(updateMyinfoCart({ mbrNo: userNumber, patnrId, prdtId, prodSno, prodQty: newQty }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dispatch, isMockMode]);
|
}, [dispatch, isMockMode, userNumber]);
|
||||||
|
|
||||||
// 상품 삭제 핸들러
|
// 상품 삭제 핸들러
|
||||||
const handleDeleteClick = useCallback((prodSno) => {
|
const handleDeleteClick = useCallback((prodSno) => {
|
||||||
@@ -205,46 +206,29 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
}
|
}
|
||||||
}, [dispatch, isMockMode]);
|
}, [dispatch, isMockMode]);
|
||||||
|
|
||||||
// 체크박스 선택 핸들러 (TCheckBoxSquare onToggle 형식에 맞춤) - debounce 적용
|
// 체크박스 선택 핸들러 - ✅ Redux 상태만 사용하도록 수정
|
||||||
const debouncedUpdateSelectedItems = useCallback(
|
const handleCheckboxToggle = useCallback((prodSno, fullItemData) => {
|
||||||
debounce((newSelectedItems) => {
|
|
||||||
dispatch(updateSelectedItems(newSelectedItems));
|
|
||||||
}, 100), // 100ms debounce
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCheckboxToggle = useCallback((prodSno) => {
|
|
||||||
return ({ selected: isChecked }) => {
|
return ({ selected: isChecked }) => {
|
||||||
const DEBUG_LOG = false; // 수동 설정: false로 비활성화
|
// Redux 상태에만 저장
|
||||||
|
dispatch(toggleCheckCart(fullItemData, isChecked));
|
||||||
if (DEBUG_LOG) {
|
|
||||||
console.log('[CartProduct] handleCheckboxToggle called - prodSno:', prodSno, 'selected:', isChecked);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMockMode) {
|
|
||||||
let newSelectedItems;
|
|
||||||
if (isChecked) {
|
|
||||||
// 상품 선택
|
|
||||||
newSelectedItems = [...selectedItems, prodSno];
|
|
||||||
} else {
|
|
||||||
// 상품 선택 해제
|
|
||||||
newSelectedItems = selectedItems.filter(id => id !== prodSno);
|
|
||||||
}
|
|
||||||
|
|
||||||
// debounced 호출 사용
|
|
||||||
debouncedUpdateSelectedItems(newSelectedItems);
|
|
||||||
|
|
||||||
if (DEBUG_LOG) {
|
|
||||||
console.log('[CartProduct] Checkbox toggled - prodSno:', prodSno, 'isChecked:', isChecked, 'selectedItems:', newSelectedItems);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [dispatch, isMockMode, selectedItems, debouncedUpdateSelectedItems]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 장바구니 삭제
|
||||||
|
const deleteCart = useCallback((patnrId, prdtId, prodSno, item) => {
|
||||||
|
dispatch(toggleCheckCart(item, false));
|
||||||
|
dispatch(deleteMyinfoCart({
|
||||||
|
mbrNo : userNumber,
|
||||||
|
patnrId: String(patnrId),
|
||||||
|
prdtId : String(prdtId),
|
||||||
|
prodSno : String(prodSno)
|
||||||
|
}))
|
||||||
|
},[dispatch, userNumber])
|
||||||
|
|
||||||
// 상품이 선택되었는지 확인
|
// 상품이 선택되었는지 확인
|
||||||
const isItemSelected = useCallback((prodSno) => {
|
const isItemSelected = useCallback((prodSno) => {
|
||||||
return selectedItems.includes(prodSno);
|
return checkedItems.some(item => item.prodSno === prodSno);
|
||||||
}, [selectedItems]);
|
}, [checkedItems]);
|
||||||
|
|
||||||
const handleFocus = useCallback((index, groupIndex) => {
|
const handleFocus = useCallback((index, groupIndex) => {
|
||||||
if(index === 0 && groupIndex === 0){
|
if(index === 0 && groupIndex === 0){
|
||||||
@@ -259,9 +243,7 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
andThen(() => document.getElementById(sectionId)),
|
andThen(() => document.getElementById(sectionId)),
|
||||||
when(isNil, () => null),
|
when(isNil, () => null),
|
||||||
andThen((targetElement) => {
|
andThen((targetElement) => {
|
||||||
// offsetTop: 부모 컨테이너 기준 절대 위치 사용
|
const y = 0;
|
||||||
// const y = targetElement.offsetTop;
|
|
||||||
const y = 0; //0으로 들어가는것이 깔끔.
|
|
||||||
return scrollTop({ y, animate: true });
|
return scrollTop({ y, animate: true });
|
||||||
})
|
})
|
||||||
)(sectionId)
|
)(sectionId)
|
||||||
@@ -269,15 +251,7 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
[scrollTop]
|
[scrollTop]
|
||||||
);
|
);
|
||||||
|
|
||||||
//장바구니 삭제
|
|
||||||
const deleteCart = useCallback((patnrId, prdtId, prodSno) => {
|
|
||||||
dispatch(deleteMyinfoCart({
|
|
||||||
mbrNo : userNumber,
|
|
||||||
patnrId: String(patnrId),
|
|
||||||
prdtId : String(prdtId),
|
|
||||||
prodSno : String(prodSno)
|
|
||||||
}))
|
|
||||||
},[dispatch])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -345,8 +319,8 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
const normalizedItem = normalizeProductDataForDisplay(item);
|
const normalizedItem = normalizeProductDataForDisplay(item);
|
||||||
|
|
||||||
// ✅ 이미지 우선순위: ProductAllSection 고품질 이미지 → 기타 모든 이미지 필드
|
// ✅ 이미지 우선순위: ProductAllSection 고품질 이미지 → 기타 모든 이미지 필드
|
||||||
const imageSrc = (item.imgUrls600 && item.imgUrls600[0]) || // ✅ ProductAllSection의 고품질 이미지
|
const imageSrc = (item.imgUrls600 && item.imgUrls600[0]) ||
|
||||||
(item.thumbnailUrl960) || // ✅ 960px 썸네일
|
(item.thumbnailUrl960) ||
|
||||||
(normalizedItem.imgUrls && normalizedItem.imgUrls[0]?.imgUrl) ||
|
(normalizedItem.imgUrls && normalizedItem.imgUrls[0]?.imgUrl) ||
|
||||||
(item.imgUrls && item.imgUrls[0]?.imgUrl) ||
|
(item.imgUrls && item.imgUrls[0]?.imgUrl) ||
|
||||||
(item.imgList && item.imgList[0]?.imgUrl) ||
|
(item.imgList && item.imgList[0]?.imgUrl) ||
|
||||||
@@ -362,8 +336,8 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
<TCheckBoxSquare
|
<TCheckBoxSquare
|
||||||
className={css.customeCheckbox}
|
className={css.customeCheckbox}
|
||||||
spotlightId={`productCheckbox-${item.prodSno}`}
|
spotlightId={`productCheckbox-${item.prodSno}`}
|
||||||
selected={isItemSelected(item.prodSno)}
|
selected={isItemSelected(item.prodSno)} // ✅ Redux 상태 기반
|
||||||
onToggle={handleCheckboxToggle(item.prodSno)}
|
onToggle={handleCheckboxToggle(item.prodSno, item)} // ✅ item 전체 데이터 전달
|
||||||
onFocus={()=> {handleFocus(index, groupIndex)}}
|
onFocus={()=> {handleFocus(index, groupIndex)}}
|
||||||
/>
|
/>
|
||||||
<span className={css.productId}>
|
<span className={css.productId}>
|
||||||
@@ -404,9 +378,9 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{item.price5 && parseFloat(item.price5) > 0 && (
|
{item.optPrc && parseFloat(item.optPrc) > 0 && parseFloat(item.optPrc) !== parseFloat(item.price2) && parseFloat(item.optPrc) !== parseFloat(item.price3) && (
|
||||||
<span className={css.optionAcc}>
|
<span className={css.optionAcc}>
|
||||||
OPTION : ${parseFloat(item.price5).toLocaleString('en-US', {
|
OPTION : ${parseFloat(item.optPrc).toLocaleString('en-US', {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2
|
maximumFractionDigits: 2
|
||||||
})}
|
})}
|
||||||
@@ -448,7 +422,7 @@ const CartProduct = ({ cartInfo, getScrollTo, scrollTop }) => {
|
|||||||
<TButton
|
<TButton
|
||||||
className={css.trashImg}
|
className={css.trashImg}
|
||||||
size="cartTrash"
|
size="cartTrash"
|
||||||
onClick={() => deleteCart(item.patnrId, item.prdtId, item.prodSno)}
|
onClick={() => deleteCart(item.patnrId, item.prdtId, item.prodSno, item)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ import {
|
|||||||
useSelector,
|
useSelector,
|
||||||
} from 'react-redux';
|
} from 'react-redux';
|
||||||
|
|
||||||
import { pushPanel } from '../../actions/panelActions';
|
import { getMyInfoCheckoutInfo } from '../../actions/checkoutActions';
|
||||||
|
import {
|
||||||
|
changeAppStatus,
|
||||||
|
setShowPopup,
|
||||||
|
showError,
|
||||||
|
} from '../../actions/commonActions';
|
||||||
|
import {
|
||||||
|
popPanel,
|
||||||
|
pushPanel,
|
||||||
|
} from '../../actions/panelActions';
|
||||||
import TButton from '../../components/TButton/TButton';
|
import TButton from '../../components/TButton/TButton';
|
||||||
import { BUYNOW_CONFIG } from '../../utils/BuyNowConfig';
|
import { BUYNOW_CONFIG } from '../../utils/BuyNowConfig';
|
||||||
import * as Config from '../../utils/Config';
|
import * as Config from '../../utils/Config';
|
||||||
@@ -26,129 +35,160 @@ const CartSidebar = ({ cartInfo }) => {
|
|||||||
|
|
||||||
// 실제 장바구니 데이터 (API 모드일 때만 사용)
|
// 실제 장바구니 데이터 (API 모드일 때만 사용)
|
||||||
const fallbackCartInfo = useSelector((state) => state.cart.getMyinfoCartSearch.cartInfo);
|
const fallbackCartInfo = useSelector((state) => state.cart.getMyinfoCartSearch.cartInfo);
|
||||||
const selectedItems = useSelector((state) => state.mockCart.selectedItems || []);
|
// ✅ Redux에서 체크된 상품 정보 가져오기
|
||||||
|
const checkedItems = useSelector((state) => state.cart.selectCart.checkedItems || []);
|
||||||
|
|
||||||
|
const { userNumber } = useSelector(
|
||||||
|
(state) => state.common.appStatus.loginUserData
|
||||||
|
);
|
||||||
|
|
||||||
// 사용할 장바구니 데이터 결정
|
// 사용할 장바구니 데이터 결정
|
||||||
const displayCartInfo = cartInfo || (isMockMode ? null : fallbackCartInfo);
|
const displayCartInfo = cartInfo || (isMockMode ? null : fallbackCartInfo);
|
||||||
|
|
||||||
// 선택된 상품들만 필터링 - 항상 선택된 상품들만 반환
|
// ✅ 계산할 상품 결정 (체크된 상품이 있으면 체크된 상품, 없으면 전체 상품)
|
||||||
const getSelectedItems = useCallback((items) => {
|
const itemsToCalculate = useMemo(() => {
|
||||||
const DEBUG_LOG = false; // 수동 설정: false로 비활성화
|
if (checkedItems && checkedItems.length > 0) {
|
||||||
|
// 체크된 상품이 있으면 체크된 상품만 사용
|
||||||
if (DEBUG_LOG) {
|
console.log('[CartSidebar] Using checked items for calculation:', checkedItems.length);
|
||||||
console.log('[CartSidebar] getSelectedItems called - isMockMode:', isMockMode, 'selectedItems:', selectedItems);
|
return checkedItems;
|
||||||
}
|
} else if (displayCartInfo && Array.isArray(displayCartInfo)) {
|
||||||
|
// 체크된 상품이 없으면 전체 장바구니 상품 사용
|
||||||
if (!items || !Array.isArray(items)) {
|
console.log('[CartSidebar] No checked items, using all cart items:', displayCartInfo.length);
|
||||||
if (DEBUG_LOG) {
|
return displayCartInfo;
|
||||||
console.log('[CartSidebar] No items provided, returning empty array');
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}, [checkedItems, displayCartInfo]);
|
||||||
|
|
||||||
if (!isMockMode) {
|
// checkOutValidate 콜백 함수 (SingleOption과 동일한 로직)
|
||||||
if (DEBUG_LOG) {
|
function checkOutValidate(response) {
|
||||||
console.log('[CartSidebar] API Mode - returning all items');
|
console.log('%c[BuyOption] 🔴 checkOutValidate CALLED', 'background: red; color: white; font-weight: bold; padding: 5px;', {
|
||||||
}
|
hasResponse: !!response,
|
||||||
return items;
|
retCode: response?.retCode,
|
||||||
}
|
retMsg: response?.retMsg,
|
||||||
|
hasCardInfo: !!response?.data?.cardInfo,
|
||||||
if (selectedItems.length === 0) {
|
hasBillingAddress: response?.data?.billingAddressList?.length > 0,
|
||||||
if (DEBUG_LOG) {
|
hasShippingAddress: response?.data?.shippingAddressList?.length > 0,
|
||||||
console.log('[CartSidebar] No items selected, returning empty array');
|
hasProductList: response?.data?.productList?.length > 0,
|
||||||
}
|
|
||||||
return []; // 선택된 상품이 없으면 빈 배열 반환
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = items.filter(item => {
|
|
||||||
const itemId = item.prodSno || item.cartId;
|
|
||||||
const isSelected = selectedItems.includes(itemId);
|
|
||||||
if (DEBUG_LOG) {
|
|
||||||
console.log('[CartSidebar] Item filter:', {
|
|
||||||
itemName: item.prdtNm,
|
|
||||||
itemId,
|
|
||||||
isSelected
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return isSelected;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (DEBUG_LOG) {
|
if (response) {
|
||||||
console.log('[CartSidebar] Filtered selected items:', filtered.length, 'out of', items.length);
|
if (response.retCode === 0) {
|
||||||
}
|
// 🔍 조건 체크
|
||||||
return filtered;
|
const isCardInfoNull = response.data.cardInfo === null;
|
||||||
}, [isMockMode, selectedItems]);
|
const isBillingAddressEmpty = response.data.billingAddressList.length === 0;
|
||||||
|
const isShippingAddressEmpty = response.data.shippingAddressList.length === 0;
|
||||||
|
|
||||||
// 개별 상품 가격 캐싱 (성능 최적화)
|
console.log('%c[BuyOption] 🔍 Address & Card Validation:', 'background: blue; color: white; font-weight: bold; padding: 5px;', {
|
||||||
const itemPriceCache = useMemo(() => {
|
isCardInfoNull,
|
||||||
const cache = new Map();
|
isBillingAddressEmpty,
|
||||||
|
isShippingAddressEmpty,
|
||||||
if (isMockMode && displayCartInfo) {
|
needsQRPopup: isCardInfoNull || isBillingAddressEmpty || isShippingAddressEmpty,
|
||||||
displayCartInfo.forEach(item => {
|
|
||||||
if (!cache.has(item.prodSno || item.cartId)) {
|
|
||||||
|
|
||||||
const orderSummary = calculateOrderSummaryFromProductInfo(item);
|
|
||||||
cache.set(item.prodSno || item.cartId, {
|
|
||||||
price: orderSummary.items,
|
|
||||||
coupon: 0,
|
|
||||||
shipping: orderSummary.shipping
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
isCardInfoNull ||
|
||||||
|
isBillingAddressEmpty ||
|
||||||
|
isShippingAddressEmpty
|
||||||
|
) {
|
||||||
|
console.log('%c[BuyOption] 🟡 Missing card/address - Showing QR Popup', 'background: orange; color: white; font-weight: bold; padding: 5px;');
|
||||||
|
dispatch(setShowPopup(Config.ACTIVE_POPUP.qrPopup));
|
||||||
|
dispatch(changeAppStatus({ isLoading: false }));
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log('%c[BuyOption] ✅ All address & card data present - Proceeding to CheckOutPanel', 'background: green; color: white; font-weight: bold; padding: 5px;');
|
||||||
|
const { mbrId, prdtId, prodSno } = response.data.productList[0];
|
||||||
|
const cartTpSno = `${mbrId}_${prdtId}_${prodSno}`;
|
||||||
|
|
||||||
|
|
||||||
|
// 🔴 CRITICAL: 기존 CheckOutPanel 있으면 제거 후 새로 push (API Mode)
|
||||||
|
dispatch((dispatchFn, getState) => {
|
||||||
|
const panels = getState().panels?.panels || [];
|
||||||
|
const checkoutPanelExists = panels.some(p => p.name === Config.panel_names.CHECKOUT_PANEL);
|
||||||
|
|
||||||
|
console.log('[BuyOption] 📊 API Mode - Current panels:', panels.map(p => p.name));
|
||||||
|
|
||||||
|
// 1️⃣ DetailPanel 제거 (STANDALONE_PANEL이므로 다른 패널 제거 필수)
|
||||||
|
console.log('[BuyOption] 🗑️ API Mode - Removing DetailPanel');
|
||||||
|
dispatchFn(popPanel(Config.panel_names.DETAIL_PANEL));
|
||||||
|
|
||||||
|
// 2️⃣ 기존 CheckOutPanel 제거 (있으면)
|
||||||
|
if (checkoutPanelExists) {
|
||||||
|
console.log('[BuyOption] 🗑️ API Mode - Removing existing CheckOutPanel');
|
||||||
|
dispatchFn(popPanel(Config.panel_names.CHECKOUT_PANEL));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3️⃣ 새로운 CheckOutPanel push
|
||||||
|
console.log('[BuyOption] ➕ API Mode - Pushing new CheckOutPanel');
|
||||||
|
dispatchFn(pushPanel({
|
||||||
|
name: Config.panel_names.CHECKOUT_PANEL,
|
||||||
|
// panelInfo: { logInfo: { ...logInfo, cartTpSno } },
|
||||||
|
}));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
} else if (response.retCode === 1001) {
|
||||||
|
dispatch(setShowPopup(Config.ACTIVE_POPUP.qrPopup));
|
||||||
|
dispatch(changeAppStatus({ isLoading: false }));
|
||||||
|
} else {
|
||||||
|
dispatch(
|
||||||
|
showError(
|
||||||
|
response.retCode,
|
||||||
|
response.retMsg,
|
||||||
|
false,
|
||||||
|
response.retDetailCode,
|
||||||
|
response.returnBindStrings
|
||||||
|
)
|
||||||
|
);
|
||||||
|
dispatch(changeAppStatus({ isLoading: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cache;
|
// ✅ Mock 데이터 또는 실제 데이터 계산 - 최적화 버전
|
||||||
}, [isMockMode, displayCartInfo]);
|
|
||||||
|
|
||||||
// Mock 데이터 또는 실제 데이터 계산 (선택된 상품만) - 최적화 버전
|
|
||||||
const calculatedData = useMemo(() => {
|
const calculatedData = useMemo(() => {
|
||||||
const DEBUG_LOG = false; // 수동 설정: false로 비활성화
|
const DEBUG_LOG = false;
|
||||||
|
|
||||||
if (isMockMode) {
|
if (itemsToCalculate && itemsToCalculate.length > 0) {
|
||||||
// Mock Mode: 선택된 상품들로 개별 가격 계산 (캐시 사용)
|
|
||||||
if (displayCartInfo && Array.isArray(displayCartInfo) && displayCartInfo.length > 0) {
|
|
||||||
const selectedCartItems = getSelectedItems(displayCartInfo);
|
|
||||||
|
|
||||||
if (DEBUG_LOG) {
|
|
||||||
console.log('[CartSidebar] Selected items for calculation:', selectedCartItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 캐시된 가격 정보 사용
|
|
||||||
let totalItems = 0;
|
let totalItems = 0;
|
||||||
let totalCoupon = 0;
|
let totalOption = 0;
|
||||||
let totalShipping = 0;
|
let totalShipping = 0;
|
||||||
let totalQuantity = 0;
|
let totalQuantity = 0;
|
||||||
|
|
||||||
selectedCartItems.forEach((item) => {
|
itemsToCalculate.forEach((item) => {
|
||||||
const itemId = item.prodSno || item.cartId;
|
// API 모드: 실제 가격 필드 사용
|
||||||
const cachedPrice = itemPriceCache.get(itemId);
|
const productPrice = parseFloat(Number(item.price3) !== 0 ? Number(item.price3) : Number(item.price2) !== 0 ? Number(item.price2) : 0);
|
||||||
|
const optionPrice = parseFloat(Number(item.price3) !== Number(item.optPrc) && (Number(item.price3) !== Number(item.optPrc)) ? Number(item.optPrc) : 0 || 0);
|
||||||
|
const shippingPrice = parseFloat(Number(item.shippingCharge) || 0);
|
||||||
|
|
||||||
if (cachedPrice) {
|
|
||||||
const qty = item.prodQty || item.qty || 1;
|
const qty = item.prodQty || item.qty || 1;
|
||||||
totalItems += cachedPrice.price * qty;
|
totalItems += productPrice * qty;
|
||||||
totalCoupon += cachedPrice.coupon * qty;
|
totalOption += optionPrice * qty;
|
||||||
totalShipping += cachedPrice.shipping * qty;
|
totalShipping += shippingPrice;
|
||||||
totalQuantity += qty;
|
totalQuantity += qty;
|
||||||
|
|
||||||
if (DEBUG_LOG) {
|
if (DEBUG_LOG) {
|
||||||
console.log('[CartSidebar] Item calculation (cached):', {
|
console.log('[CartSidebar] Item calculation:', {
|
||||||
name: item.prdtNm,
|
name: item.prdtNm,
|
||||||
qty,
|
qty,
|
||||||
itemPrice: cachedPrice.price,
|
itemPrice: productPrice,
|
||||||
itemCoupon: cachedPrice.coupon,
|
itemOption: optionPrice,
|
||||||
itemShipping: cachedPrice.shipping,
|
itemShipping: shippingPrice,
|
||||||
runningTotal: totalItems
|
runningTotal: totalItems
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const subtotal = Math.max(0, totalItems - totalCoupon + totalShipping);
|
const subtotal = Math.max(0, totalItems + totalOption + totalShipping);
|
||||||
|
|
||||||
if (DEBUG_LOG) {
|
if (DEBUG_LOG) {
|
||||||
console.log('[CartSidebar] Final calculation for selected items:', {
|
console.log('[CartSidebar] Final calculation:', {
|
||||||
|
isCheckedMode: checkedItems && checkedItems.length > 0,
|
||||||
|
itemCount: itemsToCalculate.length,
|
||||||
totalQuantity,
|
totalQuantity,
|
||||||
totalItems,
|
totalItems,
|
||||||
totalCoupon,
|
totalOption,
|
||||||
totalShipping,
|
totalShipping,
|
||||||
subtotal,
|
subtotal,
|
||||||
});
|
});
|
||||||
@@ -157,12 +197,12 @@ const CartSidebar = ({ cartInfo }) => {
|
|||||||
return {
|
return {
|
||||||
itemCount: totalQuantity,
|
itemCount: totalQuantity,
|
||||||
subtotal: totalItems,
|
subtotal: totalItems,
|
||||||
optionTotal: totalCoupon,
|
optionTotal: totalOption,
|
||||||
shippingHandling: totalShipping,
|
shippingHandling: totalShipping,
|
||||||
orderTotalBeforeTax: subtotal,
|
orderTotalBeforeTax: subtotal,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Mock Mode에 데이터가 없는 경우
|
// 상품이 없는 경우
|
||||||
return {
|
return {
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
subtotal: 0,
|
subtotal: 0,
|
||||||
@@ -171,90 +211,45 @@ const CartSidebar = ({ cartInfo }) => {
|
|||||||
orderTotalBeforeTax: 0,
|
orderTotalBeforeTax: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (displayCartInfo && Array.isArray(displayCartInfo) && displayCartInfo.length > 0) {
|
}, [itemsToCalculate, checkedItems]);
|
||||||
// API Mode: 실제 장바구니 데이터로 계산
|
|
||||||
const itemCount = displayCartInfo.reduce((sum, item) => sum + (item.prodQty || 1), 0);
|
|
||||||
const subtotal = displayCartInfo.reduce((sum, item) => {
|
|
||||||
const price = parseFloat(Number(item.price3) !== 0 ? Number(item.price3) : Number(item.price2) !== 0 ? Number(item.price2) : 0);
|
|
||||||
return sum + (price * (item.prodQty || 1));
|
|
||||||
}, 0);
|
|
||||||
const optionTotal = displayCartInfo.reduce((sum, item) => {
|
|
||||||
const optionPrice = parseFloat(Number(item.price5) || Number(item.optPrc) || 0);
|
|
||||||
return sum + (optionPrice * (item.prodQty || 1));
|
|
||||||
}, 0);
|
|
||||||
const shippingHandling = displayCartInfo.reduce((sum, item) => {
|
|
||||||
return sum + parseFloat(Number(item.shippingCharge) * (item.prodQty || 1))
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return {
|
// 체크아웃 버튼 클릭 핸들러
|
||||||
itemCount,
|
|
||||||
subtotal,
|
|
||||||
optionTotal,
|
|
||||||
shippingHandling,
|
|
||||||
orderTotalBeforeTax: subtotal + optionTotal + shippingHandling,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// 데이터가 없는 경우
|
|
||||||
return {
|
|
||||||
itemCount: 0,
|
|
||||||
subtotal: 0,
|
|
||||||
optionTotal: 0,
|
|
||||||
shippingHandling: 0,
|
|
||||||
orderTotalBeforeTax: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isMockMode, displayCartInfo, getSelectedItems, itemPriceCache]);
|
|
||||||
|
|
||||||
// 체크아웃 버튼 클릭 핸들러 - 완전한 데이터 전송
|
|
||||||
const handleCheckoutClick = useCallback(() => {
|
const handleCheckoutClick = useCallback(() => {
|
||||||
const DEBUG_LOG = true; // 수동 설정: true로 활성화 (디버깅용)
|
const DEBUG_LOG = true; // 수동 설정: true로 활성화 (디버깅용)
|
||||||
|
|
||||||
console.log('[CartSidebar] 🎯 Checkout button clicked! Starting checkout process...');
|
console.log('[CartSidebar] 🎯 Checkout button clicked! Starting checkout process...');
|
||||||
console.log('[CartSidebar] 📊 Current state:', {
|
console.log('[CartSidebar] 📊 Current state:', {
|
||||||
isMockMode,
|
isMockMode,
|
||||||
totalItems: cartInfo?.length || 0,
|
checkedItemsCount: checkedItems.length,
|
||||||
selectedItemsCount: selectedItems.length
|
itemsToCalculateCount: itemsToCalculate.length
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isMockMode) {
|
// ✅ 계산할 상품이 없는 경우
|
||||||
// Mock Mode: 선택된 상품들로 CheckOutPanel로 이동
|
if (itemsToCalculate.length === 0) {
|
||||||
if (DEBUG_LOG) {
|
console.log('[CartSidebar] ❌ No items to checkout');
|
||||||
console.log('[CartSidebar] Mock Mode - Going to Checkout');
|
return;
|
||||||
console.log('[CartSidebar] Mock Mode - cartInfo:', JSON.stringify(cartInfo));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 선택된 상품들만 필터링 (체크박스 선택된 상품)
|
|
||||||
const allCartItems = cartInfo && Array.isArray(cartInfo) ? cartInfo : [];
|
|
||||||
const selectedCartItems = getSelectedItems(allCartItems);
|
|
||||||
|
|
||||||
if (DEBUG_LOG) {
|
if (DEBUG_LOG) {
|
||||||
console.log('[CartSidebar] Mock Mode - All cart items:', allCartItems.length);
|
console.log('[CartSidebar] Items to checkout:', JSON.stringify(itemsToCalculate));
|
||||||
console.log('[CartSidebar] Mock Mode - Selected cart items:', selectedCartItems.length);
|
|
||||||
console.log('[CartSidebar] Mock Mode - Selected items:', JSON.stringify(selectedCartItems));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택된 상품이 없는 경우
|
// ✅ CheckOutPanel 전송용 데이터 구성
|
||||||
if (selectedCartItems.length === 0) {
|
const { itemCount, subtotal, optionTotal, shippingHandling, orderTotalBeforeTax } = calculatedData;
|
||||||
if (DEBUG_LOG) {
|
|
||||||
console.log('[CartSidebar] Mock Mode - No items selected, cannot proceed to checkout');
|
|
||||||
}
|
|
||||||
return; // 아무것도 하지 않음
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ CheckOutPanel 전송용 데이터 구성 (CheckOutPanel 기대 구조에 맞춤)
|
|
||||||
const checkoutData = {
|
const checkoutData = {
|
||||||
// 하위 호환성: 첫 번째 상품을 productInfo로
|
// 하위 호환성: 첫 번째 상품을 productInfo로
|
||||||
productInfo: selectedCartItems[0],
|
productInfo: itemsToCalculate[0],
|
||||||
|
|
||||||
// ✅ CheckOutPanel이 기대하는 cartItems 필드 (isFromCart=true일 때 사용)
|
// ✅ CheckOutPanel이 기대하는 cartItems 필드
|
||||||
cartItems: selectedCartItems.map(item => ({
|
cartItems: itemsToCalculate.map(item => ({
|
||||||
prdtId: item.prdtId,
|
prdtId: item.prdtId,
|
||||||
prdtNm: item.prdtNm,
|
prdtNm: item.prdtNm,
|
||||||
patnrId: item.patnrId,
|
patnrId: item.patnrId,
|
||||||
patncNm: item.patncNm,
|
patncNm: item.patncNm,
|
||||||
price2: item.price2, // 원가
|
price2: item.price2, // 원가
|
||||||
price3: item.price3, // 할인가
|
price3: item.price3, // 할인가
|
||||||
price5: item.price5, // 쿠폰
|
price5: item.price5, // 옵션가
|
||||||
prodQty: item.prodQty || item.qty || 1,
|
prodQty: item.prodQty || item.qty || 1,
|
||||||
optNm: item.optNm,
|
optNm: item.optNm,
|
||||||
shippingCharge: item.shippingCharge || '0',
|
shippingCharge: item.shippingCharge || '0',
|
||||||
@@ -266,104 +261,71 @@ const CartSidebar = ({ cartInfo }) => {
|
|||||||
// 이미지 정보
|
// 이미지 정보
|
||||||
imgUrl: item.imgUrl,
|
imgUrl: item.imgUrl,
|
||||||
thumbnailUrl: item.thumbnailUrl,
|
thumbnailUrl: item.thumbnailUrl,
|
||||||
imgUrls600: item.imgUrls600
|
imgUrls600: item.imgUrls600,
|
||||||
|
prodSno: item.prodSno,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// 메타데이터
|
// 메타데이터
|
||||||
isFromCart: true,
|
isFromCart: true,
|
||||||
fromCartPanel: true, // 추가 확인용
|
fromCartPanel: true,
|
||||||
|
isCheckedMode: checkedItems && checkedItems.length > 0, // ✅ 체크 모드 여부
|
||||||
cartTotal: orderTotalBeforeTax,
|
cartTotal: orderTotalBeforeTax,
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
subtotal: subtotal,
|
subtotal: subtotal,
|
||||||
optionTotal: optionTotal,
|
optionTotal: optionTotal,
|
||||||
shippingHandling: shippingHandling
|
shippingHandling: shippingHandling
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (DEBUG_LOG) {
|
if (DEBUG_LOG) {
|
||||||
console.log('%c🚨🚨🚨 CART SIDEBAR CHECKOUT DATA ANALYSIS 🚨🚨🚨', 'background: yellow; color: black; font-weight: bold; font-size: 14px; padding: 5px;');
|
console.log('%c🚨🚨🚨 CART SIDEBAR CHECKOUT DATA 🚨🚨🚨', 'background: yellow; color: black; font-weight: bold; font-size: 14px; padding: 5px;');
|
||||||
console.log('%cOriginal cartInfo:', 'background: yellow; color: black; padding: 3px;', cartInfo);
|
console.log('%cMode:', 'background: yellow; color: black; padding: 3px;', checkedItems.length > 0 ? 'CHECKED ITEMS MODE' : 'ALL ITEMS MODE');
|
||||||
console.log('%c🔍 ORIGINAL CART ITEM 1 PRICE FIELDS:', 'background: magenta; color: white; padding: 3px;');
|
console.log('%cItems to checkout:', 'background: yellow; color: black; padding: 3px;', itemsToCalculate);
|
||||||
console.log('price2:', cartInfo?.[0]?.price2);
|
|
||||||
console.log('price3:', cartInfo?.[0]?.price3);
|
|
||||||
console.log('price5:', cartInfo?.[0]?.price5);
|
|
||||||
console.log('optPrc:', cartInfo?.[0]?.optPrc);
|
|
||||||
console.log('finalPrice:', cartInfo?.[0]?.finalPrice);
|
|
||||||
console.log('discountPrice:', cartInfo?.[0]?.discountPrice);
|
|
||||||
console.log('price:', cartInfo?.[0]?.price);
|
|
||||||
console.log('origPrice:', cartInfo?.[0]?.origPrice);
|
|
||||||
console.log('Full first item:', cartInfo?.[0]);
|
|
||||||
console.log('%cSelected items array:', 'background: yellow; color: black; padding: 3px;', selectedItems);
|
|
||||||
console.log('%cSelected cart items (filtered):', 'background: yellow; color: black; padding: 3px;', selectedCartItems);
|
|
||||||
console.log('%cCheckout data prepared:', 'background: yellow; color: black; padding: 3px;', checkoutData);
|
console.log('%cCheckout data prepared:', 'background: yellow; color: black; padding: 3px;', checkoutData);
|
||||||
console.log('%cCartItems count:', 'background: yellow; color: black; padding: 3px;', checkoutData.cartItems.length);
|
console.log('%cCartItems count:', 'background: yellow; color: black; padding: 3px;', checkoutData.cartItems.length);
|
||||||
console.log('%cCartItems details:', 'background: yellow; color: black; padding: 3px;', checkoutData.cartItems);
|
console.log('%cCalculated totals:', 'background: yellow; color: black; padding: 3px;', {
|
||||||
console.log('%cisFromCart value:', 'background: yellow; color: black; padding: 3px;', checkoutData.isFromCart);
|
itemCount,
|
||||||
console.log('%cAbout to pushPanel to CHECKOUT_PANEL', 'background: yellow; color: black; padding: 3px;');
|
subtotal,
|
||||||
|
optionTotal,
|
||||||
|
shippingHandling,
|
||||||
|
orderTotalBeforeTax
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ CheckOutPanel로 이동
|
// ✅ CheckOutPanel로 이동
|
||||||
console.log('[CartSidebar] 🚀 Executing pushPanel to CHECKOUT_PANEL');
|
console.log('[CartSidebar] 🚀 Executing pushPanel to CHECKOUT_PANEL');
|
||||||
console.log('[CartSidebar] 📦 Checkout data summary:', {
|
|
||||||
itemCount: checkoutData.cartItems?.length || 0,
|
|
||||||
cartTotal: checkoutData.cartTotal,
|
|
||||||
hasProductInfo: !!checkoutData.productInfo,
|
|
||||||
isFromCart: checkoutData.isFromCart,
|
|
||||||
panelName: Config.panel_names.CHECKOUT_PANEL
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dispatch(pushPanel({
|
dispatch(
|
||||||
name: Config.panel_names.CHECKOUT_PANEL,
|
getMyInfoCheckoutInfo(
|
||||||
panelInfo: checkoutData
|
{
|
||||||
}));
|
mbrNo: userNumber,
|
||||||
|
dirPurcSelYn: 'Y',
|
||||||
|
cartList:
|
||||||
|
checkedItems && checkedItems.map((item) => ({
|
||||||
|
patnrId: item.patnrId,
|
||||||
|
prdtId: item.prdtId,
|
||||||
|
prodOptCdCval: item.prodOptCdCval ? item.prodOptCdCval : null,
|
||||||
|
prodQty: String(item.prodQty),
|
||||||
|
prodOptTpCdCval: item.prodOptTpCdCval,
|
||||||
|
}))
|
||||||
|
|
||||||
|
},
|
||||||
|
checkOutValidate
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// dispatch(pushPanel({
|
||||||
|
// name: Config.panel_names.CHECKOUT_PANEL,
|
||||||
|
// panelInfo: checkoutData
|
||||||
|
// }));
|
||||||
console.log('[CartSidebar] ✅ pushPanel executed successfully');
|
console.log('[CartSidebar] ✅ pushPanel executed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CartSidebar] ❌ pushPanel failed:', error);
|
console.error('[CartSidebar] ❌ pushPanel failed:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
}, [dispatch, checkedItems, itemsToCalculate, calculatedData]);
|
||||||
// API Mode: 실제 API 데이터로 처리
|
|
||||||
if (DEBUG_LOG) {
|
|
||||||
console.log('[CartSidebar] API Mode - Checkout (to be implemented)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Mode에서도 동일한 로직 적용
|
|
||||||
const allCartItems = cartInfo && Array.isArray(cartInfo) ? cartInfo : [];
|
|
||||||
const selectedCartItems = getSelectedItems(allCartItems);
|
|
||||||
|
|
||||||
if (selectedCartItems.length === 0) {
|
|
||||||
if (DEBUG_LOG) {
|
|
||||||
console.log('[CartSidebar] API Mode - No items selected, cannot proceed to checkout');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkoutData = {
|
|
||||||
productInfo: selectedCartItems[0],
|
|
||||||
cartItems: selectedCartItems.map(item => ({
|
|
||||||
prdtId: item.prdtId,
|
|
||||||
prdtNm: item.prdtNm,
|
|
||||||
patnrId: item.patnrId,
|
|
||||||
patncNm: item.patncNm,
|
|
||||||
price2: item.price2,
|
|
||||||
price3: item.price3,
|
|
||||||
price5: item.price5,
|
|
||||||
prodQty: item.prodQty || item.qty || 1,
|
|
||||||
optNm: item.optNm,
|
|
||||||
shippingCharge: item.shippingCharge || '0'
|
|
||||||
})),
|
|
||||||
isFromCart: true,
|
|
||||||
fromCartPanel: true, // 추가 확인용
|
|
||||||
cartTotal: orderTotalBeforeTax,
|
|
||||||
itemCount: itemCount
|
|
||||||
};
|
|
||||||
|
|
||||||
dispatch(pushPanel({
|
|
||||||
name: Config.panel_names.CHECKOUT_PANEL,
|
|
||||||
panelInfo: checkoutData
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [dispatch, isMockMode, cartInfo, getSelectedItems, orderTotalBeforeTax, itemCount, subtotal, optionTotal, shippingHandling]);
|
|
||||||
|
|
||||||
const { itemCount, subtotal, optionTotal, shippingHandling, orderTotalBeforeTax } = calculatedData;
|
const { itemCount, subtotal, optionTotal, shippingHandling, orderTotalBeforeTax } = calculatedData;
|
||||||
|
|
||||||
@@ -429,6 +391,7 @@ const CartSidebar = ({ cartInfo }) => {
|
|||||||
className={css.checkoutButton}
|
className={css.checkoutButton}
|
||||||
spotlightId="cart-checkout-button"
|
spotlightId="cart-checkout-button"
|
||||||
onClick={handleCheckoutClick}
|
onClick={handleCheckoutClick}
|
||||||
|
disabled={itemsToCalculate.length === 0}
|
||||||
>
|
>
|
||||||
Checkout
|
Checkout
|
||||||
</TButton>
|
</TButton>
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export default function CheckOutPanel({ panelInfo }) {
|
|||||||
const empTermsData = useSelector((state) => state.emp.empTermsData.terms);
|
const empTermsData = useSelector((state) => state.emp.empTermsData.terms);
|
||||||
const { popupVisible, activePopup } = useSelector((state) => state.common.popup);
|
const { popupVisible, activePopup } = useSelector((state) => state.common.popup);
|
||||||
const popup = useSelector((state) => state.common.popup);
|
const popup = useSelector((state) => state.common.popup);
|
||||||
|
const cartSelectInfo = useSelector((state) => state.cart.selectCart.checkedItems);
|
||||||
|
|
||||||
// Mock Mode: panelInfo.productInfo 또는 Redux에서 상품 데이터 사용
|
// Mock Mode: panelInfo.productInfo 또는 Redux에서 상품 데이터 사용
|
||||||
const productData = BUYNOW_CONFIG.isMockMode()
|
const productData = BUYNOW_CONFIG.isMockMode()
|
||||||
@@ -360,9 +361,10 @@ export default function CheckOutPanel({ panelInfo }) {
|
|||||||
getCheckoutTotalAmt(
|
getCheckoutTotalAmt(
|
||||||
{
|
{
|
||||||
mbrNo: userNumber,
|
mbrNo: userNumber,
|
||||||
dirPurcSelYn: 'Y',
|
dirPurcSelYn: cartSelectInfo.length > 0 ? 'N' : 'Y',
|
||||||
bilAddrSno: infoForCheckoutData.bilAddrSno,
|
bilAddrSno: infoForCheckoutData.bilAddrSno,
|
||||||
dlvrAddrSno: infoForCheckoutData.dlvrAddrSno,
|
dlvrAddrSno: infoForCheckoutData.dlvrAddrSno,
|
||||||
|
isPageLoading: "Y",
|
||||||
orderProductCoupontUse,
|
orderProductCoupontUse,
|
||||||
},
|
},
|
||||||
totalAmtValidate
|
totalAmtValidate
|
||||||
@@ -667,7 +669,7 @@ export default function CheckOutPanel({ panelInfo }) {
|
|||||||
closeSideBar={toggleOrderSideBar}
|
closeSideBar={toggleOrderSideBar}
|
||||||
productData={safeProductData}
|
productData={safeProductData}
|
||||||
rawProductData={productData}
|
rawProductData={productData}
|
||||||
productInfo={panelInfo?.productInfo}
|
productInfo={reduxProductData}
|
||||||
fromCartPanel={panelInfo?.fromCartPanel}
|
fromCartPanel={panelInfo?.fromCartPanel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export default function OrderItemsSideBar({
|
|||||||
patncLogPath={item.patncLogPath}
|
patncLogPath={item.patncLogPath}
|
||||||
prdtId={item.prdtId}
|
prdtId={item.prdtId}
|
||||||
expsPrdtNo={item.expsPrdtNo}
|
expsPrdtNo={item.expsPrdtNo}
|
||||||
price={item.price2 >= item.price3 ? item.price3 : item.price2}
|
price={(item.price2 >= item.price3 && item.price3 !== 0) ? item.price3 : item.price2}
|
||||||
currSign={item.currSign}
|
currSign={item.currSign}
|
||||||
currSignLoc={item.currSignLoc}
|
currSignLoc={item.currSignLoc}
|
||||||
shippingCharge={item.shippingCharge}
|
shippingCharge={item.shippingCharge}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function SummaryContainer({
|
|||||||
|
|
||||||
// Check if priceTotalData has actual data (ordPmtReqAmt is the key field)
|
// Check if priceTotalData has actual data (ordPmtReqAmt is the key field)
|
||||||
const hasValidPriceTotalData = priceTotalData && Object.keys(priceTotalData).length > 0 && priceTotalData.ordPmtReqAmt;
|
const hasValidPriceTotalData = priceTotalData && Object.keys(priceTotalData).length > 0 && priceTotalData.ordPmtReqAmt;
|
||||||
|
console.log("###hasValidPriceTotalData",hasValidPriceTotalData)
|
||||||
// Mock Mode: priceTotalData가 없으면 가짜 데이터 제공
|
// Mock Mode: priceTotalData가 없으면 가짜 데이터 제공
|
||||||
const effectivePriceTotalData = hasValidPriceTotalData ? priceTotalData : {
|
const effectivePriceTotalData = hasValidPriceTotalData ? priceTotalData : {
|
||||||
totProdPrc: 0.00,
|
totProdPrc: 0.00,
|
||||||
|
|||||||
@@ -1,40 +1,24 @@
|
|||||||
// src/views/DetailPanel/DetailPanel.new.jsx
|
// src/views/DetailPanel/DetailPanel.new.jsx
|
||||||
import React, {
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import {
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
useDispatch,
|
|
||||||
useSelector,
|
|
||||||
} from 'react-redux';
|
|
||||||
|
|
||||||
import Spotlight from '@enact/spotlight';
|
import Spotlight from '@enact/spotlight';
|
||||||
import { setContainerLastFocusedElement } from '@enact/spotlight/src/container';
|
import { setContainerLastFocusedElement } from '@enact/spotlight/src/container';
|
||||||
|
|
||||||
import { getDeviceAdditionInfo } from '../../actions/deviceActions';
|
import { getDeviceAdditionInfo } from '../../actions/deviceActions';
|
||||||
import { getThemeCurationDetailInfo } from '../../actions/homeActions';
|
import { getThemeCurationDetailInfo, updateHomeInfo } from '../../actions/homeActions';
|
||||||
import {
|
import { getMainCategoryDetail, getMainYouMayLike } from '../../actions/mainActions';
|
||||||
getMainCategoryDetail,
|
import { finishModalMediaForce } from '../../actions/mediaActions';
|
||||||
getMainYouMayLike,
|
import { popPanel, updatePanel } from '../../actions/panelActions';
|
||||||
} from '../../actions/mainActions';
|
|
||||||
import {
|
|
||||||
popPanel,
|
|
||||||
updatePanel,
|
|
||||||
} from '../../actions/panelActions';
|
|
||||||
import {
|
import {
|
||||||
finishVideoPreview,
|
finishVideoPreview,
|
||||||
pauseFullscreenVideo,
|
pauseFullscreenVideo,
|
||||||
resumeFullscreenVideo,
|
resumeFullscreenVideo,
|
||||||
|
pauseModalVideo,
|
||||||
|
resumeModalVideo,
|
||||||
} from '../../actions/playActions';
|
} from '../../actions/playActions';
|
||||||
import {
|
import { clearProductDetail, getProductOptionId } from '../../actions/productActions';
|
||||||
clearProductDetail,
|
|
||||||
getProductOptionId,
|
|
||||||
} from '../../actions/productActions';
|
|
||||||
import { clearAllToasts } from '../../actions/toastActions';
|
import { clearAllToasts } from '../../actions/toastActions';
|
||||||
import TBody from '../../components/TBody/TBody';
|
import TBody from '../../components/TBody/TBody';
|
||||||
import TPanel from '../../components/TPanel/TPanel';
|
import TPanel from '../../components/TPanel/TPanel';
|
||||||
@@ -96,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]
|
||||||
@@ -141,21 +148,144 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
Spotlight.focus('spotlightId_backBtn');
|
Spotlight.focus('spotlightId_backBtn');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onClick = useCallback(
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dispatch(finishModalMediaForce());
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ✅ DetailPanelBackground 이미지 완전 렌더링 후 그라데이션 배경 숨기기
|
||||||
|
const handleBackgroundImageReady = useCallback(() => {
|
||||||
|
// console.log('[TRACE-GRADIENT] ✅ DetailPanel - BackgroundImage fully rendered, hiding gradient');
|
||||||
|
dispatch(
|
||||||
|
updateHomeInfo({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
showGradientBackground: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ✅ [251120] DetailPanel이 사라질 때 처리 - sourcePanel에 따라 switch 문으로 처리
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const sourcePanel = panelInfo?.sourcePanel;
|
||||||
|
const sourceMenu = panelInfo?.sourceMenu;
|
||||||
|
|
||||||
|
// DetailPanel이 unmount되는 시점
|
||||||
|
console.log('[DetailPanel] unmount:', {
|
||||||
|
sourcePanel,
|
||||||
|
sourceMenu,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// sourcePanel에 따른 상태 업데이트
|
||||||
|
switch (sourcePanel) {
|
||||||
|
case panel_names.PLAYER_PANEL: {
|
||||||
|
// PlayerPanel에서 온 경우: PlayerPanel에 detailPanelClosed flag 전달
|
||||||
|
console.log('[DetailPanel] unmount - PlayerPanel에 detailPanelClosed flag 전달');
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.PLAYER_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
detailPanelClosed: true, // ✅ flag
|
||||||
|
detailPanelClosedAt: Date.now(), // ✅ 시점 기록
|
||||||
|
detailPanelClosedFromSource: sourceMenu, // ✅ 출처
|
||||||
|
lastFocusedTargetId: panelInfo?.lastFocusedTargetId, // ✅ 포커스 복원 타겟 전달
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case panel_names.HOME_PANEL: {
|
||||||
|
// HomePanel에서 온 경우: HomePanel에 detailPanelClosed flag 전달
|
||||||
|
// console.log('[DetailPanel] unmount - HomePanel에 detailPanelClosed flag 전달');
|
||||||
|
// console.log('[TRACE-GRADIENT] 🔶 DetailPanel unmount - HomePanel 복귀');
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateHomeInfo({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
detailPanelClosed: true, // ✅ flag
|
||||||
|
detailPanelClosedAt: Date.now(), // ✅ 시점 기록
|
||||||
|
detailPanelClosedFromSource: sourceMenu, // ✅ 출처
|
||||||
|
showGradientBackground: false, // ✅ 명시적으로 그라데이션 끔기
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case panel_names.SEARCH_PANEL: {
|
||||||
|
// SearchPanel에서 온 경우: SearchPanel에 detailPanelClosed flag 전달
|
||||||
|
console.log('[DetailPanel] unmount - SearchPanel에 detailPanelClosed flag 전달');
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.SEARCH_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
detailPanelClosed: true, // ✅ flag
|
||||||
|
detailPanelClosedAt: Date.now(), // ✅ 시점 기록
|
||||||
|
detailPanelClosedFromSource: sourceMenu, // ✅ 출처
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('[DetailPanel] unmount - 처리되지 않은 sourcePanel:', sourcePanel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [dispatch, panelInfo?.sourcePanel]);
|
||||||
|
|
||||||
|
const onBackClick = useCallback(
|
||||||
(isCancelClick) => (ev) => {
|
(isCancelClick) => (ev) => {
|
||||||
|
const sourcePanel = panelInfo?.sourcePanel;
|
||||||
|
const sourceMenu = panelInfo?.sourceMenu;
|
||||||
|
|
||||||
fp.pipe(
|
fp.pipe(
|
||||||
() => {
|
() => {
|
||||||
dispatch(clearAllToasts()); // BuyOption Toast 포함 모든 토스트 제거
|
dispatch(clearAllToasts()); // BuyOption Toast 포함 모든 토스트 제거
|
||||||
|
|
||||||
|
// sourcePanel에 따른 사전 처리
|
||||||
|
switch (sourcePanel) {
|
||||||
|
case panel_names.PLAYER_PANEL:
|
||||||
|
// PlayerPanel에서 온 경우: 플레이어 비디오는 그대로 두고 모달만 정리
|
||||||
|
console.log('[DetailPanel] onBackClick - PlayerPanel 출신: 모달 정리만 수행');
|
||||||
|
dispatch(finishModalMediaForce()); // MEDIA_PANEL(ProductVideo) 강제 종료
|
||||||
dispatch(finishVideoPreview());
|
dispatch(finishVideoPreview());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case panel_names.HOME_PANEL:
|
||||||
|
case panel_names.SEARCH_PANEL:
|
||||||
|
default:
|
||||||
|
// HomePanel, SearchPanel 등에서 온 경우: 백그라운드 비디오 일시 중지
|
||||||
|
console.log(
|
||||||
|
'[DetailPanel] onBackClick - source panel:',
|
||||||
|
sourcePanel,
|
||||||
|
'백그라운드 비디오 일시 중지'
|
||||||
|
);
|
||||||
|
dispatch(pauseFullscreenVideo()); // PLAYER_PANEL 비디오 중지
|
||||||
|
dispatch(finishModalMediaForce()); // MEDIA_PANEL(ProductVideo) 강제 종료
|
||||||
|
dispatch(finishVideoPreview());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(popPanel(panel_names.DETAIL_PANEL));
|
dispatch(popPanel(panel_names.DETAIL_PANEL));
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// 패널 업데이트 조건 체크
|
// sourcePanel에 따른 상태 업데이트
|
||||||
|
switch (sourcePanel) {
|
||||||
|
case panel_names.PLAYER_PANEL: {
|
||||||
|
// PlayerPanel에서 온 경우: PlayerPanel에 detailPanelClosed flag 전달
|
||||||
const shouldUpdatePanel =
|
const shouldUpdatePanel =
|
||||||
fp.pipe(
|
fp.pipe(
|
||||||
() => panels,
|
() => panels,
|
||||||
fp.get('length'),
|
fp.get('length'),
|
||||||
(length) => length === 4
|
(length) => length === 3 // PlayerPanel이 [1]에 있고 DetailPanel이 [2]에 있는 상태
|
||||||
)() &&
|
)() &&
|
||||||
fp.pipe(
|
fp.pipe(
|
||||||
() => panels,
|
() => panels,
|
||||||
@@ -164,16 +294,62 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
)();
|
)();
|
||||||
|
|
||||||
if (shouldUpdatePanel) {
|
if (shouldUpdatePanel) {
|
||||||
|
console.log(
|
||||||
|
'[DetailPanel] onBackClick - PlayerPanel에 detailPanelClosed flag 전달'
|
||||||
|
);
|
||||||
dispatch(
|
dispatch(
|
||||||
updatePanel({
|
updatePanel({
|
||||||
name: panel_names.PLAYER_PANEL,
|
name: panel_names.PLAYER_PANEL,
|
||||||
panelInfo: {
|
panelInfo: {
|
||||||
thumbnail: fp.pipe(() => panelInfo, fp.get('thumbnailUrl'))(),
|
thumbnail: fp.pipe(() => panelInfo, fp.get('thumbnailUrl'))(),
|
||||||
|
detailPanelClosed: true, // ✅ flag
|
||||||
|
detailPanelClosedAt: Date.now(), // ✅ 시점 기록
|
||||||
|
detailPanelClosedFromSource: sourceMenu, // ✅ 출처
|
||||||
|
lastFocusedTargetId: panelInfo?.lastFocusedTargetId, // ✅ 포커스 복원 타겟 전달
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// PlayerPanel의 isOnTop useEffect가 자동으로 오버레이 표시
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case panel_names.HOME_PANEL: {
|
||||||
|
// HomePanel에서 온 경우: HomePanel에 detailPanelClosed flag 전달
|
||||||
|
console.log('[DetailPanel] onBackClick - HomePanel에 detailPanelClosed flag 전달');
|
||||||
|
dispatch(
|
||||||
|
updateHomeInfo({
|
||||||
|
name: panel_names.HOME_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
detailPanelClosed: true, // ✅ flag
|
||||||
|
detailPanelClosedAt: Date.now(), // ✅ 시점 기록
|
||||||
|
detailPanelClosedFromSource: sourceMenu, // ✅ 출처
|
||||||
|
showGradientBackground: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case panel_names.SEARCH_PANEL: {
|
||||||
|
// SearchPanel에서 온 경우: SearchPanel에 detailPanelClosed flag 전달
|
||||||
|
console.log('[DetailPanel] onBackClick - SearchPanel에 detailPanelClosed flag 전달');
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.SEARCH_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
detailPanelClosed: true, // ✅ flag
|
||||||
|
detailPanelClosedAt: Date.now(), // ✅ 시점 기록
|
||||||
|
detailPanelClosedFromSource: sourceMenu, // ✅ 출처
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('[DetailPanel] onBackClick - 처리되지 않은 sourcePanel:', sourcePanel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)();
|
)();
|
||||||
|
|
||||||
@@ -186,7 +362,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
|
|
||||||
const onBackButtonFocus = useCallback(() => {
|
const onBackButtonFocus = useCallback(() => {
|
||||||
dispatch(clearAllToasts());
|
dispatch(clearAllToasts());
|
||||||
},[dispatch])
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleScrollToSection = useCallback(
|
const handleScrollToSection = useCallback(
|
||||||
(sectionId) => {
|
(sectionId) => {
|
||||||
@@ -420,13 +596,22 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
dispatch(
|
dispatch(
|
||||||
updatePanel({
|
updatePanel({
|
||||||
name: panel_names.DETAIL_PANEL,
|
name: panel_names.DETAIL_PANEL,
|
||||||
panelInfo: { shouldReload: false }
|
panelInfo: { shouldReload: false },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[DetailPanel] Reload complete');
|
console.log('[DetailPanel] Reload complete');
|
||||||
}
|
}
|
||||||
}, [panelShouldReload, dispatch, panelType, panelPatnrId, panelCurationId, panelBgImgNo, panelPrdtId, panelLiveReqFlag]);
|
}, [
|
||||||
|
panelShouldReload,
|
||||||
|
dispatch,
|
||||||
|
panelType,
|
||||||
|
panelPatnrId,
|
||||||
|
panelCurationId,
|
||||||
|
panelBgImgNo,
|
||||||
|
panelPrdtId,
|
||||||
|
panelLiveReqFlag,
|
||||||
|
]);
|
||||||
|
|
||||||
// 최근 본 상품 트리거 예시:
|
// 최근 본 상품 트리거 예시:
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
@@ -613,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) {
|
||||||
@@ -663,8 +863,10 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleProductAllSectionReady = useCallback(() => {
|
const handleProductAllSectionReady = useCallback(() => {
|
||||||
|
console.log('############## ShopByMobile focus');
|
||||||
const spotTime = setTimeout(() => {
|
const spotTime = setTimeout(() => {
|
||||||
Spotlight.focus(SpotlightIds.DETAIL_SHOPBYMOBILE);
|
// Spotlight.focus(SpotlightIds.DETAIL_SHOPBYMOBILE);
|
||||||
|
Spotlight.focus(SpotlightIds.DETAIL_PRODUCTVIDEO);
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(spotTime);
|
clearTimeout(spotTime);
|
||||||
@@ -673,55 +875,88 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
|
|
||||||
// 백그라운드 전체화면 비디오 제어: DetailPanel 진입/퇴장 시
|
// 백그라운드 전체화면 비디오 제어: DetailPanel 진입/퇴장 시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log('[BgVideo] DetailPanel mounted - checking panels:', {
|
// PlayerPanel이 존재하는지 확인 (Modal 또는 Fullscreen)
|
||||||
// panelsCount: panels?.length,
|
const playerPanel = panels.find((panel) => panel.name === panel_names.PLAYER_PANEL);
|
||||||
// panels: panels?.map(p => ({ name: p.name, modal: p.panelInfo?.modal }))
|
const hasPlayerPanel = !!playerPanel;
|
||||||
// });
|
const isModal = playerPanel?.panelInfo?.modal;
|
||||||
|
|
||||||
// 전체화면 PlayerPanel(modal=false)이 존재하는지 확인
|
|
||||||
const hasFullscreenPlayerPanel = fp.pipe(
|
|
||||||
() => panels,
|
|
||||||
(panelList) =>
|
|
||||||
panelList.some(
|
|
||||||
(panel) => panel.name === panel_names.PLAYER_PANEL && !panel.panelInfo?.modal
|
|
||||||
)
|
|
||||||
)();
|
|
||||||
|
|
||||||
// ProductAllSection에 비디오가 있는지 확인
|
// ProductAllSection에 비디오가 있는지 확인
|
||||||
const hasProductVideo = fp.pipe(() => productData, fp.get('prdtMediaUrl'), fp.isNotNil)();
|
const hasProductVideo = fp.pipe(() => productData, fp.get('prdtMediaUrl'), fp.isNotNil)();
|
||||||
|
|
||||||
// console.log('[BgVideo] hasFullscreenPlayerPanel:', hasFullscreenPlayerPanel);
|
console.log('[BgVideo] DetailPanel - Video Control Check:', {
|
||||||
// console.log('[BgVideo] hasProductVideo:', hasProductVideo);
|
hasPlayerPanel,
|
||||||
|
isModal,
|
||||||
// 전체화면 PlayerPanel이 있고, 제품에 비디오가 있을 때만 백그라운드 비디오 멈춤
|
hasProductVideo,
|
||||||
if (hasFullscreenPlayerPanel && hasProductVideo) {
|
sourceMenu: panelInfo?.sourceMenu,
|
||||||
// console.log('[BgVideo] DetailPanel - Product has video, dispatching pauseFullscreenVideo()');
|
|
||||||
dispatch(pauseFullscreenVideo());
|
|
||||||
} else {
|
|
||||||
console.log('[BgVideo] DetailPanel - Skipping pause:', {
|
|
||||||
reason: !hasFullscreenPlayerPanel ? 'no fullscreen PlayerPanel' : 'no product video',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PlayerPanel이 있고, 제품에 비디오가 있을 때만 비디오 멈춤
|
||||||
|
if (hasPlayerPanel && hasProductVideo) {
|
||||||
|
console.log('[BgVideo] DetailPanel - Pausing video');
|
||||||
|
if (isModal) {
|
||||||
|
dispatch(pauseModalVideo());
|
||||||
|
} else {
|
||||||
|
dispatch(pauseFullscreenVideo());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[BgVideo] DetailPanel - Skipping pause');
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// DetailPanel 언마운트 시: 비디오가 있었고 멈췄던 경우만 재생 재개
|
// DetailPanel 언마운트 시: 비디오가 있었고 멈췄던 경우만 재생 재개
|
||||||
// console.log('[BgVideo] DetailPanel unmounting');
|
if (hasPlayerPanel && hasProductVideo) {
|
||||||
if (hasFullscreenPlayerPanel && hasProductVideo) {
|
console.log('[BgVideo] DetailPanel - Resuming video');
|
||||||
// console.log('[BgVideo] DetailPanel - Product had video, dispatching resumeFullscreenVideo()');
|
if (isModal) {
|
||||||
|
dispatch(resumeModalVideo());
|
||||||
|
} else {
|
||||||
dispatch(resumeFullscreenVideo());
|
dispatch(resumeFullscreenVideo());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // 마운트/언마운트 시에만 실행
|
}, [panelInfo?.sourceMenu, productData?.prdtMediaUrl]);
|
||||||
|
|
||||||
|
// MediaPanel modal 상태 변화 감지 -> ProductVideo로 포커스 이동
|
||||||
|
useEffect(() => {
|
||||||
|
const topPanel = panels[panels.length - 1];
|
||||||
|
|
||||||
|
// MediaPanel이 modal=true로 복귀했을 때 포커스를 ProductVideo로 이동
|
||||||
|
// 하지만 MediaPanel에서 이미 포커스를 시도하므로 여기서는 보조 역할만 함
|
||||||
|
if (
|
||||||
|
topPanel &&
|
||||||
|
topPanel.name === panel_names.MEDIA_PANEL &&
|
||||||
|
topPanel.panelInfo.modal === true
|
||||||
|
) {
|
||||||
|
console.log('[DetailPanel] MediaPanel modal=true detected - will not interfere with focus');
|
||||||
|
// MediaPanel의 포커스 이동을 방해하지 않기 위해 아무것도 하지 않음
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [panels]);
|
||||||
|
|
||||||
|
// PlayerPanel이 modal=true인 경우 비디오 미리보기 중지
|
||||||
|
useEffect(() => {
|
||||||
|
const hasPlayerPanel = panels.some(
|
||||||
|
(panel) => panel.name === panel_names.PLAYER_PANEL && panel.panelInfo?.modal === true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPlayerPanel) {
|
||||||
|
console.log('[DetailPanel] PlayerPanel modal=true detected - stopping video preview');
|
||||||
|
dispatch(finishVideoPreview());
|
||||||
|
}
|
||||||
|
}, [panels, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<DetailPanelBackground launchedFromPlayer={panelLaunchedFromPlayer} />
|
<DetailPanelBackground
|
||||||
|
launchedFromPlayer={panelLaunchedFromPlayer}
|
||||||
|
patnrId={panelPatnrId}
|
||||||
|
onImageReady={handleBackgroundImageReady}
|
||||||
|
/>
|
||||||
|
|
||||||
<TPanel
|
<TPanel
|
||||||
isTabActivated={false}
|
isTabActivated={false}
|
||||||
className={css.detailPanelWrap}
|
className={css.detailPanelWrap}
|
||||||
handleCancel={onClick(true)}
|
handleCancel={onBackClick(true)}
|
||||||
spotlightId={spotlightId}
|
spotlightId={spotlightId}
|
||||||
>
|
>
|
||||||
<THeaderCustom
|
<THeaderCustom
|
||||||
@@ -729,7 +964,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
prdtId={productData?.prdtId}
|
prdtId={productData?.prdtId}
|
||||||
title={headerTitle}
|
title={headerTitle}
|
||||||
onBackButton
|
onBackButton
|
||||||
onClick={onClick(false)}
|
onClick={onBackClick(false)}
|
||||||
onBackButtonFocus={onBackButtonFocus}
|
onBackButtonFocus={onBackButtonFocus}
|
||||||
spotlightDisabled={isLoading}
|
spotlightDisabled={isLoading}
|
||||||
onSpotlightUp={onSpotlightUpTButton}
|
onSpotlightUp={onSpotlightUpTButton}
|
||||||
@@ -738,7 +973,6 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
logoImg={productData?.patncLogoPath}
|
logoImg={productData?.patncLogoPath}
|
||||||
patnrId={panelPatnrId}
|
patnrId={panelPatnrId}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
<TBody
|
<TBody
|
||||||
className={css.tbody}
|
className={css.tbody}
|
||||||
@@ -769,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}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// 배경 이미지와 그라데이션은 DetailPanelBackground 컴포넌트로 구현
|
// 배경 이미지와 그라데이션은 DetailPanelBackground 컴포넌트로 구현
|
||||||
// CSS 변수 대신 실제 DOM 요소 사용하여 webOS TV 호환성 확보
|
// CSS 변수 대신 실제 DOM 요소 사용하여 webOS TV 호환성 확보
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1; // 배경 컴포넌트(z-index: 0) 위에 표시
|
z-index: 98; // 배경 컴포넌트(z-index: 0) 위에 표시
|
||||||
background: transparent !important; // 투명 배경으로 설정하여 뒤의 배경 컴포넌트가 보이도록
|
background: transparent !important; // 투명 배경으로 설정하여 뒤의 배경 컴포넌트가 보이도록
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -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; /* 최소 높이만 유지 */
|
||||||
@@ -120,8 +121,6 @@
|
|||||||
padding: 0 15px 0 30px;
|
padding: 0 15px 0 30px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
@@ -409,10 +408,74 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* COUPON 버튼 */
|
||||||
|
.couponContainer {
|
||||||
|
width:100%;
|
||||||
|
display:flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
.couponTitleText {
|
||||||
|
width:350px;
|
||||||
|
height:60px;
|
||||||
|
.firstTitle {
|
||||||
|
font-size:25px;
|
||||||
|
font-weight:600;
|
||||||
|
color:@COLOR_WHITE;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
.secondTitle {
|
||||||
|
font-size:22px;
|
||||||
|
font-weight:400;
|
||||||
|
color:@COLOR_WHITE;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponButton {
|
||||||
|
flex: 1 1 0% !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 60px !important;
|
||||||
|
background: rgba(68, 68, 68, 0.5) !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
&:focus {
|
||||||
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
|
> div {
|
||||||
|
.couponText {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
> div {
|
||||||
|
display:flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
.couponText {
|
||||||
|
color: white !important;
|
||||||
|
font-size: 25px !important;
|
||||||
|
font-family: @baseFont !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
.buttonImg {
|
||||||
|
width:30px;
|
||||||
|
height:24px;
|
||||||
|
margin-left:5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/* BUY NOW + ADD TO CART 버튼 스타일 */
|
/* BUY NOW + ADD TO CART 버튼 스타일 */
|
||||||
.buyNowCartContainer {
|
.buyNowCartContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-top: 19px;
|
padding-top: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -490,6 +553,11 @@
|
|||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
|
cursor: default !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
|
||||||
&.shopByMobileOne {
|
&.shopByMobileOne {
|
||||||
margin: 0 6px 0 0;
|
margin: 0 6px 0 0;
|
||||||
}
|
}
|
||||||
@@ -501,11 +569,27 @@
|
|||||||
font-weight: 400 !important;
|
font-weight: 400 !important;
|
||||||
line-height: 35px !important;
|
line-height: 35px !important;
|
||||||
text-align: center !important;
|
text-align: center !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marquee와 내부 요소들의 커서 숨김
|
||||||
|
.marquee,
|
||||||
|
.marquee > div,
|
||||||
|
.marquee > div > div,
|
||||||
|
.text {
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
cursor: default !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 포커스 상태 추가
|
// 포커스 상태 추가
|
||||||
&:focus {
|
&:focus {
|
||||||
background: @PRIMARY_COLOR_RED !important;
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
|
outline: none !important;
|
||||||
// outline: 2px solid @PRIMARY_COLOR_RED !important;
|
// outline: 2px solid @PRIMARY_COLOR_RED !important;
|
||||||
|
|
||||||
.shopByMobileText {
|
.shopByMobileText {
|
||||||
@@ -522,6 +606,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttonStackContainer {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponStackContainer {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.favoriteBtnWrapper {
|
.favoriteBtnWrapper {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
@@ -551,6 +661,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme Item Button을 위한 새로운 Wrapper
|
||||||
|
.bottomButtonWrapper {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px; // 바닥에서 40px 마진
|
||||||
|
left: 60px; // 왼쪽에서 60px
|
||||||
|
width: 635px; // 고정 너비
|
||||||
|
min-width: 13.5rem; // 최소 너비
|
||||||
|
max-width: 27.08333rem; // 최대 너비
|
||||||
|
letter-spacing: -0.75px; // 자간 조정
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.productDetailsButton,
|
.productDetailsButton,
|
||||||
.userReviewsButton,
|
.userReviewsButton,
|
||||||
.youMayLikeButton {
|
.youMayLikeButton {
|
||||||
@@ -579,6 +707,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.productDetailsButton,
|
||||||
|
.userReviewsButton,
|
||||||
|
.youMayLikeButton {
|
||||||
|
align-self: stretch;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(255, 255, 255, 0.05); // 기본 회색 배경
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
color: #eaeaea;
|
||||||
|
font-size: 25px;
|
||||||
|
font-family: @baseFont; // LG Smart 폰트 사용
|
||||||
|
font-weight: 400; // Bold에서 Regular로 변경
|
||||||
|
line-height: 35px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: @PRIMARY_COLOR_RED; // 포커스시만 빨간색
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border: 4px solid #4f172c;
|
||||||
|
background: #4f172c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeButton {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 60px !important;
|
||||||
|
padding: 20px 30px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
border: none !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
|
||||||
|
color: white !important;
|
||||||
|
font-size: 25px !important;
|
||||||
|
font-family: @baseFont !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
line-height: 35px !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
gap: 15px !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
display: inline !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
|
outline: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeButtonIcon {
|
||||||
|
width: 52.5px;
|
||||||
|
height: 31.25px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.addToCartButton {
|
.addToCartButton {
|
||||||
flex: 1 1 0% !important;
|
flex: 1 1 0% !important;
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
@@ -618,3 +836,128 @@
|
|||||||
// PlayerPanel 모달이 이 영역에서만 재생되도록 설정
|
// PlayerPanel 모달이 이 영역에서만 재생되도록 설정
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//coupon
|
||||||
|
.itemWrap {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after,
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
width: 253px;
|
||||||
|
height: 320px;
|
||||||
|
top: 0;
|
||||||
|
content: "";
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(to right, #f8f8f8, transparent);
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to left, #f8f8f8, transparent);
|
||||||
|
}
|
||||||
|
.itemList {
|
||||||
|
.size(@w: 440px , @h: 320px);
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
> div > div {
|
||||||
|
margin-left: 253px;
|
||||||
|
}
|
||||||
|
> div {
|
||||||
|
> div:nth-child(2) {
|
||||||
|
left: -253px;
|
||||||
|
}
|
||||||
|
> div:nth-child(3) {
|
||||||
|
right: -520px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponRemain {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #808080;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponContainer {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow: 22px, @borderRadius: 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponItem {
|
||||||
|
.flex(@direction: column, @justifyCenter: space-between);
|
||||||
|
.size(@w: 440px , @h: 320px);
|
||||||
|
text-align: center;
|
||||||
|
background: @COLOR_WHITE;
|
||||||
|
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
.border-solid(@size:1px,@color:@COLOR_GRAY02);
|
||||||
|
|
||||||
|
.couponTopContents {
|
||||||
|
.couponLate {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 32px;
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
line-height: 1.1;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: @COLOR_GRAY07;
|
||||||
|
width: 400px;
|
||||||
|
height: 40px;
|
||||||
|
.elip(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponMiddleContents {
|
||||||
|
.flex(@direction: column, @justifyCenter: space-between);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 22px;
|
||||||
|
color: @COLOR_GRAY03;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
min-height: 30px;
|
||||||
|
|
||||||
|
&:nth-child(1) {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponBottomButton {
|
||||||
|
.flex();
|
||||||
|
.size(@w: 380px , @h: 60px);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #808080;
|
||||||
|
color: #fff;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
&.disable {
|
||||||
|
background-color: #808080;
|
||||||
|
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.focused {
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Spottable from '@enact/spotlight/Spottable';
|
||||||
|
import css from './ThemeItemCard.module.less';
|
||||||
|
|
||||||
|
const SpottableDiv = Spottable('div');
|
||||||
|
|
||||||
|
export default function ThemeItemCard({
|
||||||
|
onClick,
|
||||||
|
onMouseDown,
|
||||||
|
spotlightId = 'theme-open-button',
|
||||||
|
className = '',
|
||||||
|
children = 'THEME ITEM',
|
||||||
|
iconSrc = null,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SpottableDiv
|
||||||
|
className={`${css.themeItemCard} ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
spotlightId={spotlightId}
|
||||||
|
>
|
||||||
|
<div className={css.themeItemCardContent}>
|
||||||
|
<span className={css.themeItemCardText}>{children}</span>
|
||||||
|
{iconSrc && (
|
||||||
|
<div className={css.themeItemCardIcon}>
|
||||||
|
<img src={iconSrc} alt="arrow down" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SpottableDiv>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeItemCard.propTypes = {
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
onMouseDown: PropTypes.func,
|
||||||
|
spotlightId: PropTypes.string,
|
||||||
|
className: PropTypes.string,
|
||||||
|
children: PropTypes.node,
|
||||||
|
iconSrc: PropTypes.string,
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// InfoSection 높이 측정을 위한 유틸리티 함수
|
||||||
|
|
||||||
|
// InfoSection 내부 요소들의 높이를 계산하는 함수
|
||||||
|
export const calculateInfoSectionHeight = (infoSectionElement) => {
|
||||||
|
if (!infoSectionElement) {
|
||||||
|
console.error('InfoSection element not found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
// 전체 InfoSection 높이
|
||||||
|
totalHeight: infoSectionElement.offsetHeight || infoSectionElement.clientHeight,
|
||||||
|
|
||||||
|
// 내부 요소들의 높이
|
||||||
|
elements: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 주요 요소들의 높이 측정
|
||||||
|
const selectors = [
|
||||||
|
'.leftInfoContainer', // 전체 왼쪽 컨테이너
|
||||||
|
'.leftInfoWrapper', // 내부 래퍼
|
||||||
|
'.headerContent', // 헤더 콘텐츠 (태그, 별점)
|
||||||
|
'.productOverview', // 제품 개요
|
||||||
|
'.qrWrapper', // QR 코드
|
||||||
|
'.buttonStackContainer', // 버튼 스택 컨테이너
|
||||||
|
'.couponStackContainer', // 쿠폰 스택 (있을 경우)
|
||||||
|
'.buyNowCartContainer', // BUY NOW + ADD TO CART (있을 경우)
|
||||||
|
'.buttonContainer', // SHOP BY MOBILE (있을 경우)
|
||||||
|
'.callToOrderSection', // 전화 주문 섹션
|
||||||
|
'.actionButtonsWrapper', // PRODUCT DETAILS, USER REVIEWS, YOU MAY ALSO LIKE
|
||||||
|
'.themeButton', // THEME ITEM 버튼
|
||||||
|
'.DetailMobileSendPopUp', // 모바일 전송 팝업
|
||||||
|
];
|
||||||
|
|
||||||
|
selectors.forEach((selector) => {
|
||||||
|
const element = infoSectionElement.querySelector(selector);
|
||||||
|
if (element) {
|
||||||
|
result.elements[selector] = {
|
||||||
|
height: element.offsetHeight,
|
||||||
|
clientHeight: element.clientHeight,
|
||||||
|
scrollHeight: element.scrollHeight,
|
||||||
|
marginTop: parseInt(window.getComputedStyle(element).marginTop) || 0,
|
||||||
|
marginBottom: parseInt(window.getComputedStyle(element).marginBottom) || 0,
|
||||||
|
paddingTop: parseInt(window.getComputedStyle(element).paddingTop) || 0,
|
||||||
|
paddingBottom: parseInt(window.getComputedStyle(element).paddingBottom) || 0,
|
||||||
|
isVisible: element.offsetParent !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 실제 사용된 높이 계산
|
||||||
|
const calculatedHeight = Object.values(result.elements).reduce((total, element) => {
|
||||||
|
if (element && element.isVisible) {
|
||||||
|
return total + element.height + element.marginTop + element.marginBottom;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
result.calculatedHeight = calculatedHeight;
|
||||||
|
result.remainingSpace = result.totalHeight - calculatedHeight;
|
||||||
|
result.isOverflowing = calculatedHeight > result.totalHeight;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// React Hook으로 만든 버전
|
||||||
|
export const useInfoSectionHeight = (infoSectionRef) => {
|
||||||
|
const [heightInfo, setHeightInfo] = useState(null);
|
||||||
|
|
||||||
|
const measureHeight = useCallback(() => {
|
||||||
|
if (infoSectionRef.current) {
|
||||||
|
const info = calculateInfoSectionHeight(infoSectionRef.current);
|
||||||
|
setHeightInfo(info);
|
||||||
|
console.log('InfoSection Height Info:', info);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}, [infoSectionRef]);
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 측정
|
||||||
|
useEffect(() => {
|
||||||
|
measureHeight();
|
||||||
|
}, [measureHeight]);
|
||||||
|
|
||||||
|
// 윈도우 리사이즈 시 재측정 (선택사항)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
measureHeight();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [measureHeight]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
heightInfo,
|
||||||
|
measureHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용 예시:
|
||||||
|
// const infoSectionRef = useRef(null);
|
||||||
|
// const { heightInfo, measureHeight } = useInfoSectionHeight(infoSectionRef);
|
||||||
|
//
|
||||||
|
// // JSX에서
|
||||||
|
// <div ref={infoSectionRef} className={css.infoSection}>
|
||||||
|
// ... content ...
|
||||||
|
// </div>
|
||||||
@@ -33,11 +33,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: 4px solid transparent;
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 6px solid @PRIMARY_COLOR_RED;
|
border: 4px solid @PRIMARY_COLOR_RED;
|
||||||
outline-offset: 2px;
|
border-radius: 12px;
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.productDescription {
|
.productDescription {
|
||||||
@@ -49,5 +48,6 @@
|
|||||||
padding: 30px;
|
padding: 30px;
|
||||||
background-color: rgba(51, 51, 51, 1);
|
background-color: rgba(51, 51, 51, 1);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function ProductVideo({
|
|||||||
onScrollToImages,
|
onScrollToImages,
|
||||||
autoPlay = false, // 자동 재생 여부
|
autoPlay = false, // 자동 재생 여부
|
||||||
continuousPlay = false, // 반복 재생 여부
|
continuousPlay = false, // 반복 재생 여부
|
||||||
|
onFocus = null, // 외부에서 전달된 포커스 핸들러
|
||||||
}) {
|
}) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export default function ProductVideo({
|
|||||||
// autoPlay 기능: 컴포넌트 마운트 시 자동으로 비디오 재생
|
// autoPlay 기능: 컴포넌트 마운트 시 자동으로 비디오 재생
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoPlay && canPlayVideo && !hasAutoPlayed && productInfo) {
|
if (autoPlay && canPlayVideo && !hasAutoPlayed && productInfo) {
|
||||||
console.log('[ProductVideo] Auto-playing video');
|
// console.log('[ProductVideo] Auto-playing video');
|
||||||
setHasAutoPlayed(true);
|
setHasAutoPlayed(true);
|
||||||
|
|
||||||
// 짧은 딸레이 후 재생 시작 (컴포넌트 마운트 완료 후)
|
// 짧은 딸레이 후 재생 시작 (컴포넌트 마운트 완료 후)
|
||||||
@@ -112,20 +113,25 @@ export default function ProductVideo({
|
|||||||
const videoContainerOnFocus = useCallback(() => {
|
const videoContainerOnFocus = useCallback(() => {
|
||||||
if (canPlayVideo) {
|
if (canPlayVideo) {
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
console.log('[ProductVideo] Calling restoreModalMedia');
|
// console.log('[ProductVideo] Calling restoreModalMedia');
|
||||||
// ProductVideo에 포커스가 돌아오면 비디오 복원
|
// ProductVideo에 포커스가 돌아오면 비디오 복원
|
||||||
dispatch(restoreModalMedia());
|
// dispatch(restoreModalMedia());
|
||||||
|
|
||||||
|
// 외부에서 전달된 onFocus 핸들러도 호출
|
||||||
|
if (onFocus) {
|
||||||
|
onFocus();
|
||||||
}
|
}
|
||||||
}, [canPlayVideo, dispatch]);
|
}
|
||||||
|
}, [canPlayVideo, dispatch, onFocus]);
|
||||||
|
|
||||||
const videoContainerOnBlur = useCallback(() => {
|
const videoContainerOnBlur = useCallback(() => {
|
||||||
console.log('[ProductVideo] onBlur called - canPlayVideo:', canPlayVideo);
|
// console.log('[ProductVideo] onBlur called - canPlayVideo:', canPlayVideo);
|
||||||
if (canPlayVideo) {
|
// if (canPlayVideo) {
|
||||||
setFocused(false);
|
// setFocused(false);
|
||||||
// minimize는 handleScrollToImages에서 명시적으로 처리
|
// // 포커스를 잃으면 모달 MediaPanel을 최소화하여 1px 상태로 유지
|
||||||
// 여기서는 focused 상태만 변경
|
// dispatch(minimizeModalMedia());
|
||||||
}
|
// }
|
||||||
}, [canPlayVideo]);
|
}, [canPlayVideo, dispatch]);
|
||||||
|
|
||||||
// Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤
|
// Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤
|
||||||
const handleSpotlightDown = useCallback(
|
const handleSpotlightDown = useCallback(
|
||||||
@@ -143,10 +149,10 @@ export default function ProductVideo({
|
|||||||
|
|
||||||
// MediaPanel 비디오 클릭 핸들러 + 모달 토글 기능
|
// MediaPanel 비디오 클릭 핸들러 + 모달 토글 기능
|
||||||
const handleVideoClick = useCallback(() => {
|
const handleVideoClick = useCallback(() => {
|
||||||
console.log('[ProductVideo] ========== handleVideoClick 호출 ==========');
|
// console.log('[ProductVideo] ========== handleVideoClick 호출 ==========');
|
||||||
console.log('[ProductVideo] canPlayVideo:', canPlayVideo);
|
// console.log('[ProductVideo] canPlayVideo:', canPlayVideo);
|
||||||
console.log('[ProductVideo] panels.length:', panels.length);
|
// console.log('[ProductVideo] panels.length:', panels.length);
|
||||||
console.log('[ProductVideo] All panels:', JSON.stringify(panels, null, 2));
|
// console.log('[ProductVideo] All panels:', JSON.stringify(panels, null, 2));
|
||||||
|
|
||||||
if (canPlayVideo) {
|
if (canPlayVideo) {
|
||||||
const currentTopPanel = panels[panels.length - 1];
|
const currentTopPanel = panels[panels.length - 1];
|
||||||
@@ -157,19 +163,19 @@ export default function ProductVideo({
|
|||||||
currentTopPanel.name === panel_names.MEDIA_PANEL &&
|
currentTopPanel.name === panel_names.MEDIA_PANEL &&
|
||||||
currentTopPanel.panelInfo.modal === true;
|
currentTopPanel.panelInfo.modal === true;
|
||||||
|
|
||||||
console.log('[ProductVideo] currentTopPanel:', JSON.stringify(currentTopPanel, null, 2));
|
// console.log('[ProductVideo] currentTopPanel:', JSON.stringify(currentTopPanel, null, 2));
|
||||||
console.log('[ProductVideo] isCurrentlyPlayingModal:', isCurrentlyPlayingModal);
|
// console.log('[ProductVideo] isCurrentlyPlayingModal:', isCurrentlyPlayingModal);
|
||||||
|
|
||||||
// modal로 재생 중이면 전체화면으로 전환
|
// modal로 재생 중이면 전체화면으로 전환
|
||||||
if (isCurrentlyPlayingModal) {
|
if (isCurrentlyPlayingModal) {
|
||||||
console.log(
|
// console.log(
|
||||||
'[ProductVideo] *** Switching to fullscreen mode via switchMediaToFullscreen ***'
|
// '[ProductVideo] *** Switching to fullscreen mode via switchMediaToFullscreen ***'
|
||||||
);
|
// );
|
||||||
dispatch(switchMediaToFullscreen());
|
dispatch(switchMediaToFullscreen());
|
||||||
setModalState(false);
|
setModalState(false);
|
||||||
} else {
|
} else {
|
||||||
console.log('[ProductVideo] *** Starting modal MediaPanel ***');
|
// console.log('[ProductVideo] *** Starting modal MediaPanel ***');
|
||||||
console.log('[ProductVideo] productInfo:', JSON.stringify(productInfo, null, 2));
|
// console.log('[ProductVideo] productInfo:', JSON.stringify(productInfo, null, 2));
|
||||||
// 처음 재생 시작 - modal=true로 시작
|
// 처음 재생 시작 - modal=true로 시작
|
||||||
dispatch(
|
dispatch(
|
||||||
startMediaPlayer({
|
startMediaPlayer({
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 6px; // 포커스 테두리를 위한 공간
|
padding: 6px; // 포커스 테두리를 위한 공간
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
.videoThumbnailWrapper {
|
.videoThumbnailWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
// width: 658px;
|
// width: 658px;
|
||||||
@@ -24,6 +27,9 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
.videoThumbnail {
|
.videoThumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -34,6 +40,9 @@
|
|||||||
background-color: @COLOR_WHITE;
|
background-color: @COLOR_WHITE;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playButtonOverlay {
|
.playButtonOverlay {
|
||||||
@@ -43,11 +52,16 @@
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -614,12 +614,7 @@ export function ProductVideoV2({
|
|||||||
debugLog('[Fullscreen Container] Enter key - overlay visible, allow default behavior');
|
debugLog('[Fullscreen Container] Enter key - overlay visible, allow default behavior');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[isPlaying, isFullscreen, toggleOverlayVisibility, mediaOverlayState.controls?.visible]
|
||||||
isPlaying,
|
|
||||||
isFullscreen,
|
|
||||||
toggleOverlayVisibility,
|
|
||||||
mediaOverlayState.controls?.visible,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 마우스 다운 (클릭) 이벤트 - capture phase에서 처리
|
// 마우스 다운 (클릭) 이벤트 - capture phase에서 처리
|
||||||
@@ -1178,6 +1173,3 @@ export function ProductVideoV2({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import Spotlight from '@enact/spotlight';
|
||||||
|
import Spottable from '@enact/spotlight/Spottable';
|
||||||
|
import {
|
||||||
|
startMediaPlayer,
|
||||||
|
finishMediaPreview,
|
||||||
|
switchMediaToFullscreen,
|
||||||
|
minimizeModalMedia,
|
||||||
|
restoreModalMedia,
|
||||||
|
} from '../../../../actions/mediaActions';
|
||||||
|
import CustomImage from '../../../../components/CustomImage/CustomImage';
|
||||||
|
import { panel_names } from '../../../../utils/Config';
|
||||||
|
import playImg from '../../../../../assets/images/btn/btn-play-thumb-nor.png';
|
||||||
|
import css from './ProductVideo.module.less';
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable('div');
|
||||||
|
|
||||||
|
export default function ProductVideo({
|
||||||
|
productInfo,
|
||||||
|
videoUrl,
|
||||||
|
thumbnailUrl,
|
||||||
|
onScrollToImages,
|
||||||
|
onVideoPlaying = null, // 비디오 재생 시 호출되는 콜백
|
||||||
|
autoPlay = false, // 자동 재생 여부
|
||||||
|
continuousPlay = false, // 반복 재생 여부
|
||||||
|
onFocus = null, // 외부에서 전달된 포커스 핸들러
|
||||||
|
}) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// MediaPanel 상태 체크를 위한 selectors 추가
|
||||||
|
const panels = useSelector((state) => state.panels.panels);
|
||||||
|
const [isLaunchedFromPlayer, setIsLaunchedFromPlayer] = useState(false);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [modalState, setModalState] = useState(true); // 모달 상태 관리 추가
|
||||||
|
const [hasAutoPlayed, setHasAutoPlayed] = useState(false); // 자동 재생 완료 여부
|
||||||
|
const [isVideoPlaying, setIsVideoPlaying] = useState(false); // 비디오 재생 여부 flag
|
||||||
|
const [isVideoLoading, setIsVideoLoading] = useState(false); // 비디오 로딩 중 flag
|
||||||
|
const prevModalStateRef = useRef(null); // 이전 modal 상태 추적
|
||||||
|
|
||||||
|
const topPanel = panels[panels.length - 1];
|
||||||
|
|
||||||
|
// MediaPanel 상태 체크 로직 + 모달 상태 복원 + 포커스 복구
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
topPanel &&
|
||||||
|
topPanel.name === panel_names.MEDIA_PANEL &&
|
||||||
|
topPanel.panelInfo.modal === false
|
||||||
|
) {
|
||||||
|
// 전체화면 모드: 이전 상태 저장
|
||||||
|
prevModalStateRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaPanel이 modal=true로 복귀했을 때 포커스 복구
|
||||||
|
if (
|
||||||
|
topPanel &&
|
||||||
|
topPanel.name === panel_names.MEDIA_PANEL &&
|
||||||
|
topPanel.panelInfo.modal === true &&
|
||||||
|
prevModalStateRef.current === false
|
||||||
|
) {
|
||||||
|
// console.log('[ProductVideo] MediaPanel returned to modal - restoring focus to ProductVideo');
|
||||||
|
|
||||||
|
// VideoPlayer의 controlsHandleAbove가 자동으로 포커스를 빼앗지 않도록
|
||||||
|
// 약간의 딜레이 후에 강제로 포커스 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
// console.log('[ProductVideo] Forcing focus to product-video-player');
|
||||||
|
const element = document.querySelector('[data-spotlight-id="product-video-player"]');
|
||||||
|
if (element) {
|
||||||
|
// Spotlight 내부 포커스 강제 설정
|
||||||
|
Spotlight.focus('product-video-player');
|
||||||
|
// console.log('[ProductVideo] Focus set to product-video-player');
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
prevModalStateRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaPanel이 닫혔을 때 modalState를 true로 복원
|
||||||
|
if (!topPanel || topPanel.name !== panel_names.MEDIA_PANEL) {
|
||||||
|
// console.log('[ProductVideo] MediaPanel closed - restoring modal state');
|
||||||
|
setModalState(true);
|
||||||
|
prevModalStateRef.current = null;
|
||||||
|
}
|
||||||
|
}, [topPanel]);
|
||||||
|
|
||||||
|
// autoPlay 기능: 컴포넌트 마운트 시 자동으로 비디오 재생
|
||||||
|
// 단, 이미 MediaPanel이 modal로 재생 중이면 autoPlay 하지 않음
|
||||||
|
useEffect(() => {
|
||||||
|
// MediaPanel이 이미 modal로 재생 중인지 확인
|
||||||
|
const isMediaPanelAlreadyPlaying =
|
||||||
|
topPanel?.name === panel_names.MEDIA_PANEL && topPanel?.panelInfo?.modal === true;
|
||||||
|
|
||||||
|
if (autoPlay && canPlayVideo && !hasAutoPlayed && productInfo && !isMediaPanelAlreadyPlaying) {
|
||||||
|
// console.log('[ProductVideo]-LoadingVideo 🎯 AutoPlay 시작:', {
|
||||||
|
// prdtId: productInfo?.prdtId,
|
||||||
|
// prdtNm: productInfo?.prdtNm,
|
||||||
|
// prdtMediaUrl: productInfo?.prdtMediaUrl?.substring(0, 50),
|
||||||
|
// });
|
||||||
|
setHasAutoPlayed(true);
|
||||||
|
setIsVideoLoading(true); // 로딩 시작
|
||||||
|
|
||||||
|
// 짧은 딜레이 후 재생 시작 (컴포넌트 마운트 완료 후)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsVideoPlaying(true); // 비디오 재생 flag 설정
|
||||||
|
if (onVideoPlaying) {
|
||||||
|
onVideoPlaying(); // 부모 컴포넌트에 알림
|
||||||
|
}
|
||||||
|
dispatch(
|
||||||
|
startMediaPlayer({
|
||||||
|
qrCurrentItem: productInfo,
|
||||||
|
showUrl: productInfo?.prdtMediaUrl,
|
||||||
|
showNm: productInfo?.prdtNm,
|
||||||
|
patnrNm: productInfo?.patncNm,
|
||||||
|
patncLogoPath: productInfo?.patncLogoPath,
|
||||||
|
orderPhnNo: productInfo?.orderPhnNo,
|
||||||
|
disclaimer: productInfo?.disclaimer,
|
||||||
|
subtitle: productInfo?.prdtMediaSubtitlUrl,
|
||||||
|
lgCatCd: productInfo?.catCd,
|
||||||
|
patnrId: productInfo?.patnrId,
|
||||||
|
lgCatNm: productInfo?.catNm,
|
||||||
|
prdtId: productInfo?.prdtId,
|
||||||
|
patncNm: productInfo?.patncNm,
|
||||||
|
prdtNm: productInfo?.prdtNm,
|
||||||
|
thumbnailUrl: productInfo?.thumbnailUrl960,
|
||||||
|
shptmBanrTpNm: 'MEDIA',
|
||||||
|
modal: true,
|
||||||
|
modalContainerId: 'product-video-player',
|
||||||
|
modalClassName: modalClassNameChange(),
|
||||||
|
spotlightDisable: true,
|
||||||
|
continuousPlay, // 반복 재생 옵션 전달
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setIsVideoLoading(false); // 로딩 완료
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
autoPlay,
|
||||||
|
canPlayVideo,
|
||||||
|
hasAutoPlayed,
|
||||||
|
productInfo,
|
||||||
|
dispatch,
|
||||||
|
modalClassNameChange,
|
||||||
|
continuousPlay,
|
||||||
|
topPanel,
|
||||||
|
panels,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 비디오 재생 가능 여부 체크
|
||||||
|
const canPlayVideo = useMemo(() => {
|
||||||
|
return Boolean(productInfo?.prdtMediaUrl);
|
||||||
|
}, [productInfo]);
|
||||||
|
|
||||||
|
// 모달 CSS 클래스 변경 로직
|
||||||
|
const modalClassNameChange = useCallback(() => {
|
||||||
|
if (focused) {
|
||||||
|
return css.videoModal;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
// 포커스 이벤트 핸들러
|
||||||
|
const videoContainerOnFocus = useCallback(() => {
|
||||||
|
if (canPlayVideo) {
|
||||||
|
setFocused(true);
|
||||||
|
// console.log('[ProductVideo] Calling restoreModalMedia');
|
||||||
|
// ProductVideo에 포커스가 돌아오면 비디오 복원
|
||||||
|
// dispatch(restoreModalMedia());
|
||||||
|
|
||||||
|
// 외부에서 전달된 onFocus 핸들러도 호출
|
||||||
|
if (onFocus) {
|
||||||
|
onFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [canPlayVideo, dispatch, onFocus]);
|
||||||
|
|
||||||
|
const videoContainerOnBlur = useCallback(() => {
|
||||||
|
// console.log('[ProductVideo] onBlur called - canPlayVideo:', canPlayVideo);
|
||||||
|
// if (canPlayVideo) {
|
||||||
|
// setFocused(false);
|
||||||
|
// // 포커스를 잃으면 모달 MediaPanel을 최소화하여 1px 상태로 유지
|
||||||
|
// dispatch(minimizeModalMedia());
|
||||||
|
// }
|
||||||
|
}, [canPlayVideo, dispatch]);
|
||||||
|
|
||||||
|
// Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤
|
||||||
|
const handleSpotlightDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (canPlayVideo && onScrollToImages) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dispatch(minimizeModalMedia());
|
||||||
|
onScrollToImages();
|
||||||
|
return true; // 이벤트 처리 완료
|
||||||
|
}
|
||||||
|
return false; // Spotlight가 기본 동작 수행
|
||||||
|
},
|
||||||
|
[canPlayVideo, onScrollToImages, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// MediaPanel 비디오 클릭 핸들러 + 모달 토글 기능
|
||||||
|
const handleVideoClick = useCallback(() => {
|
||||||
|
// console.log('[ProductVideo] ========== handleVideoClick 호출 ==========');
|
||||||
|
// console.log('[ProductVideo] canPlayVideo:', canPlayVideo);
|
||||||
|
// console.log('[ProductVideo] isVideoLoading:', isVideoLoading);
|
||||||
|
// console.log('[ProductVideo] panels.length:', panels.length);
|
||||||
|
// console.log('[ProductVideo] All panels:', JSON.stringify(panels, null, 2));
|
||||||
|
|
||||||
|
// 비디오 로딩 중이면 클릭 무시
|
||||||
|
if (isVideoLoading) {
|
||||||
|
console.log('[ProductVideo] ⚠️ 비디오 로딩 중 - 클릭 무시');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canPlayVideo) {
|
||||||
|
const currentTopPanel = panels[panels.length - 1];
|
||||||
|
|
||||||
|
// 현재 MediaPanel이 modal=true로 재생 중인지 확인
|
||||||
|
const isCurrentlyPlayingModal =
|
||||||
|
currentTopPanel &&
|
||||||
|
currentTopPanel.name === panel_names.MEDIA_PANEL &&
|
||||||
|
currentTopPanel.panelInfo.modal === true;
|
||||||
|
|
||||||
|
// console.log('[ProductVideo] currentTopPanel:', JSON.stringify(currentTopPanel, null, 2));
|
||||||
|
// console.log('[ProductVideo] isCurrentlyPlayingModal:', isCurrentlyPlayingModal);
|
||||||
|
|
||||||
|
// modal로 재생 중이면 전체화면으로 전환
|
||||||
|
if (isCurrentlyPlayingModal) {
|
||||||
|
// console.log(
|
||||||
|
// '[ProductVideo] *** Switching to fullscreen mode via switchMediaToFullscreen ***'
|
||||||
|
// );
|
||||||
|
dispatch(switchMediaToFullscreen());
|
||||||
|
setModalState(false);
|
||||||
|
|
||||||
|
// 전체화면 전환 후 VideoPlayer에 포커스 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
// console.log('[ProductVideo] Focusing to fullscreen video player');
|
||||||
|
const focusTarget =
|
||||||
|
document.querySelector('[data-spotlight-id="modal-video-player"]') ||
|
||||||
|
document.querySelector('[data-spotlight-id="product-video-player"]');
|
||||||
|
if (focusTarget) {
|
||||||
|
Spotlight.focus('modal-video-player');
|
||||||
|
// console.log('[ProductVideo] Focus set to fullscreen video player');
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
// console.log('[ProductVideo] *** Starting modal MediaPanel ***');
|
||||||
|
// console.log('[ProductVideo] productInfo:', JSON.stringify(productInfo, null, 2));
|
||||||
|
// 처음 재생 시작 - modal=true로 시작
|
||||||
|
setIsVideoPlaying(true); // 비디오 재생 flag 설정
|
||||||
|
if (onVideoPlaying) {
|
||||||
|
onVideoPlaying(); // 부모 컴포넌트에 알림
|
||||||
|
}
|
||||||
|
dispatch(
|
||||||
|
startMediaPlayer({
|
||||||
|
qrCurrentItem: productInfo,
|
||||||
|
showUrl: productInfo?.prdtMediaUrl,
|
||||||
|
showNm: productInfo?.prdtNm,
|
||||||
|
patnrNm: productInfo?.patncNm,
|
||||||
|
patncLogoPath: productInfo?.patncLogoPath,
|
||||||
|
orderPhnNo: productInfo?.orderPhnNo,
|
||||||
|
disclaimer: productInfo?.disclaimer,
|
||||||
|
subtitle: productInfo?.prdtMediaSubtitlUrl,
|
||||||
|
lgCatCd: productInfo?.catCd,
|
||||||
|
patnrId: productInfo?.patnrId,
|
||||||
|
lgCatNm: productInfo?.catNm,
|
||||||
|
prdtId: productInfo?.prdtId,
|
||||||
|
patncNm: productInfo?.patncNm,
|
||||||
|
prdtNm: productInfo?.prdtNm,
|
||||||
|
thumbnailUrl: productInfo?.thumbnailUrl960,
|
||||||
|
shptmBanrTpNm: 'MEDIA',
|
||||||
|
modal: true,
|
||||||
|
modalContainerId: 'product-video-player',
|
||||||
|
modalClassName: modalClassNameChange(),
|
||||||
|
spotlightDisable: true,
|
||||||
|
continuousPlay, // 반복 재생 옵션 전달
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLaunchedFromPlayer) {
|
||||||
|
setIsLaunchedFromPlayer(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
dispatch,
|
||||||
|
productInfo,
|
||||||
|
canPlayVideo,
|
||||||
|
isLaunchedFromPlayer,
|
||||||
|
modalClassNameChange,
|
||||||
|
panels,
|
||||||
|
modalState,
|
||||||
|
isVideoLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!canPlayVideo) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.videoContainer}
|
||||||
|
onClick={handleVideoClick}
|
||||||
|
onFocus={videoContainerOnFocus}
|
||||||
|
onBlur={videoContainerOnBlur}
|
||||||
|
onSpotlightDown={handleSpotlightDown}
|
||||||
|
spotlightId="product-video-player"
|
||||||
|
aria-label={`${productInfo?.prdtNm} 동영상 재생`}
|
||||||
|
>
|
||||||
|
<div className={css.videoThumbnailWrapper}>
|
||||||
|
<CustomImage
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt={`${productInfo?.prdtNm} 동영상 썸네일`}
|
||||||
|
className={css.videoThumbnail}
|
||||||
|
/>
|
||||||
|
<div className={css.playButtonOverlay}>
|
||||||
|
<img src={playImg} alt="재생" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpottableComponent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,20 @@
|
|||||||
import React, {
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import {
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
useDispatch,
|
|
||||||
useSelector,
|
|
||||||
} from 'react-redux';
|
|
||||||
|
|
||||||
import { Job } from '@enact/core/util';
|
import { Job } from '@enact/core/util';
|
||||||
import SpotlightContainerDecorator
|
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
|
||||||
from '@enact/spotlight/SpotlightContainerDecorator';
|
|
||||||
import Spottable from '@enact/spotlight/Spottable';
|
import Spottable from '@enact/spotlight/Spottable';
|
||||||
|
|
||||||
import { clearThemeDetail } from '../../../../actions/homeActions';
|
import { clearThemeDetail } from '../../../../actions/homeActions';
|
||||||
import {
|
import { popPanel, pushPanel, updatePanel } from '../../../../actions/panelActions';
|
||||||
popPanel,
|
|
||||||
pushPanel,
|
|
||||||
updatePanel,
|
|
||||||
} from '../../../../actions/panelActions';
|
|
||||||
import { finishVideoPreview } from '../../../../actions/playActions';
|
import { finishVideoPreview } from '../../../../actions/playActions';
|
||||||
import THeader from '../../../../components/THeader/THeader';
|
import THeader from '../../../../components/THeader/THeader';
|
||||||
import TItemCardNew from '../../../../components/TItemCard/TItemCard.new';
|
import TItemCardNew from '../../../../components/TItemCard/TItemCard.new';
|
||||||
import TVerticalPagenator
|
import TVerticalPagenator from '../../../../components/TVerticalPagenator/TVerticalPagenator';
|
||||||
from '../../../../components/TVerticalPagenator/TVerticalPagenator';
|
import TVirtualGridList from '../../../../components/TVirtualGridList/TVirtualGridList';
|
||||||
import TVirtualGridList
|
|
||||||
from '../../../../components/TVirtualGridList/TVirtualGridList';
|
|
||||||
import useScrollTo from '../../../../hooks/useScrollTo';
|
import useScrollTo from '../../../../hooks/useScrollTo';
|
||||||
import {
|
import { LOG_CONTEXT_NAME, LOG_MESSAGE_ID, panel_names } from '../../../../utils/Config';
|
||||||
LOG_CONTEXT_NAME,
|
|
||||||
LOG_MESSAGE_ID,
|
|
||||||
panel_names,
|
|
||||||
} from '../../../../utils/Config';
|
|
||||||
import { $L } from '../../../../utils/helperMethods';
|
import { $L } from '../../../../utils/helperMethods';
|
||||||
import css from './YouMayAlsoLike.module.less';
|
import css from './YouMayAlsoLike.module.less';
|
||||||
|
|
||||||
@@ -186,9 +166,11 @@ export default function YouMayAlsoLike({
|
|||||||
);
|
);
|
||||||
cursorOpen.current.stop();
|
cursorOpen.current.stop();
|
||||||
};
|
};
|
||||||
|
// prdtId가 없는 경우를 대비한 안정적인 key 생성
|
||||||
|
const itemKey = prdtId ? `${patnrId}-${prdtId}` : `product-${index}`;
|
||||||
return (
|
return (
|
||||||
<TItemCardNew
|
<TItemCardNew
|
||||||
key={prdtId}
|
key={itemKey}
|
||||||
className={css.itemCardNew}
|
className={css.itemCardNew}
|
||||||
contextName={LOG_CONTEXT_NAME.YOUMAYLIKE}
|
contextName={LOG_CONTEXT_NAME.YOUMAYLIKE}
|
||||||
messageId={LOG_MESSAGE_ID.CONTENTCLICK}
|
messageId={LOG_MESSAGE_ID.CONTENTCLICK}
|
||||||
|
|||||||
@@ -106,12 +106,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
.enrgLbImg {
|
.enrgLbImg {
|
||||||
width:62px;
|
width:62px;
|
||||||
height:38px;
|
|
||||||
border:4px solid transparent;
|
border:4px solid transparent;
|
||||||
&:focus {
|
&:focus {
|
||||||
border: 4px solid @PRIMARY_COLOR_RED;
|
border: 4px solid @PRIMARY_COLOR_RED;
|
||||||
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
|
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
|
||||||
border-radius: 12px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
> img {
|
> img {
|
||||||
|
|||||||
@@ -0,0 +1,958 @@
|
|||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
padding: 60,
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.53) 0%, rgba(20.56, 4.68, 32.71, 0.53) 60%, rgba(199, 32, 84, 0) 98%), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.40) 45%, rgba(0, 0, 0, 0.40) 100%), rgba(30, 30, 30, 0.80)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 30,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 635,
|
||||||
|
height: 60,
|
||||||
|
paddingLeft: 30,
|
||||||
|
paddingRight: 30,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 20,
|
||||||
|
background: '#C72054',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 6,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 15,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 25,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 35,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
THEME ITEM
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 26.25, height: 15.63, background: 'white' }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ justifyContent: 'flex-start', alignItems: 'center', gap: 18, display: 'inline-flex' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 470,
|
||||||
|
padding: 30,
|
||||||
|
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
|
||||||
|
borderRadius: 12,
|
||||||
|
outline: '4px #C70850 solid',
|
||||||
|
outlineOffset: '-4px',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 15,
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
paddingLeft: 0.41,
|
||||||
|
paddingRight: 0.41,
|
||||||
|
paddingTop: 0.51,
|
||||||
|
paddingBottom: 0.51,
|
||||||
|
}}
|
||||||
|
src="https://placehold.co/120x120"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
color: '#EAEAEA',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
lineHeight: 25,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Orlgami Removable Connecting Rack 2 -pack
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 1,
|
||||||
|
paddingBottom: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C70850',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$32.98
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C2C2C2',
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$150.00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 470,
|
||||||
|
padding: 30,
|
||||||
|
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 15,
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img style={{ width: 120, height: 120 }} src="https://placehold.co/120x120" />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
color: '#EAEAEA',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
lineHeight: 25,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Orlgami Removable Connecting Rack 2 -pack
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 1,
|
||||||
|
paddingBottom: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C70850',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$32.98
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C2C2C2',
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$150.00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 470,
|
||||||
|
padding: 30,
|
||||||
|
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 15,
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
paddingLeft: 0.41,
|
||||||
|
paddingRight: 0.41,
|
||||||
|
paddingTop: 0.51,
|
||||||
|
paddingBottom: 0.51,
|
||||||
|
}}
|
||||||
|
src="https://placehold.co/120x120"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
color: '#EAEAEA',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
lineHeight: 25,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Orlgami Removable Connecting Rack 2 -pack
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 1,
|
||||||
|
paddingBottom: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C70850',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$32.98
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C2C2C2',
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$150.00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 5,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.11,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#BFD730',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.18,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.28,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 45.94,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 44.77,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ width: 11.7, height: 13.88, left: 21.49, top: 8.02, position: 'absolute' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 8.95,
|
||||||
|
height: 13.52,
|
||||||
|
left: 1.37,
|
||||||
|
top: 0.18,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.45,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#F37021',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.52,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.62,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 46.28,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 45.11,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ width: 11.7, height: 13.88, left: 21.49, top: 8.02, position: 'absolute' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 7.13,
|
||||||
|
height: 13.81,
|
||||||
|
left: 2.29,
|
||||||
|
top: 0.04,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.45,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#00A651',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.52,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.62,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 46.28,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 45.11,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 11.7,
|
||||||
|
height: 13.88,
|
||||||
|
left: 21.49,
|
||||||
|
top: 8.01,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 470,
|
||||||
|
padding: 30,
|
||||||
|
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 15,
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
paddingLeft: 0.41,
|
||||||
|
paddingRight: 0.41,
|
||||||
|
paddingTop: 0.51,
|
||||||
|
paddingBottom: 0.51,
|
||||||
|
}}
|
||||||
|
src="https://placehold.co/120x120"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
color: '#EAEAEA',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
lineHeight: 25,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Orlgami Removable Connecting Rack 2 -pack
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 1,
|
||||||
|
paddingBottom: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C70850',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$32.98
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C2C2C2',
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$150.00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 5,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.11,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#BFD730',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.17,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.28,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 45.93,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 44.77,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ width: 11.7, height: 13.88, left: 21.49, top: 8.02, position: 'absolute' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 8.95,
|
||||||
|
height: 13.52,
|
||||||
|
left: 1.37,
|
||||||
|
top: 0.18,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.45,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#F37021',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.52,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.62,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 46.28,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 45.11,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ width: 11.7, height: 13.88, left: 21.49, top: 8.02, position: 'absolute' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 7.13,
|
||||||
|
height: 13.81,
|
||||||
|
left: 2.29,
|
||||||
|
top: 0.04,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.45,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#00A651',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.52,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.62,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 46.28,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 45.11,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 11.7,
|
||||||
|
height: 13.88,
|
||||||
|
left: 21.49,
|
||||||
|
top: 8.01,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 470,
|
||||||
|
padding: 18,
|
||||||
|
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
paddingLeft: 0.41,
|
||||||
|
paddingRight: 0.41,
|
||||||
|
paddingTop: 0.51,
|
||||||
|
paddingBottom: 0.51,
|
||||||
|
}}
|
||||||
|
src="https://placehold.co/110x110"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
paddingBottom: 15,
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
color: '#EAEAEA',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
lineHeight: 25,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Orlgami Removable Connecting Rack 2 -pack
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 1,
|
||||||
|
paddingBottom: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C70850',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$32.98
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C2C2C2',
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$150.00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
|
import Spotlight from '@enact/spotlight';
|
||||||
|
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
|
||||||
|
|
||||||
|
import arrowDownIcon from '../../../../assets/images/icons/ic-arrow-down.svg';
|
||||||
|
import themeImage1 from '../../../../assets/images/theme/image-1.png';
|
||||||
|
import themeImage2 from '../../../../assets/images/theme/image-2.png';
|
||||||
|
import themeImage3 from '../../../../assets/images/theme/image-3.png';
|
||||||
|
import { updatePanel } from '../../../actions/panelActions';
|
||||||
|
import TButton from '../../../components/TButton/TButton';
|
||||||
|
import ThemeItemCard from './ThemeItemCard';
|
||||||
|
import { LOG_CONTEXT_NAME, LOG_MESSAGE_ID, panel_names } from '../../../utils/Config';
|
||||||
|
import { $L } from '../../../utils/helperMethods';
|
||||||
|
import css from './ThemeContents.module.less';
|
||||||
|
import { sendLogTotalRecommend } from '../../../actions/logActions';
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: 'default-element',
|
||||||
|
preserveId: true,
|
||||||
|
spotlightDirection: 'vertical', // THEME ITEM 버튼 -> 리스트(아래) 이동
|
||||||
|
spotlightRestrict: 'self-only',
|
||||||
|
},
|
||||||
|
'div'
|
||||||
|
);
|
||||||
|
|
||||||
|
const ButtonContainer = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: 'last-focused',
|
||||||
|
preserveId: true,
|
||||||
|
spotlightDirection: 'horizontal',
|
||||||
|
},
|
||||||
|
'div'
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemsContainer = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: 'default-element',
|
||||||
|
preserveId: true,
|
||||||
|
spotlightDirection: 'horizontal',
|
||||||
|
},
|
||||||
|
'div'
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ThemeContents({
|
||||||
|
themeItems,
|
||||||
|
setSelectedIndex,
|
||||||
|
videoVerticalVisible,
|
||||||
|
currentVideoShowId,
|
||||||
|
tabIndex,
|
||||||
|
handleItemFocus,
|
||||||
|
tabTitle,
|
||||||
|
panelInfo,
|
||||||
|
direction = 'horizontal',
|
||||||
|
version = 2,
|
||||||
|
onThemeItemClose,
|
||||||
|
}) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const isClickBlocked = useRef(false);
|
||||||
|
const blockTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
// 랜덤 이미지 선택 함수
|
||||||
|
const getRandomThemeImage = () => {
|
||||||
|
const images = [themeImage1, themeImage2, themeImage3];
|
||||||
|
return images[Math.floor(Math.random() * images.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock 데이터
|
||||||
|
const mockItems = [
|
||||||
|
{
|
||||||
|
prdtId: 'mock-1',
|
||||||
|
prdtNm: 'Origami Removable Connecting Rack 2-pack',
|
||||||
|
prdtImgPath: getRandomThemeImage(),
|
||||||
|
salePrice: '$32.98',
|
||||||
|
originalPrice: '$150.00',
|
||||||
|
patnrLogoPath: null,
|
||||||
|
patncNm: 'Partner 1',
|
||||||
|
showId: 'show-1',
|
||||||
|
catNm: 'Category',
|
||||||
|
energyLabels: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prdtId: 'mock-2',
|
||||||
|
prdtNm: 'Premium Stainless Steel Cookware Set',
|
||||||
|
prdtImgPath: getRandomThemeImage(),
|
||||||
|
salePrice: '$45.99',
|
||||||
|
originalPrice: '$189.99',
|
||||||
|
patnrLogoPath: null,
|
||||||
|
patncNm: 'Partner 2',
|
||||||
|
showId: 'show-2',
|
||||||
|
catNm: 'Category',
|
||||||
|
energyLabels: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prdtId: 'mock-3',
|
||||||
|
prdtNm: 'Smart Home Security Camera System',
|
||||||
|
prdtImgPath: getRandomThemeImage(),
|
||||||
|
salePrice: '$78.50',
|
||||||
|
originalPrice: '$299.00',
|
||||||
|
patnrLogoPath: null,
|
||||||
|
patncNm: 'Partner 3',
|
||||||
|
showId: 'show-3',
|
||||||
|
catNm: 'Category',
|
||||||
|
energyLabels: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prdtId: 'mock-4',
|
||||||
|
prdtNm: 'Ergonomic Office Chair with Lumbar Support',
|
||||||
|
prdtImgPath: getRandomThemeImage(),
|
||||||
|
salePrice: '$125.00',
|
||||||
|
originalPrice: '$450.00',
|
||||||
|
patnrLogoPath: null,
|
||||||
|
patncNm: 'Partner 4',
|
||||||
|
showId: 'show-4',
|
||||||
|
catNm: 'Category',
|
||||||
|
energyLabels: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prdtId: 'mock-5',
|
||||||
|
prdtNm: 'Wireless Bluetooth Noise-Canceling Headphones',
|
||||||
|
prdtImgPath: getRandomThemeImage(),
|
||||||
|
salePrice: '$89.99',
|
||||||
|
originalPrice: '$249.99',
|
||||||
|
patnrLogoPath: null,
|
||||||
|
patncNm: 'Partner 5',
|
||||||
|
showId: 'show-5',
|
||||||
|
catNm: 'Category',
|
||||||
|
energyLabels: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// themeItems가 비어있으면 mock 데이터 사용
|
||||||
|
const displayItems = useMemo(() => {
|
||||||
|
if (themeItems && themeItems.length > 0) {
|
||||||
|
return themeItems;
|
||||||
|
}
|
||||||
|
return mockItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
prdtImgPath: getRandomThemeImage(), // 각 아이템마다 랜덤 이미지 재할당
|
||||||
|
}));
|
||||||
|
}, [themeItems]);
|
||||||
|
|
||||||
|
const handleThemeItemButtonClick = useCallback(() => {
|
||||||
|
if (onThemeItemClose) {
|
||||||
|
onThemeItemClose();
|
||||||
|
}
|
||||||
|
}, [onThemeItemClose]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(item, index) => {
|
||||||
|
console.log('[Theme] renderItem index', index, 'displayItems size', displayItems?.length);
|
||||||
|
const {
|
||||||
|
prdtId,
|
||||||
|
prdtNm,
|
||||||
|
prdtImgPath,
|
||||||
|
salePrice,
|
||||||
|
originalPrice,
|
||||||
|
patnrLogoPath,
|
||||||
|
patncNm,
|
||||||
|
showId,
|
||||||
|
catNm,
|
||||||
|
energyLabels,
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
const spotlightItemId = `theme-toast-item-${index}`;
|
||||||
|
|
||||||
|
const handleItemClick = () => {
|
||||||
|
const params = {
|
||||||
|
tabTitle: tabTitle[tabIndex],
|
||||||
|
productId: prdtId,
|
||||||
|
productTitle: prdtNm,
|
||||||
|
showType: panelInfo?.shptmBanrTpNm,
|
||||||
|
category: catNm,
|
||||||
|
partner: patncNm,
|
||||||
|
contextName: LOG_CONTEXT_NAME.PRODUCT,
|
||||||
|
messageId: LOG_MESSAGE_ID.CONTENTCLICK,
|
||||||
|
};
|
||||||
|
dispatch(sendLogTotalRecommend(params));
|
||||||
|
|
||||||
|
if (isClickBlocked.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isClickBlocked.current = true;
|
||||||
|
|
||||||
|
if (blockTimeoutRef.current) {
|
||||||
|
clearTimeout(blockTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockTimeoutRef.current = setTimeout(() => {
|
||||||
|
isClickBlocked.current = false;
|
||||||
|
blockTimeoutRef.current = null;
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
if (!prdtId) return;
|
||||||
|
|
||||||
|
setSelectedIndex(index);
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.PLAYER_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
prdtId,
|
||||||
|
showId,
|
||||||
|
shptmBanrTpNm: 'THEME',
|
||||||
|
isUpdatedByClick: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeItemCard
|
||||||
|
key={prdtId || `theme-item-${index}`}
|
||||||
|
prdtId={prdtId}
|
||||||
|
prdtNm={prdtNm}
|
||||||
|
prdtImgPath={prdtImgPath}
|
||||||
|
salePrice={salePrice}
|
||||||
|
originalPrice={originalPrice}
|
||||||
|
energyLabels={energyLabels}
|
||||||
|
onClick={handleItemClick}
|
||||||
|
spotlightId={spotlightItemId}
|
||||||
|
dataSpotlightDefault={index === 0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
handleItemClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
handleItemFocus?.(index);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => Spotlight.focus(spotlightItemId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[displayItems, tabTitle, tabIndex, panelInfo, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// cleanup useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (blockTimeoutRef.current) {
|
||||||
|
clearTimeout(blockTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 토스트가 열리면 THEME ITEM 버튼에 포커스되도록 보정
|
||||||
|
useEffect(() => {
|
||||||
|
// 약간의 지연을 주어 컴포넌트가 렌더링된 후 포커스 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
Spotlight.focus('theme-contents-close-button');
|
||||||
|
}, 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 키 이동 보정: 위/아래/좌우 이동 설정
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[Theme] focus mapping displayItems', displayItems, displayItems?.length);
|
||||||
|
if (!displayItems || displayItems.length === 0) return;
|
||||||
|
|
||||||
|
const firstItemId = 'theme-toast-item-0';
|
||||||
|
const lastIndex = displayItems.length - 1;
|
||||||
|
|
||||||
|
// THEME ITEM 버튼과 첫 번째 아이템 연결
|
||||||
|
Spotlight.set('theme-contents-close-button', {
|
||||||
|
next: {
|
||||||
|
down: firstItemId,
|
||||||
|
up: 'theme-contents-close-button',
|
||||||
|
left: 'theme-contents-close-button',
|
||||||
|
right: 'theme-contents-close-button',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 아이템의 위/아래/좌/우 네비게이션 설정
|
||||||
|
displayItems.forEach((_, index) => {
|
||||||
|
const itemId = `theme-toast-item-${index}`;
|
||||||
|
const nextItemId = index < lastIndex ? `theme-toast-item-${index + 1}` : null;
|
||||||
|
const prevItemId = index > 0 ? `theme-toast-item-${index - 1}` : null;
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
up: 'theme-contents-close-button',
|
||||||
|
down: itemId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (nextItemId) {
|
||||||
|
nextConfig.right = nextItemId;
|
||||||
|
} else {
|
||||||
|
nextConfig.right = itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevItemId) {
|
||||||
|
nextConfig.left = prevItemId;
|
||||||
|
} else {
|
||||||
|
nextConfig.left = itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Spotlight.set(itemId, { next: nextConfig });
|
||||||
|
});
|
||||||
|
}, [displayItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
className={css.toastWrapper}
|
||||||
|
spotlightId="theme-contents-container"
|
||||||
|
spotlightDefaultElement="theme-contents-close-button"
|
||||||
|
>
|
||||||
|
<ButtonContainer className={css.topButtonWrapper} spotlightId="theme-button-container">
|
||||||
|
<TButton
|
||||||
|
className={css.themeButton}
|
||||||
|
onClick={handleThemeItemButtonClick}
|
||||||
|
spotlightId="theme-contents-close-button"
|
||||||
|
data-spotlight-default
|
||||||
|
>
|
||||||
|
<div>{$L('THEME ITEM')}</div>
|
||||||
|
<img src={arrowDownIcon} className={css.themeButtonIcon} />
|
||||||
|
</TButton>
|
||||||
|
</ButtonContainer>
|
||||||
|
<ItemsContainer
|
||||||
|
className={css.itemsWrapper}
|
||||||
|
spotlightId="theme-items-container"
|
||||||
|
spotlightDefaultElement="theme-toast-item-0"
|
||||||
|
>
|
||||||
|
{displayItems && displayItems.length > 0 ? (
|
||||||
|
displayItems.map((item, index) => renderItem(item, index))
|
||||||
|
) : (
|
||||||
|
<div>No items</div>
|
||||||
|
)}
|
||||||
|
</ItemsContainer>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
// Toast wrapper 스타일
|
||||||
|
.toastWrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 450px; // padding(120) + button(60) + gap(30) + items(240) = 450px
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 60px;
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.53) 0%,
|
||||||
|
rgba(20.56, 4.68, 32.71, 0.53) 60%,
|
||||||
|
rgba(199, 32, 84, 0) 98%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.4) 45%, rgba(0, 0, 0, 0.4) 100%),
|
||||||
|
rgba(30, 30, 30, 0.8);
|
||||||
|
overflow: visible; // 포커스 테두리가 잘리지 않도록
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topButtonWrapper {
|
||||||
|
width: 635px;
|
||||||
|
height: 60px;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemsWrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
padding: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
scrollbar-width: none; // Firefox 스크롤바 숨김
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 18px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none; // WebKit 스크롤바 숨김
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
|
||||||
|
> div:nth-child(1) {
|
||||||
|
.size(@w: 100%, @h: 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeList {
|
||||||
|
width: 100%;
|
||||||
|
.flex(@display: flex, @justifyCenter: flex-start, @alignCenter: flex-start);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
margin-left: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme Item Button 스타일
|
||||||
|
.bottomButtonWrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeButton {
|
||||||
|
width: 635px !important;
|
||||||
|
height: 60px !important;
|
||||||
|
padding: 20px 30px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
border: none !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
transition: background 0.2s ease !important;
|
||||||
|
|
||||||
|
color: white !important;
|
||||||
|
font-size: 25px !important;
|
||||||
|
font-family: @baseFont !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
line-height: 35px !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
display: inline !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-right: 15px !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:global(.spotlight) {
|
||||||
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
|
outline: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeButtonIcon {
|
||||||
|
width: 52.5px;
|
||||||
|
height: 31.25px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '470px',
|
||||||
|
height: '180px',
|
||||||
|
padding: 30,
|
||||||
|
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
paddingLeft: 0.41,
|
||||||
|
paddingRight: 0.41,
|
||||||
|
paddingTop: 0.51,
|
||||||
|
paddingBottom: 0.51,
|
||||||
|
}}
|
||||||
|
src="https://placehold.co/120x120"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
color: '#EAEAEA',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
lineHeight: 25,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Orlgami Removable Connecting Rack 2 -pack
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 1,
|
||||||
|
paddingBottom: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C70850',
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$32.98
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#C2C2C2',
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'LG Smart UI',
|
||||||
|
fontWeight: '400',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
lineHeight: 16.67,
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$150.00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ justifyContent: 'flex-end', alignItems: 'flex-end', display: 'inline-flex' }}>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.11,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#BFD730',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.18,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.28,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 45.94,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 44.77,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ width: 11.7, height: 13.88, left: 21.49, top: 8.02, position: 'absolute' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 8.95,
|
||||||
|
height: 13.52,
|
||||||
|
left: 1.37,
|
||||||
|
top: 0.18,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.45,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#F37021',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.52,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.62,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 46.28,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 45.11,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ width: 11.7, height: 13.88, left: 21.49, top: 8.02, position: 'absolute' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 7.13,
|
||||||
|
height: 13.81,
|
||||||
|
left: 2.29,
|
||||||
|
top: 0.04,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12.21,
|
||||||
|
height: 28.05,
|
||||||
|
left: 41.07,
|
||||||
|
top: 1.03,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40.83,
|
||||||
|
height: 27.81,
|
||||||
|
left: 1.45,
|
||||||
|
top: 1.05,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#00A651',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 51.71,
|
||||||
|
height: 28.23,
|
||||||
|
left: 1.52,
|
||||||
|
top: 0.84,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.37px #231F20 solid',
|
||||||
|
outlineOffset: '-0.68px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6.42,
|
||||||
|
height: 7.05,
|
||||||
|
left: 44.62,
|
||||||
|
top: 2.81,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3.14,
|
||||||
|
height: 1.57,
|
||||||
|
left: 46.28,
|
||||||
|
top: 12.04,
|
||||||
|
position: 'absolute',
|
||||||
|
outline: '1.46px #231F20 solid',
|
||||||
|
outlineOffset: '-0.73px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 5.44,
|
||||||
|
height: 7.12,
|
||||||
|
left: 45.11,
|
||||||
|
top: 19.99,
|
||||||
|
position: 'absolute',
|
||||||
|
background: '#231F20',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 11.7,
|
||||||
|
height: 13.88,
|
||||||
|
left: 21.49,
|
||||||
|
top: 8.01,
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'white',
|
||||||
|
outline: '0.68px #231F20 solid',
|
||||||
|
outlineOffset: '-0.34px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Spotlight from '@enact/spotlight';
|
||||||
|
import Spottable from '@enact/spotlight/Spottable';
|
||||||
|
import css from './ThemeItemCard.module.less';
|
||||||
|
|
||||||
|
const SpottableDiv = Spottable('div');
|
||||||
|
|
||||||
|
export default function ThemeItemCard({
|
||||||
|
prdtId,
|
||||||
|
prdtNm,
|
||||||
|
prdtImgPath,
|
||||||
|
salePrice,
|
||||||
|
originalPrice,
|
||||||
|
energyLabels,
|
||||||
|
onClick,
|
||||||
|
onFocus,
|
||||||
|
onMouseEnter,
|
||||||
|
onKeyDown,
|
||||||
|
spotlightId,
|
||||||
|
dataSpotlightDefault,
|
||||||
|
onFocused,
|
||||||
|
}) {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = (e) => {
|
||||||
|
setIsFocused(true);
|
||||||
|
onFocus?.(e);
|
||||||
|
onFocused?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (e) => {
|
||||||
|
setIsFocused(false);
|
||||||
|
onFocused?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpottableDiv
|
||||||
|
className={`${css.itemCard} ${isFocused ? css.focused : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
spotlightId={spotlightId}
|
||||||
|
data-spotlight-default={dataSpotlightDefault}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
>
|
||||||
|
<img src={prdtImgPath} alt={prdtId} className={css.itemImage} />
|
||||||
|
<div className={css.itemInfo}>
|
||||||
|
<div className={css.itemName}>{prdtNm}</div>
|
||||||
|
<div className={css.itemPrice}>
|
||||||
|
<span className={css.salePrice}>{salePrice}</span>
|
||||||
|
<span className={css.originalPrice}>{originalPrice}</span>
|
||||||
|
</div>
|
||||||
|
{energyLabels && energyLabels.length > 0 && (
|
||||||
|
<div className={css.energyLabels}>
|
||||||
|
{energyLabels.map((label, labelIndex) => (
|
||||||
|
<div key={labelIndex} className={css.energyLabel}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SpottableDiv>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeItemCard.propTypes = {
|
||||||
|
prdtId: PropTypes.string,
|
||||||
|
prdtNm: PropTypes.string,
|
||||||
|
prdtImgPath: PropTypes.string,
|
||||||
|
salePrice: PropTypes.string,
|
||||||
|
originalPrice: PropTypes.string,
|
||||||
|
energyLabels: PropTypes.array,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
onFocus: PropTypes.func,
|
||||||
|
onFocused: PropTypes.func,
|
||||||
|
onMouseEnter: PropTypes.func,
|
||||||
|
onKeyDown: PropTypes.func,
|
||||||
|
spotlightId: PropTypes.string,
|
||||||
|
dataSpotlightDefault: PropTypes.bool,
|
||||||
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.itemCard {
|
||||||
|
width: 470px;
|
||||||
|
height: 180px;
|
||||||
|
padding: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: linear-gradient(0deg, 0%, 100%), #2C2C2C;
|
||||||
|
border-radius: 12px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible; // 포커스 테두리 노출
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 15px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveChannel 카드와 유사하게 포커스 테두리를 pseudo-element로 표시
|
||||||
|
&::after {
|
||||||
|
.position(@position: absolute, @top: -4px, @right: -4px, @bottom: -4px, @left: -4px);
|
||||||
|
content: '';
|
||||||
|
border: 4px solid @PRIMARY_COLOR_RED;
|
||||||
|
border-radius: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.focused::after,
|
||||||
|
&:focus::after,
|
||||||
|
&:global(.spotlight)::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemImage {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
padding-left: 0.41px;
|
||||||
|
padding-right: 0.41px;
|
||||||
|
padding-top: 0.51px;
|
||||||
|
padding-bottom: 0.51px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemInfo {
|
||||||
|
flex: 1 1 0;
|
||||||
|
align-self: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
align-self: stretch;
|
||||||
|
color: #EAEAEA;
|
||||||
|
font-size: 20px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 25px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemPrice {
|
||||||
|
padding-top: 1px;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 5px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.salePrice {
|
||||||
|
color: #C70850;
|
||||||
|
font-size: 20px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16.67;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.originalPrice {
|
||||||
|
color: #C2C2C2;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
font-weight: 400;
|
||||||
|
text-decoration: line-through;
|
||||||
|
line-height: 16.67;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energyLabels {
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 5px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.energyLabel {
|
||||||
|
width: 54px;
|
||||||
|
height: 30px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button 스타일 (기존)
|
||||||
|
.themeItemCard {
|
||||||
|
align-self: stretch;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(255, 255, 255, 0.05); // 기본 회색 배경
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 30px;
|
||||||
|
padding-right: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: @PRIMARY_COLOR_RED; // 포커스시만 빨간색
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border: 4px solid #4f172c;
|
||||||
|
background: #4f172c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내부 콘텐츠 컨테이너
|
||||||
|
.themeItemCardContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 15px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 스타일
|
||||||
|
.themeItemCardText {
|
||||||
|
color: white;
|
||||||
|
font-size: 25px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 400; // Regular (Bold가 아님)
|
||||||
|
line-height: 35;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1; // 남은 공간 차지
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 아이콘 스타일
|
||||||
|
.themeItemCardIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 26.25px;
|
||||||
|
height: 15.63px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
import { clearAllToasts } from '../../../actions/toastActions';
|
import { clearAllToasts } from '../../../actions/toastActions';
|
||||||
import TButton from '../../../components/TButton/TButton';
|
import TButton from '../../../components/TButton/TButton';
|
||||||
import TPopUp from '../../../components/TPopUp/TPopUp';
|
import TPopUp from '../../../components/TPopUp/TPopUp';
|
||||||
|
import { launchMembershipApp } from '../../../lunaSend';
|
||||||
import { BUYNOW_CONFIG } from '../../../utils/BuyNowConfig';
|
import { BUYNOW_CONFIG } from '../../../utils/BuyNowConfig';
|
||||||
import {
|
import {
|
||||||
createMockProductOptionData,
|
createMockProductOptionData,
|
||||||
@@ -638,7 +639,8 @@ const BuyOption = ({
|
|||||||
Spotlight.focus('buy-option-buy-now-button');
|
Spotlight.focus('buy-option-buy-now-button');
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBuyNowClick = useCallback(() => {
|
const handleBuyNowClick = useCallback((e) => {
|
||||||
|
e.stopPropagation()
|
||||||
console.log('%c🔥🔥🔥 BUY NOW CLICKED! FUNCTION CALLED! 🔥🔥🔥', 'background: red; color: white; font-size: 16px; font-weight: bold; padding: 5px;');
|
console.log('%c🔥🔥🔥 BUY NOW CLICKED! FUNCTION CALLED! 🔥🔥🔥', 'background: red; color: white; font-size: 16px; font-weight: bold; padding: 5px;');
|
||||||
console.log('%cproductInfo exists:', 'background: red; color: white; padding: 3px;', !!productInfo);
|
console.log('%cproductInfo exists:', 'background: red; color: white; padding: 3px;', !!productInfo);
|
||||||
console.log('%cuserNumber exists:', 'background: red; color: white; padding: 3px;', !!userNumber);
|
console.log('%cuserNumber exists:', 'background: red; color: white; padding: 3px;', !!userNumber);
|
||||||
@@ -1012,7 +1014,7 @@ const BuyOption = ({
|
|||||||
console.log('%c[BuyOption] ✅ AFTER pushPanel dispatch', 'background: orange; color: white; font-weight: bold; padding: 5px;');
|
console.log('%c[BuyOption] ✅ AFTER pushPanel dispatch', 'background: orange; color: white; font-weight: bold; padding: 5px;');
|
||||||
|
|
||||||
// Toast 정리는 성공적으로 패널 이동 후에만 실행
|
// Toast 정리는 성공적으로 패널 이동 후에만 실행
|
||||||
dispatch(clearAllToasts());
|
// dispatch(clearAllToasts());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -1034,7 +1036,8 @@ const BuyOption = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// ADD TO CART 버튼 클릭 핸들러
|
// ADD TO CART 버튼 클릭 핸들러
|
||||||
const handleAddToCartClick = useCallback(() => {
|
const handleAddToCartClick = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
console.log('[BuyOption] ADD TO CART clicked');
|
console.log('[BuyOption] ADD TO CART clicked');
|
||||||
|
|
||||||
const isMock = isMockMode;
|
const isMock = isMockMode;
|
||||||
@@ -1086,6 +1089,46 @@ const BuyOption = ({
|
|||||||
optionForUse?.optNm || optionForUse?.prodOptCval || '';
|
optionForUse?.optNm || optionForUse?.prodOptCval || '';
|
||||||
|
|
||||||
if (!isMock) {
|
if (!isMock) {
|
||||||
|
if (
|
||||||
|
productOptionInfos &&
|
||||||
|
productOptionInfos.length > 1 &&
|
||||||
|
productInfo?.optProdYn === 'Y'
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (selectFirstOptionIndex === 0) {
|
||||||
|
dispatch(
|
||||||
|
showError(
|
||||||
|
null,
|
||||||
|
"PLEASE SELECT OPTION",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl &&
|
||||||
|
productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl.length > 1 &&
|
||||||
|
productInfo?.optProdYn === 'Y'
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (selectSecondOptionIndex === 0) {
|
||||||
|
dispatch(
|
||||||
|
showError(
|
||||||
|
null,
|
||||||
|
"PLEASE SELECT OPTION",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
dispatch(
|
dispatch(
|
||||||
insertMyinfoCart({
|
insertMyinfoCart({
|
||||||
mbrNo: userNumber,
|
mbrNo: userNumber,
|
||||||
@@ -1139,17 +1182,18 @@ const BuyOption = ({
|
|||||||
name: optionLabel,
|
name: optionLabel,
|
||||||
price: optionForUse?.optPrc || '0.00',
|
price: optionForUse?.optPrc || '0.00',
|
||||||
};
|
};
|
||||||
|
dispatch(setShowPopup(Config.ACTIVE_POPUP.addCartPopup));
|
||||||
dispatch(
|
return;
|
||||||
pushPanel({
|
// dispatch(
|
||||||
name: Config.panel_names.CART_PANEL,
|
// pushPanel({
|
||||||
panelInfo: {
|
// name: Config.panel_names.CART_PANEL,
|
||||||
productInfo: productInfoForCart,
|
// panelInfo: {
|
||||||
optionInfo: optionInfoForCart,
|
// productInfo: productInfoForCart,
|
||||||
quantity: effectiveQuantity,
|
// optionInfo: optionInfoForCart,
|
||||||
},
|
// quantity: effectiveQuantity,
|
||||||
})
|
// },
|
||||||
);
|
// })
|
||||||
|
// );
|
||||||
} else {
|
} else {
|
||||||
console.log('[BuyOption] Mock Mode - Adding to cart (Mock)');
|
console.log('[BuyOption] Mock Mode - Adding to cart (Mock)');
|
||||||
|
|
||||||
@@ -1362,7 +1406,7 @@ const BuyOption = ({
|
|||||||
if (typeof spotlightId === 'string') {
|
if (typeof spotlightId === 'string') {
|
||||||
currentSpot = spotlightId;
|
currentSpot = spotlightId;
|
||||||
} else {
|
} else {
|
||||||
currentSpot = 'buy-option-buy-now-button';
|
currentSpot = 'buy-option-add-to-cart-button';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentSpot) {
|
if (currentSpot) {
|
||||||
@@ -1383,7 +1427,7 @@ const BuyOption = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
dispatch(setHidePopup());
|
dispatch(setHidePopup());
|
||||||
// dispatch(launchMembershipApp()); // 필요시 추가
|
dispatch(launchMembershipApp());
|
||||||
} else {
|
} else {
|
||||||
dispatch(setShowPopup(Config.ACTIVE_POPUP.qrPopup));
|
dispatch(setShowPopup(Config.ACTIVE_POPUP.qrPopup));
|
||||||
}
|
}
|
||||||
@@ -1406,8 +1450,25 @@ const BuyOption = ({
|
|||||||
}
|
}
|
||||||
}, [dispatch, hasOnClose, isOptionValue, webOSVersion, userNumber]);
|
}, [dispatch, hasOnClose, isOptionValue, webOSVersion, userNumber]);
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
return(()=>{
|
||||||
|
dispatch(clearAllToasts());
|
||||||
|
})
|
||||||
|
},[])
|
||||||
|
|
||||||
|
const handleCartMove = useCallback(() => {
|
||||||
|
dispatch(setHidePopup());
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: Config.panel_names.CART_PANEL,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},[dispatch])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className={styles.buy_option}>
|
<Container
|
||||||
|
className={styles.buy_option}
|
||||||
|
>
|
||||||
<div className={styles.buy_option__left_section}>
|
<div className={styles.buy_option__left_section}>
|
||||||
{/* 동적 옵션 렌더링 */}
|
{/* 동적 옵션 렌더링 */}
|
||||||
{productOptionInfos &&
|
{productOptionInfos &&
|
||||||
@@ -1502,6 +1563,7 @@ const BuyOption = ({
|
|||||||
productInfo={productInfo || productData}
|
productInfo={productInfo || productData}
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
patncNm={patncNm || productData?.patncNm || productInfo?.patncNm}
|
patncNm={patncNm || productData?.patncNm || productInfo?.patncNm}
|
||||||
|
quantity={quantity}
|
||||||
/>
|
/>
|
||||||
<div className={styles.buy_option__button_section}>
|
<div className={styles.buy_option__button_section}>
|
||||||
<TButton
|
<TButton
|
||||||
@@ -1540,8 +1602,24 @@ const BuyOption = ({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activePopup === Config.ACTIVE_POPUP.addCartPopup && (
|
||||||
|
<TPopUp
|
||||||
|
kind="textPopup"
|
||||||
|
hasText
|
||||||
|
open={popupVisible}
|
||||||
|
text={"Added to Cart"}
|
||||||
|
hasButton
|
||||||
|
hasOnClose={hasOnClose}
|
||||||
|
button1Text={$L('VIEW CART')}
|
||||||
|
button2Text={$L('CONTINUE SHOPPING')}
|
||||||
|
onClick={handleCartMove}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default BuyOption;
|
export default BuyOption;
|
||||||
|
|||||||
@@ -133,3 +133,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[id="floatLayer"] > div:not([id]) > div > div:nth-child(2) {
|
||||||
|
bottom: 54%;
|
||||||
|
transform: translateY(50%);
|
||||||
|
overflow: initial;
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import usePriceInfo from '../../../hooks/usePriceInfo';
|
import usePriceInfo from '../../../hooks/usePriceInfo';
|
||||||
import { $L } from '../../../utils/helperMethods';
|
import { $L } from '../../../utils/helperMethods';
|
||||||
|
|
||||||
import styles from './BuyOptionPriceBlock.module.less';
|
import styles from './BuyOptionPriceBlock.module.less';
|
||||||
|
|
||||||
const BuyOptionPriceBlock = ({ patncNm, productInfo, selectedOptions, className }) => {
|
const BuyOptionPriceBlock = ({ patncNm, productInfo, selectedOptions, quantity, className }) => {
|
||||||
const productData = useSelector((state) => state.main.productData);
|
const productData = useSelector((state) => state.main.productData);
|
||||||
const sourceProduct = productInfo || productData;
|
const sourceProduct = productInfo || productData;
|
||||||
const { shippingCharge } = sourceProduct || {};
|
const { shippingCharge } = sourceProduct || {};
|
||||||
@@ -23,6 +23,26 @@ const BuyOptionPriceBlock = ({ patncNm, productInfo, selectedOptions, className
|
|||||||
return $L('Price');
|
return $L('Price');
|
||||||
}, [patncNm]);
|
}, [patncNm]);
|
||||||
|
|
||||||
|
// 가격에서 통화 기호와 숫자 분리
|
||||||
|
const extractPriceInfo = (priceString) => {
|
||||||
|
if (!priceString) return { currencySymbol: '', numericPrice: 0 };
|
||||||
|
|
||||||
|
const numericPrice = parseFloat(priceString.replace(/[^0-9.]/g, ''));
|
||||||
|
const currencySymbol = priceString.replace(/[0-9.]/g, '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
currencySymbol: currencySymbol || '',
|
||||||
|
numericPrice: isNaN(numericPrice) ? 0 : numericPrice
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// quantity를 곱한 총 가격 계산
|
||||||
|
const totalPrice = useMemo(() => {
|
||||||
|
const { currencySymbol, numericPrice } = extractPriceInfo(discountedPrice);
|
||||||
|
const total = numericPrice * (quantity || 1);
|
||||||
|
return `${currencySymbol}${total.toFixed(2)}`;
|
||||||
|
}, [discountedPrice, quantity]);
|
||||||
|
|
||||||
const showDiscountBadge = discountedPrice && originalPrice && discountedPrice !== originalPrice;
|
const showDiscountBadge = discountedPrice && originalPrice && discountedPrice !== originalPrice;
|
||||||
|
|
||||||
if (!discountedPrice && !shippingCharge) {
|
if (!discountedPrice && !shippingCharge) {
|
||||||
@@ -35,7 +55,7 @@ const BuyOptionPriceBlock = ({ patncNm, productInfo, selectedOptions, className
|
|||||||
<div className={styles.price_row}>
|
<div className={styles.price_row}>
|
||||||
<span className={styles.price_label}>{priceLabel}</span>
|
<span className={styles.price_label}>{priceLabel}</span>
|
||||||
<div className={styles.price_value_group}>
|
<div className={styles.price_value_group}>
|
||||||
<span className={styles.price_value}>{discountedPrice}</span>
|
<span className={styles.price_value}>{totalPrice}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
// src/views/DetailPanel/components/DetailPanelBackground/DetailPanelBackground.jsx
|
// src/views/DetailPanel/components/DetailPanelBackground/DetailPanelBackground.jsx
|
||||||
import React, { useEffect } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import ImagePreloader from '../../../../utils/ImagePreloader';
|
||||||
|
|
||||||
|
import hsn from '../../../../../assets/images/bg/hsn_new.png';
|
||||||
|
import koreaKiosk from '../../../../../assets/images/bg/koreaKiosk_new.png';
|
||||||
|
import lgelectronics from '../../../../../assets/images/bg/lgelectronics_new.png';
|
||||||
|
import ontv4u from '../../../../../assets/images/bg/ontv4u_new.png';
|
||||||
|
import Pinkfong from '../../../../../assets/images/bg/Pinkfong_new.png';
|
||||||
|
import qvc from '../../../../../assets/images/bg/qvc_new.png';
|
||||||
|
import shoplc from '../../../../../assets/images/bg/shoplc_new.png';
|
||||||
import css from './DetailPanelBackground.module.less';
|
import css from './DetailPanelBackground.module.less';
|
||||||
import detailPanelBg from '../../../../../assets/images/detailpanel/detailpanel-bg-1.png';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DetailPanel의 배경 이미지와 그라데이션을 렌더링하는 컴포넌트
|
* DetailPanel의 배경 이미지와 그라데이션을 렌더링하는 컴포넌트
|
||||||
@@ -11,36 +20,153 @@ import detailPanelBg from '../../../../../assets/images/detailpanel/detailpanel-
|
|||||||
* - true: PlayerPanel의 MEDIA 재생 완료 후 진입 (updatePanel로 전달됨)
|
* - true: PlayerPanel의 MEDIA 재생 완료 후 진입 (updatePanel로 전달됨)
|
||||||
* - false/undefined: 다른 패널(Shop Now, You May Like 등)에서 진입
|
* - false/undefined: 다른 패널(Shop Now, You May Like 등)에서 진입
|
||||||
* - 이 값에 따라 배경 UI를 다르게 표시할 수 있음
|
* - 이 값에 따라 배경 UI를 다르게 표시할 수 있음
|
||||||
|
* @param {Function} onImageReady - 이미지가 DOM에 완전히 렌더링되었을 때 호출되는 콜백
|
||||||
*/
|
*/
|
||||||
export default function DetailPanelBackground({ launchedFromPlayer = false }) {
|
export default function DetailPanelBackground({
|
||||||
|
launchedFromPlayer = false,
|
||||||
|
patnrId,
|
||||||
|
onImageReady,
|
||||||
|
}) {
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const imgRef = useRef(null);
|
||||||
|
|
||||||
|
const BG_MAP = {
|
||||||
|
1: qvc,
|
||||||
|
2: hsn,
|
||||||
|
4: ontv4u,
|
||||||
|
9: lgelectronics,
|
||||||
|
11: shoplc,
|
||||||
|
16: koreaKiosk,
|
||||||
|
19: Pinkfong,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailPanelBg = useMemo(() => {
|
||||||
|
return BG_MAP[patnrId] || qvc;
|
||||||
|
}, [patnrId]);
|
||||||
|
|
||||||
|
// ✅ 이미지가 DOM에 완전히 렌더링되었는지 확인하는 함수
|
||||||
|
const checkImageFullyLoaded = useCallback(() => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return false;
|
||||||
|
|
||||||
|
// 이미지 로드 완료 및 실제 크기가 파싱됨
|
||||||
|
const isLoaded = img.complete && img.naturalWidth > 0;
|
||||||
|
// 렌더링된 DOM에 크기가 설정됨
|
||||||
|
const isRendered = img.offsetHeight > 0 && img.offsetWidth > 0;
|
||||||
|
|
||||||
|
const isDOMReady = isLoaded && isRendered;
|
||||||
|
|
||||||
|
// if (isDOMReady) {
|
||||||
|
// console.log('[DetailPanelBackground] ✅ 이미지 DOM 완전 렌더링 완료:', {
|
||||||
|
// imgSrc: detailPanelBg,
|
||||||
|
// complete: img.complete,
|
||||||
|
// naturalWidth: img.naturalWidth,
|
||||||
|
// naturalHeight: img.naturalHeight,
|
||||||
|
// offsetHeight: img.offsetHeight,
|
||||||
|
// offsetWidth: img.offsetWidth,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
return isDOMReady;
|
||||||
|
}, [detailPanelBg]);
|
||||||
|
|
||||||
|
// 🔧 [251119] PlayerPanel에서 올라온 DetailPanel은 배경 이미지를 표시하지 않음
|
||||||
|
// launchedFromPlayer가 false일 때만 배경 이미지를 로드하고 표시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[DetailPanelBackground] 배경 이미지 경로:', detailPanelBg);
|
// launchedFromPlayer가 true이면 배경 이미지를 로드하지 않음 (PlayerPanel 비디오 보이도록)
|
||||||
console.log('[DetailPanelBackground] launchedFromPlayer:', launchedFromPlayer);
|
if (launchedFromPlayer) {
|
||||||
}, [launchedFromPlayer]);
|
// console.log('[DetailPanelBackground] Skip background image loading - launchedFromPlayer=true (showing PlayerPanel video)');
|
||||||
|
setImageReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// launchedFromPlayer가 false일 때만 배경 이미지 로드
|
||||||
|
// console.log('[DetailPanelBackground] Loading background image - launchedFromPlayer=false');
|
||||||
|
if (ImagePreloader.isLoaded(detailPanelBg)) {
|
||||||
|
// console.log('[DetailPanelBackground] Using preloaded image:', detailPanelBg);
|
||||||
|
setImageReady(true);
|
||||||
|
} else {
|
||||||
|
// 프리로드되지 않았다면 즉시 로드 시도
|
||||||
|
// console.log('[DetailPanelBackground] Image not preloaded, loading on-demand:', detailPanelBg);
|
||||||
|
ImagePreloader.preloadImage(detailPanelBg)
|
||||||
|
.then(() => {
|
||||||
|
// console.log('[DetailPanelBackground] On-demand image loaded:', detailPanelBg);
|
||||||
|
setImageReady(true);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
// console.error('[DetailPanelBackground] On-demand image load failed:', e);
|
||||||
|
// 실패해도 이미지를 표시해야 함
|
||||||
|
setImageReady(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [detailPanelBg, launchedFromPlayer]); // launchedFromPlayer 추가
|
||||||
|
|
||||||
|
// ✅ 이미지가 DOM에 완전히 렌더링되면 부모에 콜백 호출
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageReady || !imgRef.current) return;
|
||||||
|
|
||||||
|
// 이미 렌더링된 경우 (캐시된 이미지)
|
||||||
|
if (checkImageFullyLoaded()) {
|
||||||
|
if (onImageReady) {
|
||||||
|
onImageReady();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 아직 렌더링 중인 경우: onLoad 대기
|
||||||
|
const img = imgRef.current;
|
||||||
|
const handleLoad = () => {
|
||||||
|
if (checkImageFullyLoaded()) {
|
||||||
|
if (onImageReady) {
|
||||||
|
onImageReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// img 요소가 ready 상태라면 즉시 실행
|
||||||
|
if (img.complete) {
|
||||||
|
handleLoad();
|
||||||
|
} else {
|
||||||
|
img.addEventListener('load', handleLoad);
|
||||||
|
return () => {
|
||||||
|
img.removeEventListener('load', handleLoad);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [imageReady, onImageReady, checkImageFullyLoaded]);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// console.log('[DetailPanelBackground] 배경 이미지 경로:', detailPanelBg);
|
||||||
|
// console.log('[DetailPanelBackground] launchedFromPlayer:', launchedFromPlayer);
|
||||||
|
// console.log('[DetailPanelBackground] imageReady:', imageReady);
|
||||||
|
// }, [detailPanelBg, launchedFromPlayer, imageReady]);
|
||||||
|
|
||||||
|
//partnrId 1 = QVC, 2 = HSN, 4 = ONTV, 9 = LG ELECTRONICS, 11 = SHOPLC, 19 = PINKPONG, 16 = KOREA KIOSK,
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.backgroundContainer}>
|
<div className={css.backgroundContainer}>
|
||||||
{/* 실제 배경 이미지 */}
|
{/* 이미지가 준비되지 않았을 때 placeholder 표시 */}
|
||||||
{!launchedFromPlayer && (
|
{!imageReady && <div className={css.backgroundPlaceholder} aria-hidden="true" />}
|
||||||
|
|
||||||
|
{/* 실제 배경 이미지 - launchedFromPlayer가 false일 때만 표시 */}
|
||||||
|
{imageReady && !launchedFromPlayer && (
|
||||||
<img
|
<img
|
||||||
|
ref={imgRef}
|
||||||
src={detailPanelBg}
|
src={detailPanelBg}
|
||||||
alt=""
|
alt=""
|
||||||
className={css.backgroundImage}
|
className={css.backgroundImage}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onLoad={() => console.log('[DetailPanelBackground] 이미지 로드 완료')}
|
// onLoad={() => console.log('[DetailPanelBackground] 이미지 로드 완료')}
|
||||||
onError={(e) => console.error('[DetailPanelBackground] 이미지 로드 실패:', e)}
|
// onError={(e) => console.error('[DetailPanelBackground] 이미지 로드 실패:', e)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 그라데이션 레이어들 - CSS의 linear-gradient를 div로 구현 */}
|
{/* 그라데이션 레이어들 - launchedFromPlayer일 때만 추가 */}
|
||||||
{/* 1. 270도 방향 그라데이션 (왼쪽→오른쪽, 투명→불투명) */}
|
{/* 1. 270도 방향 그라데이션 (왼쪽→오른쪽, 투명→불투명) */}
|
||||||
<div className={css.gradientLayer1} aria-hidden="true" />
|
{launchedFromPlayer && <div className={css.gradientLayer1} aria-hidden="true" />}
|
||||||
|
|
||||||
{/* 2. 180도 방향 그라데이션 (위→아래, 투명→불투명) */}
|
{/* 2. 180도 방향 그라데이션 (위→아래, 투명→불투명) */}
|
||||||
<div className={css.gradientLayer2} aria-hidden="true" />
|
{launchedFromPlayer && <div className={css.gradientLayer2} aria-hidden="true" />}
|
||||||
|
|
||||||
{/* 3. 투명 그라데이션 */}
|
{/* 3. 투명 그라데이션 */}
|
||||||
<div className={css.gradientLayer3} aria-hidden="true" />
|
{launchedFromPlayer && <div className={css.gradientLayer3} aria-hidden="true" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,19 @@
|
|||||||
pointer-events: none; // 클릭 이벤트가 아래로 통과하도록
|
pointer-events: none; // 클릭 이벤트가 아래로 통과하도록
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 배경 이미지 로딩 전 placeholder
|
||||||
|
.backgroundPlaceholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%);
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
// 실제 배경 이미지
|
// 실제 배경 이미지
|
||||||
.backgroundImage {
|
.backgroundImage {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -23,6 +36,8 @@
|
|||||||
object-fit: cover; // 화면 크기에 맞춰 이미지 조정
|
object-fit: cover; // 화면 크기에 맞춰 이미지 조정
|
||||||
object-position: center;
|
object-position: center;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그라데이션 레이어 1: 270도 방향 (왼쪽→오른쪽, 투명→불투명)
|
// 그라데이션 레이어 1: 270도 방향 (왼쪽→오른쪽, 투명→불투명)
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// src/views/DetailPanel/components/DetailPanelBackground/DetailPanelBackground.v2.jsx
|
||||||
|
import React, { useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
|
// 이미지 imports
|
||||||
|
import hsn from '../../../../../assets/images/bg/hsn_new.png';
|
||||||
|
import koreaKiosk from '../../../../../assets/images/bg/koreaKiosk_new.png';
|
||||||
|
import lgelectronics from '../../../../../assets/images/bg/lgelectronics_new.png';
|
||||||
|
import ontv4u from '../../../../../assets/images/bg/ontv4u_new.png';
|
||||||
|
import Pinkfong from '../../../../../assets/images/bg/Pinkfong_new.png';
|
||||||
|
import qvc from '../../../../../assets/images/bg/qvc_new.png';
|
||||||
|
import shoplc from '../../../../../assets/images/bg/shoplc_new.png';
|
||||||
|
import css from './DetailPanelBackground.module.less';
|
||||||
|
|
||||||
|
// ==================== 로깅 함수들 ====================
|
||||||
|
/**
|
||||||
|
* DetailPanelBackgroundV2 초기화 로그
|
||||||
|
* @param {number} patnrId - 파트너사 ID
|
||||||
|
* @param {boolean} visible - 표시 여부
|
||||||
|
* @param {string} imageUrl - 이미지 URL
|
||||||
|
*/
|
||||||
|
// const logDetailPanelInit = (patnrId, visible, imageUrl) => {
|
||||||
|
// console.log(`[DetailPanelBackgroundV2] patnrId: ${patnrId}, visible: ${visible}, imageUrl: ${imageUrl}`);
|
||||||
|
// };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 로드 성공 로그
|
||||||
|
* @param {number} patnrId - 파트너사 ID
|
||||||
|
*/
|
||||||
|
// const logImageLoaded = (patnrId) => {
|
||||||
|
// console.log(`[DetailPanelBackgroundV2] Image loaded: patnrId=${patnrId}`);
|
||||||
|
// };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 로드 실패 로그
|
||||||
|
* @param {number} patnrId - 파트너사 ID
|
||||||
|
* @param {Error} error - 에러 객체
|
||||||
|
*/
|
||||||
|
// const logImageError = (patnrId, error) => {
|
||||||
|
// console.error(`[DetailPanelBackgroundV2] Image load failed: patnrId=${patnrId}`, error);
|
||||||
|
// };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개선된 배경 이미지 컴포넌트 v2
|
||||||
|
* HomePanel에 미리 로드되어 메모리에 상주하며, visible props로 표시 여부만 제어
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {number} props.patnrId - 파트너사 ID
|
||||||
|
* @param {boolean} props.visible - 표시 여부 (HomePanel이 isOnTop일 때 false)
|
||||||
|
* @param {boolean} props.launchedFromPlayer - PlayerPanel에서 진입했는지 여부
|
||||||
|
* @param {boolean} props.usePlaceholder - placeholder 표시 여부
|
||||||
|
*/
|
||||||
|
export default function DetailPanelBackgroundV2({
|
||||||
|
patnrId,
|
||||||
|
visible = true,
|
||||||
|
launchedFromPlayer = false,
|
||||||
|
usePlaceholder = false,
|
||||||
|
}) {
|
||||||
|
// 파트너사별 배경 이미지 맵
|
||||||
|
const BG_MAP = useMemo(
|
||||||
|
() => ({
|
||||||
|
1: qvc, // QVC
|
||||||
|
2: hsn, // HSN
|
||||||
|
4: ontv4u, // ONTV4U
|
||||||
|
9: lgelectronics, // LG ELECTRONICS
|
||||||
|
11: shoplc, // SHOPLC
|
||||||
|
16: koreaKiosk, // KOREA KIOSK
|
||||||
|
19: Pinkfong, // PINKFONG
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const backgroundImageUrl = useMemo(() => {
|
||||||
|
return BG_MAP[patnrId] || qvc; // 기본값은 QVC
|
||||||
|
}, [patnrId, BG_MAP]);
|
||||||
|
|
||||||
|
// useCallback으로 메모이제이션된 핸들러
|
||||||
|
const handleImageLoad = useCallback(() => {
|
||||||
|
logImageLoaded(patnrId);
|
||||||
|
}, [patnrId]);
|
||||||
|
|
||||||
|
const handleImageError = useCallback(
|
||||||
|
(e) => {
|
||||||
|
logImageError(patnrId, e);
|
||||||
|
},
|
||||||
|
[patnrId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 개발 환경에서만 로깅
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
logDetailPanelInit(patnrId, visible, backgroundImageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css.backgroundContainerV2}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
zIndex: 50, // HomePanel(z-index: 1)보다 높고, DetailPanel(z-index: 100)보다 낮음
|
||||||
|
visibility: visible ? 'visible' : 'hidden',
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transition: 'opacity 0.3s ease-in-out, visibility 0.3s ease-in-out',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* PlayerPanel에서 진입한 경우 이미지를 표시하지 않고 그라데이션만 표시 */}
|
||||||
|
{launchedFromPlayer ? (
|
||||||
|
// 그라데이션 레이어들만 표시
|
||||||
|
<>
|
||||||
|
{/* 1. 270도 방향 그라데이션 (왼쪽→오른쪽, 투명→불투명) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background:
|
||||||
|
'linear-gradient(270deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.77) 70%, rgba(0, 0, 0, 1) 100%)',
|
||||||
|
zIndex: 3,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* 2. 180도 방향 그라데이션 (위→아래, 투명→불투명) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: 'linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)',
|
||||||
|
zIndex: 4,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* 3. 투명 그라데이션 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0))',
|
||||||
|
zIndex: 5,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : usePlaceholder ? (
|
||||||
|
// placeholder 모드
|
||||||
|
<div
|
||||||
|
className={css.backgroundPlaceholder}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%)',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 실제 배경 이미지
|
||||||
|
<img
|
||||||
|
src={backgroundImageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HomePanel에서 사용할 모든 배경 이미지 미리 로딩 컴포넌트
|
||||||
|
* HomePanel이 렌더링될 때 모든 파트너사 배경 이미지를 미리 로드하여 메모리에 상주시킴
|
||||||
|
*/
|
||||||
|
export function PreloadedBackgroundImages({
|
||||||
|
selectedPatnrId,
|
||||||
|
isHomePanelOnTop = true,
|
||||||
|
launchedFromPlayer = false,
|
||||||
|
}) {
|
||||||
|
// 모든 파트너사 ID 목록
|
||||||
|
const allPatnrIds = useMemo(() => [1, 2, 4, 9, 11, 16, 19], []);
|
||||||
|
|
||||||
|
// ✅ 원래 로직 복원: HomePanel이 onTop이 아니고 selectedPatnrId가 있을 때만 배경 표시
|
||||||
|
const shouldShowBackground = !isHomePanelOnTop && selectedPatnrId;
|
||||||
|
|
||||||
|
// ✅ 디버깅 로그 추가
|
||||||
|
useMemo(() => {
|
||||||
|
console.log('[PreloadedBackgroundImages] Debug info:', {
|
||||||
|
selectedPatnrId,
|
||||||
|
isHomePanelOnTop,
|
||||||
|
launchedFromPlayer,
|
||||||
|
shouldShowBackground,
|
||||||
|
allPatnrIds,
|
||||||
|
});
|
||||||
|
}, [selectedPatnrId, isHomePanelOnTop, launchedFromPlayer, shouldShowBackground]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{allPatnrIds.map((patnrId) => {
|
||||||
|
// ✅ 원래 로직: DetailPanel에서 선택된 patnrId와 일치하는 배경만 표시
|
||||||
|
const isVisible = shouldShowBackground && patnrId === selectedPatnrId;
|
||||||
|
console.log(`[PreloadedBackgroundImages] patnrId ${patnrId}, visible: ${isVisible}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPanelBackgroundV2
|
||||||
|
key={`bg-${patnrId}`}
|
||||||
|
patnrId={patnrId}
|
||||||
|
visible={isVisible}
|
||||||
|
launchedFromPlayer={launchedFromPlayer}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export default function FavoriteBtn({
|
|||||||
onFavoriteFlagChanged,
|
onFavoriteFlagChanged,
|
||||||
logMenu,
|
logMenu,
|
||||||
kind,
|
kind,
|
||||||
|
nextDownId,
|
||||||
}) {
|
}) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { popupVisible, activePopup } = useSelector((state) => state.common.popup);
|
const { popupVisible, activePopup } = useSelector((state) => state.common.popup);
|
||||||
@@ -66,19 +67,46 @@ export default function FavoriteBtn({
|
|||||||
onFavoriteFlagChanged(favoriteFlag === 'Y' ? 'N' : 'Y');
|
onFavoriteFlagChanged(favoriteFlag === 'Y' ? 'N' : 'Y');
|
||||||
}, [dispatch, favoriteFlag, onFavoriteFlagChanged]);
|
}, [dispatch, favoriteFlag, onFavoriteFlagChanged]);
|
||||||
|
|
||||||
|
const handleSpotlightDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (kind === 'item_detail') {
|
||||||
|
e.preventDefault();
|
||||||
|
const targetId = nextDownId || 'product-details-button';
|
||||||
|
const moved = Spotlight.focus(targetId);
|
||||||
|
// 포커스가 비거나 다른 곳으로 튀는 경우를 방어
|
||||||
|
if (!moved) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const current = Spotlight.getCurrent();
|
||||||
|
const currentId = current?.dataset?.spotlightId;
|
||||||
|
if (!current || currentId !== targetId) {
|
||||||
|
Spotlight.focus(targetId);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[kind, nextDownId]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpottableDiv
|
<SpottableDiv
|
||||||
className={classNames(css.favorBtnContainer, kind === 'item_detail' ? css.smallSize : '')}
|
|
||||||
spotlightId="favoriteBtn"
|
spotlightId="favoriteBtn"
|
||||||
|
className={classNames(css.favorBtnContainer, kind === 'item_detail' ? css.smallSize : '')}
|
||||||
|
role="button"
|
||||||
|
aria-label="Register in Favorites"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleFavoriteClick}
|
||||||
|
onSpotlightDown={handleSpotlightDown}
|
||||||
|
data-spotlight-next-down={
|
||||||
|
kind === 'item_detail' ? nextDownId || 'product-details-button' : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
css.favoriteButton,
|
css.favoriteButton,
|
||||||
favoriteFlag === 'N' ? css.favorBtn : css.favorUnableBtn
|
favoriteFlag === 'N' ? css.favorBtn : css.favorUnableBtn
|
||||||
)}
|
)}
|
||||||
onClick={handleFavoriteClick}
|
|
||||||
role="button"
|
|
||||||
aria-label="Register in Favorites"
|
|
||||||
/>
|
/>
|
||||||
{activePopup === Config.ACTIVE_POPUP.favoritePopup && (
|
{activePopup === Config.ACTIVE_POPUP.favoritePopup && (
|
||||||
<TPopUp
|
<TPopUp
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSelector } from 'react-redux';
|
|||||||
import { off, on } from '@enact/core/dispatcher';
|
import { off, on } from '@enact/core/dispatcher';
|
||||||
import { Job } from '@enact/core/util';
|
import { Job } from '@enact/core/util';
|
||||||
import Scroller from '@enact/sandstone/Scroller';
|
import Scroller from '@enact/sandstone/Scroller';
|
||||||
|
import Spotlight from '@enact/spotlight';
|
||||||
|
|
||||||
import AutoScrollAreaDetail, { POSITION } from '../AutoScrollAreaDetail/AutoScrollAreaDetail';
|
import AutoScrollAreaDetail, { POSITION } from '../AutoScrollAreaDetail/AutoScrollAreaDetail';
|
||||||
import css from './TScrollerDetail.module.less';
|
import css from './TScrollerDetail.module.less';
|
||||||
@@ -41,6 +42,7 @@ const TScrollerDetail = forwardRef(
|
|||||||
|
|
||||||
const isScrolling = useRef(false);
|
const isScrolling = useRef(false);
|
||||||
const scrollPosition = useRef('top');
|
const scrollPosition = useRef('top');
|
||||||
|
const thumbElementRef = useRef(null); // 스크롤바 thumb 요소 저장
|
||||||
|
|
||||||
const scrollToRef = useRef(null);
|
const scrollToRef = useRef(null);
|
||||||
const scrollHorizontalPos = useRef(0);
|
const scrollHorizontalPos = useRef(0);
|
||||||
@@ -100,6 +102,12 @@ const TScrollerDetail = forwardRef(
|
|||||||
|
|
||||||
const _onScrollStart = useCallback(
|
const _onScrollStart = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
// 스크롤 시작 시 현재 포커스된 요소가 thumb인지 확인하고 저장
|
||||||
|
const currentFocused = Spotlight.getCurrent();
|
||||||
|
if (currentFocused && currentFocused.getAttribute('aria-label')?.includes('scroll')) {
|
||||||
|
thumbElementRef.current = currentFocused;
|
||||||
|
}
|
||||||
|
|
||||||
if (onScrollStart) {
|
if (onScrollStart) {
|
||||||
onScrollStart(e);
|
onScrollStart(e);
|
||||||
}
|
}
|
||||||
@@ -148,6 +156,13 @@ const TScrollerDetail = forwardRef(
|
|||||||
if (setCheckScrollPosition && prevPosition !== scrollPosition.current) {
|
if (setCheckScrollPosition && prevPosition !== scrollPosition.current) {
|
||||||
setCheckScrollPosition(scrollPosition.current);
|
setCheckScrollPosition(scrollPosition.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 스크롤 완료 후 thumb으로 포커스 복구
|
||||||
|
if (thumbElementRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
Spotlight.focus(thumbElementRef.current);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[onScrollStop]
|
[onScrollStop]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,6 +80,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 스크롤바 thumb의 포커스 상태
|
||||||
|
> div:nth-child(1) {
|
||||||
|
> div {
|
||||||
|
&:focus {
|
||||||
|
background-color: @PRIMARY_COLOR_RED !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.preventScroll {
|
&.preventScroll {
|
||||||
|
|||||||