[251012] fix: ProductAllSection 렌더링 최적화-1

🕐 커밋 시간: 2025. 10. 12. 08:02:13

📊 변경 통계:
  • 총 파일: 3개
  • 추가: +48줄
  • 삭제: -313줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx

🗑️ 삭제된 파일:
  - com.twin.app.shoptime/src/views/MediaPanel/README.md

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: SpotlightContainerDecorator(), extractProductMeta()
  📄 com.twin.app.shoptime/src/views/MediaPanel/README.md (md파일):
     Deleted: dispatch(), useDispatch(), useRef()

🔧 주요 변경 내용:
  • 개발 문서 및 가이드 개선

Performance: 코드 최적화로 성능 개선 기대
This commit is contained in:
2025-10-12 08:02:15 +09:00
parent a79b57ff4b
commit 159dfa3f3a
3 changed files with 50 additions and 322 deletions

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { throttle } from 'lodash';
// import { throttle } from 'lodash';
import { PropTypes } from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
@@ -93,7 +93,6 @@ const HorizontalContainer = SpotlightContainerDecorator(
'div'
);
// FP: Pure function to determine product data based on typeP
const getProductData = curry((productType, themeProductInfo, productInfo) =>
pipe(
when(
@@ -105,8 +104,6 @@ const getProductData = curry((productType, themeProductInfo, productInfo) =>
)(productInfo)
);
// FP: Pure function to derive favorite flag
// 명확성을 위해 단순화된 버전으로 변경
const deriveFavoriteFlag = curry((favoriteOverride, productData) => {
// favoriteOverride가 null/undefined가 아니면 그 값을 사용
if (isNotNil(favoriteOverride)) {
@@ -116,7 +113,6 @@ const deriveFavoriteFlag = curry((favoriteOverride, productData) => {
return pipe(get('favorYn'), defaultTo('N'))(productData);
});
// FP: Pure function to extract review grade and order phone
const extractProductMeta = (productInfo) => ({
revwGrd: get('revwGrd', productInfo),
orderPhnNo: get('orderPhnNo', productInfo),
@@ -301,28 +297,27 @@ export default function ProductAllSection({
}, []);
// 디버깅: 실제 이미지 및 동영상 데이터 확인
useEffect(() => {
console.log('[ProductId] ProductAllSection productData check:', {
hasProductData: !!productData,
productDataPrdtId: productData && productData.prdtId,
imgUrls600: productData && productData.imgUrls600,
imgUrls600Length: productData && productData.imgUrls600 && productData.imgUrls600.length,
imgUrls600Type: Array.isArray(productData && productData.imgUrls600)
? 'array'
: typeof (productData && productData.imgUrls600),
// 동영상 관련 정보 추가
prdtMediaUrl: productData && productData.prdtMediaUrl,
thumbnailUrl960: productData && productData.thumbnailUrl960,
hasVideo: !!(productData && productData.prdtMediaUrl),
renderItemsLength: renderItems.length,
renderItems: renderItems,
productData: productData,
});
}, [productData, renderItems]);
// useEffect(() => {
// console.log('[ProductId] ProductAllSection productData check:', {
// hasProductData: !!productData,
// productDataPrdtId: productData && productData.prdtId,
// imgUrls600: productData && productData.imgUrls600,
// imgUrls600Length: productData && productData.imgUrls600 && productData.imgUrls600.length,
// imgUrls600Type: Array.isArray(productData && productData.imgUrls600)
// ? 'array'
// : typeof (productData && productData.imgUrls600),
// // 동영상 관련 정보 추가
// prdtMediaUrl: productData && productData.prdtMediaUrl,
// thumbnailUrl960: productData && productData.thumbnailUrl960,
// hasVideo: !!(productData && productData.prdtMediaUrl),
// renderItemsLength: renderItems.length,
// renderItems: renderItems,
// productData: productData,
// });
// }, [productData, renderItems]);
const { revwGrd, orderPhnNo } = useMemo(() => extractProductMeta(productInfo), [productInfo]);
// FP: derive favorite flag from props with local override, avoid non-I/O useEffect
const [favoriteOverride, setFavoriteOverride] = useState(null);
const favoriteFlag = useMemo(
() => deriveFavoriteFlag(favoriteOverride, productData),
@@ -344,7 +339,7 @@ export default function ProductAllSection({
// User Reviews 스크롤 핸들러 추가
const handleUserReviewsClick = useCallback(
() => scrollToSection('scroll-marker-user-reviews'),
[]
[scrollToSection]
);
const scrollContainerRef = useRef(null);
@@ -383,25 +378,21 @@ export default function ProductAllSection({
const { getScrollTo, scrollTop } = useScrollTo();
// FP: Pure function for mobile popup state change
const handleShopByMobileOpen = useCallback(
pipe(() => true, setMobileSendPopupOpen),
[]
);
// FP: Pure function for focus navigation to back button
const handleSpotlightUpToBackButton = useCallback((e) => {
e.stopPropagation();
Spotlight.focus('spotlightId_backBtn');
}, []);
// FP: Pure function for favorite flag change
const onFavoriteFlagChanged = useCallback(
(newFavoriteFlag) => setFavoriteOverride(newFavoriteFlag),
[]
);
// FP: Pure function for theme item button click with side effects
const handleThemeItemButtonClick = useCallback(
pipe(
() => setOpenThemeItemOverlay(true),
@@ -413,21 +404,22 @@ export default function ProductAllSection({
[setOpenThemeItemOverlay]
);
// FP: Pure function for scroll to section with early returns handled functionally
const scrollToSection = curry((sectionId) =>
pipe(
when(isEmpty, () => null),
andThen(() => document.getElementById(sectionId)),
when(isNil, () => null),
andThen((targetElement) => {
const targetRect = targetElement.getBoundingClientRect();
const y = targetRect.top;
return scrollTop({ y, animate: true });
})
)(sectionId)
const scrollToSection = useCallback(
curry((sectionId) =>
pipe(
when(isEmpty, () => null),
andThen(() => document.getElementById(sectionId)),
when(isNil, () => null),
andThen((targetElement) => {
const targetRect = targetElement.getBoundingClientRect();
const y = targetRect.top;
return scrollTop({ y, animate: true });
})
)(sectionId)
),
[]
);
// FP: Curried scroll handlers
const handleProductDetailsClick = useCallback(
() => scrollToSection('scroll-marker-product-details'),
[scrollToSection]
@@ -471,7 +463,7 @@ export default function ProductAllSection({
}
}
},
[documentHeight, isBottom, youMayAlsoLikelRef]
[documentHeight, isBottom]
);
const productFocus = useCallback(() => {
@@ -504,7 +496,7 @@ export default function ProductAllSection({
(descriptionRef.current?.scrollHeight || 0) +
(reviewRef.current?.scrollHeight || 0)
);
}, [productDetailRef.current, descriptionRef.current, hasReviews, hasYouMayAlsoLike]);
}, [hasReviews, hasYouMayAlsoLike]);
//spot관련
useEffect(() => {

View File

@@ -60,16 +60,16 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
// modal/full screen에 따른 일시정지/재생 처리
useEffect(() => {
console.log('[MediaPanel] ========== isOnTop useEffect ==========');
console.log('[MediaPanel] isOnTop:', isOnTop);
console.log('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2));
// console.log('[MediaPanel] ========== isOnTop useEffect ==========');
// console.log('[MediaPanel] isOnTop:', isOnTop);
// console.log('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2));
if (panelInfo && panelInfo.modal) {
if (!isOnTop) {
console.log('[MediaPanel] Not on top - pausing video');
// console.log('[MediaPanel] Not on top - pausing video');
dispatch(pauseModalMedia());
} else if (isOnTop && panelInfo.isPaused) {
console.log('[MediaPanel] Back on top - resuming video');
// console.log('[MediaPanel] Back on top - resuming video');
dispatch(resumeModalMedia());
}
}
@@ -79,10 +79,10 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
useEffect(() => {
if (panelInfo?.modal && videoPlayer.current) {
if (panelInfo.isPaused) {
console.log('[MediaPanel] Executing pause via videoPlayer.current');
// console.log('[MediaPanel] Executing pause via videoPlayer.current');
videoPlayer.current.pause();
} else if (panelInfo.isPaused === false) {
console.log('[MediaPanel] Executing play via videoPlayer.current');
// console.log('[MediaPanel] Executing play via videoPlayer.current');
videoPlayer.current.play();
}
}
@@ -148,7 +148,7 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
// 비디오 클릭 시 modal → fullscreen 전환
const onVideoClick = useCallback(() => {
if (panelInfo.modal) {
console.log('[MediaPanel] Video clicked - switching to fullscreen');
// console.log('[MediaPanel] Video clicked - switching to fullscreen');
dispatch(switchMediaToFullscreen());
}
}, [dispatch, panelInfo.modal]);
@@ -263,7 +263,7 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
const onEnded = useCallback(
(e) => {
console.log('[MediaPanel] Video ended');
// console.log('[MediaPanel] Video ended');
// 비디오 종료 시 패널 닫기
Spotlight.pause();
setTimeout(() => {
@@ -288,11 +288,11 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
setVideoLoaded(false);
}, [currentPlayingUrl]);
console.log('[MediaPanel] ========== Rendering ==========');
console.log('[MediaPanel] isOnTop:', isOnTop);
console.log('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2));
console.log('[MediaPanel] currentPlayingUrl:', currentPlayingUrl);
console.log('[MediaPanel] hasVideoPlayer:', !!videoPlayer.current);
// console.log('[MediaPanel] ========== Rendering ==========');
// console.log('[MediaPanel] isOnTop:', isOnTop);
// console.log('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2));
// console.log('[MediaPanel] currentPlayingUrl:', currentPlayingUrl);
// console.log('[MediaPanel] hasVideoPlayer:', !!videoPlayer.current);
return (
<TPanel

View File

@@ -1,264 +0,0 @@
# MediaPanel 사용 가이드
MediaPanel은 순수 비디오 재생에 특화된 간소화된 패널입니다. PlayerPanel과 달리 탭, 채팅, QR코드 등의 오버레이 없이 오직 비디오 재생만 지원합니다.
## 주요 특징
-**단순성**: PlayerPanel의 1/8 크기 (~350줄 vs 2,300줄)
-**Modal/Fullscreen 모드**: `modal` prop으로 제어
-**자막 지원**: 선택적 자막 표시
-**오버레이 없음**: 탭, 채팅, Shop Now 등 제거
-**충돌 방지**: PlayerPanel과 독립적으로 동작
## 사용 방법
### 1. Modal 모드 (부분 화면)
```javascript
import { startMediaPlayer } from '../../actions/mediaActions';
// Modal 모드로 비디오 재생
dispatch(startMediaPlayer({
modal: true,
modalContainerId: 'some-container-id', // 비디오가 표시될 컨테이너 ID
modalClassName: css.videoModal, // 추가 스타일 클래스
showUrl: 'https://example.com/video.m3u8',
thumbnailUrl: 'https://example.com/thumb.jpg',
subtitle: 'https://example.com/subtitle.vtt', // 선택사항
}));
```
### 2. Fullscreen 모드 (전체 화면)
```javascript
import { pushPanel } from '../../actions/panelActions';
import { panel_names } from '../../utils/Config';
// Fullscreen 모드로 비디오 재생
dispatch(pushPanel({
name: panel_names.MEDIA_PANEL,
panelInfo: {
modal: false,
showUrl: 'https://example.com/video.m3u8',
thumbnailUrl: 'https://example.com/thumb.jpg',
subtitle: 'https://example.com/subtitle.vtt', // 선택사항
}
}));
```
### 3. Modal → Fullscreen 전환
Modal 모드에서 사용자가 비디오를 클릭하면 자동으로 Fullscreen으로 전환할 수 있습니다:
```javascript
// MediaPanel 내부에서 자동 처리됨
// modalContainerId가 있으면 Back 버튼으로 Modal로 복귀 가능
```
### 4. 비디오 종료
```javascript
import { finishMediaPreview } from '../../actions/mediaActions';
// Modal 모드 종료
dispatch(finishMediaPreview());
// 또는 일반 패널 닫기
dispatch(popPanel());
```
## Props 상세
### panelInfo 객체
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `modal` | boolean | Yes | true: 부분화면, false: 전체화면 |
| `showUrl` | string | Yes | 비디오 URL (.m3u8, .mp4, .mpd 지원) |
| `thumbnailUrl` | string | No | 썸네일 이미지 URL |
| `subtitle` | string | No | 자막 파일 URL (.vtt) |
| `modalContainerId` | string | No* | Modal 모드 시 컨테이너 ID (*modal=true면 권장) |
| `modalClassName` | string | No | 추가 CSS 클래스 |
| `modalStyle` | object | No | 직접 스타일 지정 (modalContainerId 대신) |
| `modalScale` | number | No | Modal 스케일 (modalContainerId 대신) |
| `isPaused` | boolean | No | 일시정지 상태 (자동 관리됨) |
## API 레퍼런스
### mediaActions.js에서 제공하는 액션들
#### `startMediaPlayer(params)`
MediaPanel을 시작합니다.
```javascript
import { startMediaPlayer } from '../../actions/mediaActions';
dispatch(startMediaPlayer({
modal: true, // 필수: modal/fullscreen 모드
showUrl: 'video.m3u8', // 필수: 비디오 URL
modalContainerId: 'id', // modal=true일 때 권장
thumbnailUrl: 'thumb.jpg',// 선택
subtitle: 'subtitle.vtt', // 선택
}));
```
#### `finishMediaPreview()`
Modal 모드의 MediaPanel을 종료합니다.
```javascript
import { finishMediaPreview } from '../../actions/mediaActions';
dispatch(finishMediaPreview());
```
#### `finishModalMediaForce()`
강제로 modal MediaPanel을 종료합니다 (스택 어디에 있든).
```javascript
import { finishModalMediaForce } from '../../actions/mediaActions';
dispatch(finishModalMediaForce());
```
#### `pauseModalMedia()`
Modal MediaPanel을 일시정지합니다 (패널은 유지).
```javascript
import { pauseModalMedia } from '../../actions/mediaActions';
dispatch(pauseModalMedia());
```
#### `resumeModalMedia()`
Modal MediaPanel을 재생합니다.
```javascript
import { resumeModalMedia } from '../../actions/mediaActions';
dispatch(resumeModalMedia());
```
#### `switchMediaToFullscreen()`
MediaPanel을 fullscreen 모드로 전환합니다.
```javascript
import { switchMediaToFullscreen } from '../../actions/mediaActions';
dispatch(switchMediaToFullscreen());
```
#### `switchMediaToModal(modalContainerId, modalClassName)`
MediaPanel을 modal 모드로 전환합니다.
```javascript
import { switchMediaToModal } from '../../actions/mediaActions';
dispatch(switchMediaToModal('container-id', css.modalClass));
```
## 지원 비디오 포맷
- **HLS**: `.m3u8` (권장)
- **MP4**: `.mp4`
- **DASH**: `.mpd`
- **YouTube**: URL에 "youtu" 포함 시 자동 감지
## PlayerPanel과의 차이점
| 기능 | PlayerPanel | MediaPanel |
|------|-------------|------------|
| 비디오 재생 | ✅ | ✅ |
| Modal/Fullscreen | ✅ | ✅ |
| 자막 | ✅ | ✅ |
| TabContainer | ✅ | ❌ |
| Shop Now | ✅ | ❌ |
| 채팅 | ✅ | ❌ |
| QR Code | ✅ | ❌ |
| 재생목록 | ✅ | ❌ |
| Live Channel | ✅ | ❌ |
| 코드 복잡도 | 매우 높음 | 낮음 |
| 사용 사례 | 쇼핑 라이브/VOD | 순수 미디어 재생 |
## 실제 사용 예시
### 예시 1: 홈 배너에서 비디오 미리보기
```javascript
const HomeBanner = () => {
const dispatch = useDispatch();
const videoContainerRef = useRef(null);
const handleVideoPreview = () => {
dispatch(startMediaPlayer({
modal: true,
modalContainerId: 'home-banner-video',
showUrl: bannerData.videoUrl,
thumbnailUrl: bannerData.thumbnailUrl,
}));
};
return (
<div
ref={videoContainerRef}
data-spotlight-id="home-banner-video"
onClick={handleVideoPreview}
>
<img src={bannerData.thumbnailUrl} />
</div>
);
};
```
### 예시 2: 상세 페이지에서 전체화면 재생
```javascript
const DetailPanel = ({ productData }) => {
const dispatch = useDispatch();
const handlePlayVideo = () => {
dispatch(pushPanel({
name: panel_names.MEDIA_PANEL,
panelInfo: {
modal: false,
showUrl: productData.demoVideoUrl,
thumbnailUrl: productData.videoCover,
subtitle: productData.subtitleUrl,
}
}));
};
return (
<TButton onClick={handlePlayVideo}>
Play Demo Video
</TButton>
);
};
```
## 이벤트 핸들링
### onEnded
비디오 종료 시 자동으로 패널이 닫힙니다 (1.5초 후).
### onError
비디오 에러 발생 시 `sendBroadCast` 액션으로 에러 정보를 전송합니다.
### onClickBack
- Modal 모드: 패널 닫기
- Fullscreen with modalContainerId: Modal로 전환
- Fullscreen: 패널 닫기
## 주의사항
1. **modalContainerId 필수**: Modal 모드 사용 시 `modalContainerId`를 반드시 지정해야 정확한 위치에 비디오가 표시됩니다.
2. **spotlight-id 설정**: modalContainerId로 사용할 엘리먼트에 `data-spotlight-id` 속성을 반드시 설정하세요.
3. **VideoPlayer 공유**: MediaPanel과 PlayerPanel은 동일한 VideoPlayer 컴포넌트를 사용하므로 동시에 여러 패널을 열면 안 됩니다.
4. **자막 포맷**: 자막은 WebVTT (.vtt) 포맷만 지원합니다.
## 디버깅
콘솔 로그를 확인하세요:
- `[MediaPanel] isOnTop:` - 패널 상태 변경
- `[MediaPanel] Not on top - pausing video` - Modal이 백그라운드로 이동
- `[MediaPanel] Back on top - resuming video` - Modal이 포그라운드로 복귀
- `[MediaPanel] Video ended` - 비디오 종료
## 라이센스
ShopTime 프로젝트의 일부로 동일한 라이센스가 적용됩니다.