[251025] feat: usePanelHistory && panelHistoryMiddleware

🕐 커밋 시간: 2025. 10. 25. 09:46:43

📊 변경 통계:
  • 총 파일: 8개
  • 추가: +195줄
  • 삭제: -84줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/actions/panelNavigationActions.js

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/App/App.js
  ~ com.twin.app.shoptime/src/actions/panelHistoryActions.js
  ~ com.twin.app.shoptime/src/hooks/usePanelHistory/usePanelHistory.js
  ~ com.twin.app.shoptime/src/middleware/panelHistoryMiddleware.js
  ~ com.twin.app.shoptime/src/reducers/panelHistoryReducer.js
  ~ com.twin.app.shoptime/src/views/HomePanel/BestSeller/BestSeller.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchResultsNew/ItemCard.jsx

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • 중간 규모 기능 개선
  • 모듈 구조 개선
This commit is contained in:
2025-10-25 09:46:45 +00:00
parent d09a59a86e
commit 728e503976
8 changed files with 378 additions and 82 deletions

View File

@@ -42,6 +42,7 @@ import { getShoptimeTerms } from '../actions/empActions';
import { getHomeMenu, getHomeTerms } from '../actions/homeActions';
import { getMyRecommandedKeyword, getMyUpcomingAlertShow } from '../actions/myPageActions';
import { pushPanel } from '../actions/panelActions';
import { enqueuePanelHistory } from '../actions/panelHistoryActions';
import NotSupportedVersion from '../components/NotSupportedVersion/NotSupportedVersion';
import ToastContainer from '../components/TToast/ToastContainer';
import usePrevious from '../hooks/usePrevious';
@@ -387,6 +388,17 @@ function AppBase(props) {
console.log('[App.js] initService,httpHeaderRef.current', httpHeaderRef.current);
console.log('[App.js] haveyInit', haveyInit);
// 앱 초기화 시 HomePanel 자동 기록
console.log('[App.js] Recording initial HomePanel on app start');
dispatch(enqueuePanelHistory(
'homepanel',
{},
'APP_INITIALIZE',
new Date().toISOString(),
false, // fromGNB: false (앱 초기화)
false // fromResetPanel: false (앱 초기화)
));
if (httpHeaderRef.current) {
if (haveyInit) {
dispatch(changeAppStatus({ connectionFailed: false }));

View File

@@ -9,16 +9,21 @@ import { types } from './actionTypes';
* Panel을 히스토리에 추가
* @param {string} panelName - Panel 이름
* @param {Object} panelInfo - Panel 상태 정보
* @param {string} action - 발생한 Redux 액션 ('PUSH' | 'POP' | 'UPDATE' | 'RESET')
* @param {string} action - 발생한 Redux 액션 ('PUSH' | 'POP' | 'UPDATE' | 'RESET' | 'APP_INITIALIZE')
* @param {string} timestamp - 타임스탬프 (선택사항)
* @param {boolean} fromGNB - GNB를 통해 이동했는지 여부
* @param {boolean} fromResetPanel - RESET_PANELS를 통해 이동했는지 여부
* @returns {Object} Redux action
*/
export const enqueuePanelHistory = (panelName, panelInfo = {}, action = 'PUSH') => ({
export const enqueuePanelHistory = (panelName, panelInfo = {}, action = 'PUSH', timestamp, fromGNB = false, fromResetPanel = false) => ({
type: types.ENQUEUE_PANEL_HISTORY,
payload: {
panelName,
panelInfo,
action,
timestamp: new Date().toISOString(),
timestamp: timestamp || new Date().toISOString(),
fromGNB,
fromResetPanel,
},
});

View File

@@ -0,0 +1,185 @@
/**
* src/actions/panelNavigationActions.js
* Panel navigation 순차 처리를 위한 액션 크리에이터
*
* Chrome 68 호환성을 위한 callback-free 순차 네비게이션
*/
import { pushPanel, updatePanel } from './panelActions';
import { panel_names } from '../utils/Config';
/**
* 상품 클릭 시 순차 네비게이션 (Search → Detail)
* @param {string} patnrId - 파트너 ID
* @param {string} prdtId - 상품 ID
* @param {string} searchQuery - 검색어
* @param {string} currentSpot - 현재 spotlight ID
* @param {Object} additionalInfo - 추가 패널 정보
* @returns {Function} Redux thunk function
*/
export const navigateToDetailPanel = (
patnrId,
prdtId,
searchQuery,
currentSpot,
additionalInfo = {}
) => (dispatch, getState) => {
// 현재 상태에서 lastPanelAction 카운트 저장
const currentActionCount = getState().panels.lastPanelAction || 0;
console.log('[PanelNavigation] Starting navigation to detail:', {
patnrId,
prdtId,
searchQuery,
currentSpot,
currentActionCount
});
// 1. 먼저 현재 패널(예: SearchPanel) 업데이트
dispatch(updatePanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
searchVal: searchQuery,
currentSpot,
tab: 0,
...additionalInfo
}
}));
// 2. Redux store 구독하여 상태 변화 감지
// 직접 store 접근 대신 타이머 기반 방식 사용 (Chrome 68 호환)
const storeUnsubscribe = (() => {
let isUnsubscribed = false;
const checkStateChange = () => {
if (isUnsubscribed) return;
const newState = getState();
const newActionCount = newState.panels.lastPanelAction || 0;
// updatePanel이 완료되면 (action count가 변경되면)
if (newActionCount !== currentActionCount) {
console.log('[PanelNavigation] UpdatePanel completed, pushing DetailPanel');
// 구독 해제
isUnsubscribed = true;
// 3. DetailPanel push
dispatch(pushPanel({
name: panel_names.DETAIL_PANEL,
panelInfo: {
patnrId,
prdtId,
fromSearch: true,
searchQuery,
...additionalInfo
}
}));
}
};
// 즉시 한번 확인하고, 그 후 주기적으로 확인
setTimeout(checkStateChange, 0);
const intervalId = setInterval(checkStateChange, 16); // 60fps
return () => {
isUnsubscribed = true;
clearInterval(intervalId);
};
})();
// 타임아웃 방어 (최대 1초 대기)
setTimeout(() => {
storeUnsubscribe();
console.log('[PanelNavigation] Timeout fallback, pushing DetailPanel');
dispatch(pushPanel({
name: panel_names.DETAIL_PANEL,
panelInfo: {
patnrId,
prdtId,
fromSearch: true,
searchQuery,
...additionalInfo
}
}));
}, 1000);
};
/**
* HomePanel에서 상품 클릭 시 순차 네비게이션
* @param {string} patnrId - 파트너 ID
* @param {string} prdtId - 상품 ID
* @param {Object} additionalInfo - 추가 패널 정보
* @returns {Function} Redux thunk function
*/
export const navigateToDetailFromHome = (
patnrId,
prdtId,
additionalInfo = {}
) => (dispatch, getState) => {
const currentActionCount = getState().panels.lastPanelAction || 0;
console.log('[PanelNavigation] Starting navigation from home:', {
patnrId,
prdtId,
currentActionCount
});
dispatch(updatePanel({
name: panel_names.HOME_PANEL,
panelInfo: {
lastSelectedProduct: { patnrId, prdtId },
...additionalInfo
}
}));
const storeUnsubscribe = (() => {
let isUnsubscribed = false;
const checkStateChange = () => {
if (isUnsubscribed) return;
const newState = getState();
const newActionCount = newState.panels.lastPanelAction || 0;
if (newActionCount !== currentActionCount) {
console.log('[PanelNavigation] HomePanel update completed, pushing DetailPanel');
isUnsubscribed = true;
dispatch(pushPanel({
name: panel_names.DETAIL_PANEL,
panelInfo: {
patnrId,
prdtId,
fromHome: true,
...additionalInfo
}
}));
}
};
setTimeout(checkStateChange, 0);
const intervalId = setInterval(checkStateChange, 16);
return () => {
isUnsubscribed = true;
clearInterval(intervalId);
};
})();
setTimeout(() => {
storeUnsubscribe();
console.log('[PanelNavigation] Timeout fallback from home');
dispatch(pushPanel({
name: panel_names.DETAIL_PANEL,
panelInfo: {
patnrId,
prdtId,
fromHome: true,
...additionalInfo
}
}));
}, 1000);
};

View File

@@ -35,17 +35,11 @@ export const usePanelHistory = () => {
* [current, previous, older, oldest, ...]
*/
const getHistory = useCallback(() => {
const { history, head, size } = panelHistory;
if (size === 0) return [];
const { history } = panelHistory;
if (history.length === 0) return [];
const sorted = [];
for (let i = 0; i < size; i++) {
const index = (head - i + 10) % 10;
if (history[index]) {
sorted.push(history[index]);
}
}
return sorted;
// history는 오래된 순서이므로, 최신 순으로 뒤집기
return [...history].reverse();
}, [panelHistory]);
/**
@@ -55,10 +49,11 @@ export const usePanelHistory = () => {
*/
const getHistoryAt = useCallback(
(distance) => {
const { history, head, size } = panelHistory;
if (distance >= size || distance < 0) return null;
const { history } = panelHistory;
if (distance >= history.length || distance < 0) return null;
const index = (head - distance + 10) % 10;
// history는 오래된 순서, distance는 최신부터 계산
const index = history.length - 1 - distance;
return history[index] || null;
},
[panelHistory]
@@ -76,7 +71,7 @@ export const usePanelHistory = () => {
return { pattern: 'no-panel', confidence: 0 };
}
// 🔽 Back navigation: A → B → A
// Back navigation: A → B → A
if (
current &&
older &&
@@ -92,7 +87,7 @@ export const usePanelHistory = () => {
};
}
// 🔽 Detail flow: Search/Home → Detail
// Detail flow: Search/Home → Detail
if (current.panelName === 'detailpanel' && previous) {
if (previous.panelName === 'searchpanel') {
return {
@@ -108,7 +103,7 @@ export const usePanelHistory = () => {
}
}
// 🔽 Modal overlay: base → modal
// Modal overlay: base → modal
const modalPanels = ['imagepanel', 'mediapanel', 'playerpanel'];
if (modalPanels.includes(current.panelName) && previous) {
return {
@@ -119,7 +114,7 @@ export const usePanelHistory = () => {
};
}
// 🔽 Search flow: Home → Search
// Search flow: Home → Search
if (current.panelName === 'searchpanel' && previous?.panelName === 'homepanel') {
return {
pattern: 'home-to-search',
@@ -127,7 +122,7 @@ export const usePanelHistory = () => {
};
}
// 🔽 Cart flow: Product → Cart
// Cart flow: Product → Cart
if (current.panelName === 'cartpanel' && previous) {
return {
pattern: 'product-to-cart',
@@ -135,7 +130,7 @@ export const usePanelHistory = () => {
};
}
// 🔽 Checkout flow: Cart → Checkout
// Checkout flow: Cart → Checkout
if (current.panelName === 'checkoutpanel' && previous?.panelName === 'cartpanel') {
return {
pattern: 'cart-to-checkout',

View File

@@ -3,7 +3,7 @@
* Panel history 자동 추적 middleware
*
* Panel action (PUSH, POP, UPDATE, RESET)을 감지하고
* 자동으로 panel history에 기록합니다.
* 자동으로 panel history에 기록
*/
import { types } from '../actions/actionTypes';
@@ -17,6 +17,37 @@ import { enqueuePanelHistory, clearPanelHistory } from '../actions/panelHistoryA
* @returns {Function} middleware function
*/
export const panelHistoryMiddleware = (store) => (next) => (action) => {
// 모든 PANEL 관련 액션 로깅
if (action.type && (
action.type.includes('PANEL') ||
action.type === 'CLEAR_PANEL_HISTORY'
)) {
const caller = new Error().stack.split('\n')[1]?.trim();
console.log(`[PANEL DEBUG] ${action.type} from: ${caller}`);
console.log(' Payload:', action.payload);
}
// GNB 호출 식별을 위한 helper 함수
const isGNBCall = () => {
const stack = new Error().stack;
if (!stack) return false;
const stackLines = stack.split('\n');
for (const line of stackLines) {
if (line.includes('TabLayout.jsx') ||
line.includes('TIconButton.jsx')) {
return true;
}
}
return false;
};
// 임시: CLEAR_PANEL_HISTORY 무력화
if (action.type === 'CLEAR_PANEL_HISTORY') {
console.log('[PANEL DEBUG] CLEAR_PANEL_HISTORY BLOCKED!');
return action; // 액션을 전달하지 않음
}
// 원래 액션 실행
const result = next(action);
@@ -32,7 +63,19 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
const { name: panelName, panelInfo = {} } = action.payload;
if (panelName) {
store.dispatch(enqueuePanelHistory(panelName, panelInfo, 'PUSH'));
const isGNB = isGNBCall();
console.log('[PANEL] PUSH_PANEL:', { panelName, panelInfo, isGNB, timestamp: new Date().toISOString() });
store.dispatch(enqueuePanelHistory(panelName, panelInfo, 'PUSH', new Date().toISOString(), isGNB, false));
// PanelHistory 상태 로그 (state 업데이트 후)
const logPanelHistoryAfter = () => {
const stateAfter = store.getState();
const panelHistoryAfter = stateAfter.panelHistory;
console.log('[PANEL_HISTORY] After PUSH_PANEL:', panelHistoryAfter);
};
// state 업데이트가 완료된 후 로그
Promise.resolve().then(logPanelHistoryAfter);
}
}
break;
@@ -44,7 +87,19 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
if (panels.length > 0) {
const topPanel = panels[panels.length - 1];
if (topPanel && topPanel.name) {
store.dispatch(enqueuePanelHistory(topPanel.name, topPanel.panelInfo || {}, 'POP'));
const isGNB = isGNBCall();
console.log('[PANEL] POP_PANEL:', { panelName: topPanel.name, panelInfo: topPanel.panelInfo || {}, isGNB, timestamp: new Date().toISOString() });
store.dispatch(enqueuePanelHistory(topPanel.name, topPanel.panelInfo || {}, 'POP', new Date().toISOString(), isGNB, false));
// PanelHistory 상태 로그 (state 업데이트 후)
const logPanelHistoryAfter = () => {
const stateAfter = store.getState();
const panelHistoryAfter = stateAfter.panelHistory;
console.log('[PANEL_HISTORY] After POP_PANEL:', panelHistoryAfter);
};
// state 업데이트가 완료된 후 로그
Promise.resolve().then(logPanelHistoryAfter);
}
}
break;
@@ -56,15 +111,76 @@ export const panelHistoryMiddleware = (store) => (next) => (action) => {
const { name: panelName, panelInfo = {} } = action.payload;
if (panelName) {
store.dispatch(enqueuePanelHistory(panelName, panelInfo, 'UPDATE'));
const isGNB = isGNBCall();
console.log('[PANEL] UPDATE_PANEL:', { panelName, panelInfo, isGNB, timestamp: new Date().toISOString() });
store.dispatch(enqueuePanelHistory(panelName, panelInfo, 'UPDATE', new Date().toISOString(), isGNB, false));
// PanelHistory 상태 로그 (state 업데이트 후)
const logPanelHistoryAfter = () => {
const stateAfter = store.getState();
const panelHistoryAfter = stateAfter.panelHistory;
console.log('[PANEL_HISTORY] After UPDATE_PANEL:', panelHistoryAfter);
};
// state 업데이트가 완료된 후 로그
Promise.resolve().then(logPanelHistoryAfter);
}
}
break;
}
// RESET_PANELS: 히스토리 초기화
// RESET_PANELS: GNB 네비게이션 또는 완전 초기화
case types.RESET_PANELS: {
store.dispatch(clearPanelHistory());
console.log('[PANEL] RESET_PANELS:', {
payload: action.payload,
timestamp: new Date().toISOString()
});
console.log('[PANEL_HISTORY] Before RESET_PANELS:', store.getState().panelHistory);
// 모든 RESET_PANELS를 기록
const isGNB = isGNBCall();
if (action.payload && action.payload.length > 0) {
// payload가 있는 경우 (GNB 탭, 패널 내 네비게이션 등)
const firstPanel = action.payload[0];
if (firstPanel && firstPanel.name) {
console.log('[PANEL_DEBUG] RESET_PANELS to:', firstPanel.name, { isGNB, fromResetPanel: true });
// RESET 이동을 히스토리에 기록
store.dispatch(enqueuePanelHistory(
firstPanel.name,
firstPanel.panelInfo || {},
'RESET',
new Date().toISOString(),
isGNB, // TabLayout/TIconButton이면 true
true // fromResetPanel: 항상 true
));
}
} else {
// 완전 초기화 (payload가 없는 경우 - HomePanel, 앱 초기화 등)
console.log('[PANEL_DEBUG] Complete panel history reset (payload empty)', { isGNB, fromResetPanel: true });
store.dispatch(clearPanelHistory());
// HomePanel 초기화 기록 (앱 시작 시)
console.log('[PANEL_DEBUG] Recording initial HomePanel');
store.dispatch(enqueuePanelHistory(
'homepanel',
{},
'APP_INITIALIZE',
new Date().toISOString(),
isGNB, // TIconButton Home 버튼이면 true
true // fromResetPanel: true
));
}
// PanelHistory 상태 로그 (초기화 후)
const logPanelHistoryAfter = () => {
const stateAfter = store.getState();
const panelHistoryAfter = stateAfter.panelHistory;
console.log('[PANEL_HISTORY] After RESET_PANELS:', panelHistoryAfter);
};
// state 업데이트가 완료된 후 로그
Promise.resolve().then(logPanelHistoryAfter);
break;
}

View File

@@ -10,51 +10,49 @@ import { types } from '../actions/actionTypes';
// 초기 상태
const initialState = {
history: new Array(10).fill(null), // 10개 ring buffer
head: -1, // 현재 위치 (-1 ~ 9)
size: 0, // 현재 저장된 개수 (0 ~ 10)
history: [], // 최대 10개의 히스토리 배열 (오래된 순서)
current: null, // 현재 panel entry
previous: null, // 이전 panel entry
hasTransition: false, // panel 전환 여부
};
/**
* Panel을 Ring buffer에 추가
* Panel을 히스토리 배열에 추가
* @param {Object} state - 현재 상태
* @param {string} panelName - 추가할 panel 이름
* @param {Object} panelInfo - panel 정보
* @param {string} action - Redux 액션 타입 ('PUSH' | 'POP' | 'UPDATE')
* @param {string} timestamp - ISO 8601 timestamp
* @param {boolean} fromGNB - GNB를 통해 이동했는지 여부
* @param {boolean} fromResetPanel - RESET_PANELS를 통해 이동했는지 여부
* @returns {Object} 새로운 상태
*/
const enqueuePanel = (state, panelName, panelInfo, action, timestamp) => {
// State 불변성 유지
const newHistory = state.history.map((item) => item);
// 다음 위치 계산 (ring buffer 순환)
const newHead = (state.head + 1) % 10;
const enqueuePanel = (state, panelName, panelInfo, action, timestamp, fromGNB = false, fromResetPanel = false) => {
// 새 entry 생성
const newEntry = {
panelName,
panelInfo: { ...panelInfo }, // Deep copy
timestamp,
action,
fromGNB, // GNB 이동 여부
fromResetPanel, // RESET_PANELS 이동 여부
};
// Ring buffer에 저장
newHistory[newHead] = newEntry;
// Size 업데이트 (최대 10)
const newSize = Math.min(state.size + 1, 10);
// 최대 10개 유지하며 새로운 히스토리 생성
let newHistory;
if (state.history.length >= 10) {
// 10개가 차면 첫 번째 요소 제거하고 추가
newHistory = [...state.history.slice(1), newEntry];
} else {
// 10개 미만이면 그냥 추가
newHistory = [...state.history, newEntry];
}
// 이전 panel 추출
const previousEntry = state.current;
return {
history: newHistory,
head: newHead,
size: newSize,
current: newEntry,
previous: previousEntry,
hasTransition: previousEntry !== null && previousEntry.panelName !== newEntry.panelName,
@@ -68,7 +66,7 @@ export const panelHistoryReducer = (state = initialState, action) => {
switch (action.type) {
// 🔽 Panel history에 새 entry 추가
case types.ENQUEUE_PANEL_HISTORY: {
const { panelName, panelInfo = {}, action: actionType, timestamp } = action.payload;
const { panelName, panelInfo = {}, action: actionType, timestamp, fromGNB = false, fromResetPanel = false } = action.payload;
// 입력값 검증
if (!panelName || typeof panelName !== 'string') {
@@ -85,12 +83,14 @@ export const panelHistoryReducer = (state = initialState, action) => {
panelInfo: { ...panelInfo }, // panelInfo만 업데이트
timestamp,
action: actionType, // 최신 action 기록
fromGNB: fromGNB || state.current.fromGNB, // GNB 플래그 유지 또는 업데이트
fromResetPanel: fromResetPanel || state.current.fromResetPanel, // ResetPanel 플래그 유지 또는 업데이트
},
};
}
// 🔽 다른 panelName이면 새로운 버퍼 항목 추가
return enqueuePanel(state, panelName, panelInfo, actionType, timestamp);
return enqueuePanel(state, panelName, panelInfo, actionType, timestamp, fromGNB, fromResetPanel);
}
// 🔽 History 초기화
@@ -98,10 +98,8 @@ export const panelHistoryReducer = (state = initialState, action) => {
return { ...initialState };
}
// 🔽 Panel reset 시 history도 리셋
case types.RESET_PANELS: {
return { ...initialState };
}
// 🔽 RESET_PANELS는 middleware에서 직접 처리하므로 여기서는 초기화하지 않음
// GNB 네비게이션을 위해 히스토리를 유지해야 함
// 🔽 Panel history 명시적 리셋
case types.RESET_PANEL_HISTORY: {
@@ -120,18 +118,11 @@ export const selectPanelHistory = (state) => state.panelHistory;
export const selectCurrentPanel = (state) => state.panelHistory.current;
export const selectPreviousPanel = (state) => state.panelHistory.previous;
export const selectPanelHistoryList = (state) => {
const { history, head, size } = state.panelHistory;
if (size === 0) return [];
const { history } = state.panelHistory;
if (history.length === 0) return [];
// 최신순으로 정렬된 히스토리 반환
const sorted = [];
for (let i = 0; i < size; i++) {
const index = (head - i + 10) % 10;
if (history[index]) {
sorted.push(history[index]);
}
}
return sorted;
// 최신순으로 정렬된 히스토리 반환 (오래된 순서를 뒤집기)
return [...history].reverse();
};
export default panelHistoryReducer;

View File

@@ -20,6 +20,7 @@ import {
pushPanel,
updatePanel,
} from '../../../actions/panelActions';
import { navigateToDetailFromHome } from '../../../actions/panelNavigationActions';
import SectionTitle from '../../../components/SectionTitle/SectionTitle';
import Tag from '../../../components/TItemCard/Tag';
import TItemCard from '../../../components/TItemCard/TItemCard';

View File

@@ -5,6 +5,7 @@ import { useDispatch } from 'react-redux';
import Spotlight from '@enact/spotlight';
import { pushPanel, updatePanel } from '../../../actions/panelActions';
import { navigateToDetailPanel } from '../../../actions/panelNavigationActions';
import TItemCardNew from '../../../components/TItemCard/TItemCard.new';
import TScroller from '../../../components/TScroller/TScroller';
import { panel_names } from '../../../utils/Config';
@@ -19,25 +20,15 @@ const ItemCard = ({ onClick, itemInfo, searchQuery }) => {
if (onClick) {
onClick(ev);
}
dispatch(
updatePanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
searchVal: searchQuery,
currentSpot,
tab: 0,
},
})
);
dispatch(
pushPanel({
name: panel_names.DETAIL_PANEL,
panelInfo: {
patnrId: patnrId,
prdtId: prdtId,
},
})
);
// 순차 네비게이션 사용 (Chrome 68 호환)
dispatch(navigateToDetailPanel(
patnrId,
prdtId,
searchQuery,
currentSpot,
{ tab: 0 }
));
},
[onClick, dispatch, searchQuery]
);