Files
shoptime/com.twin.app.shoptime/src/components/TItemCard/TItemCard.new.jsx
optrader 0781bb39b2 [251018] fix: EnergyLabel
🕐 커밋 시간: 2025. 10. 18. 23:31:25

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +387줄
  • 삭제: -93줄

📁 추가된 파일:
  + com.twin.app.shoptime/assets/mock/EnergyLabelSample.pdf

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/actionTypes.js
  ~ com.twin.app.shoptime/src/actions/convertActions.js
  ~ com.twin.app.shoptime/src/api/TAxios.js
  ~ com.twin.app.shoptime/src/components/TItemCard/TItemCard.module.less
  ~ com.twin.app.shoptime/src/components/TItemCard/TItemCard.new.jsx
  ~ com.twin.app.shoptime/src/reducers/convertReducer.js

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/components/TItemCard/TItemCard.new.jsx (javascript):
     Added: hashCode()

🔧 주요 변경 내용:
  • 타입 시스템 안정성 강화
  • 핵심 비즈니스 로직 개선
  • API 서비스 레이어 개선
  • UI 컴포넌트 아키텍처 개선
2025-10-18 23:31:29 +09:00

564 lines
17 KiB
JavaScript

import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import Spottable from '@enact/spotlight/Spottable';
import defaultLogoImg from '../../../assets/images/ic-tab-partners-default@3x.png';
import defaultimgHorizontal from '../../../assets/images/img-thumb-empty-hor@3x.png';
import defaultImageItem from '../../../assets/images/img-thumb-empty-product@3x.png';
import defaultimgVertical from '../../../assets/images/img-thumb-empty-ver@3x.png';
import IcLiveShow from '../../../assets/images/tag/tag-liveshow.png';
// 🧪 테스트용 에너지 라벨 (실제 PDF 변환 테스트)
import testEnergyIconA from '../../../assets/images/energyLabel/labelgradeA.png';
import testEnergyIconB from '../../../assets/images/energyLabel/labelgradeB.png';
import testEnergyIconC from '../../../assets/images/energyLabel/labelgradeC.png';
import testEnergyPdf from '../../../assets/mock/EnergyLabelSample.pdf';
import { setHidePopup, setShowPopup } from '../../actions/commonActions';
import {
clearConvertedImage,
convertPdfToImage,
convertMultiplePdfs,
} from '../../actions/convertActions';
import { sendLogTotalRecommend } from '../../actions/logActions';
import usePriceInfo from '../../hooks/usePriceInfo';
import * as Config from '../../utils/Config';
import { $L, getQRCodeUrl, removeSpecificTags } from '../../utils/helperMethods';
import { SpotlightIds } from '../../utils/SpotlightIds';
import CustomImage from '../CustomImage/CustomImage';
import TPopUp from '../TPopUp/TPopUp';
import css from './TItemCard.module.less';
const SpottableComponent = Spottable('div');
const SpottableTemp = Spottable('div');
const TYPES = {
vertical: 'vertical',
horizontal: 'horizontal',
videoShow: 'videoShow',
};
const IMAGETYPES = {
imgHorizontal: 'imgHorizontal',
imgVertical: 'imgVertical',
};
const STRING_CONF = {
SOLD_OUT: 'SOLD OUT',
ENERGY_LOADING: 'Loading energy label...',
ENERGY_ERROR: 'Failed to load energy label',
};
export const removeDotAndColon = (string) => {
return /[.:]/.test(string) ? string.replace(/[.:]/g, '') : string;
};
const parsePrice = (price) => {
return parseFloat(price?.replace(/[^0-9.-]+/g, '') || '0');
};
export default memo(function TItemCardNew({
children,
className,
disabled,
imageAlt,
imageSource,
imgType = IMAGETYPES.imgHorizontal,
logo,
logoDisplay = false,
isBestSeller = false,
isLive = false,
onBlur,
onClick,
onFocus,
onError,
offerInfo,
priceInfo,
productId,
productName,
catNm,
rank,
soldoutFlag,
spotlightId,
nonPosition = false,
type = TYPES.vertical,
firstLabel,
label,
lastLabel,
contextName,
messageId,
order,
patnerName,
brandName,
shelfId,
shelfLocation,
shelfTitle,
contentTitle,
category,
curationId,
curationTitle,
nowProductId,
nowCategory,
nowProductTitle,
contentId,
dcPrice,
originPrice,
euEnrgLblInfos,
...rest
}) {
const dispatch = useDispatch();
const [defaultImage, setDefaultImage] = useState(null);
const [currentPdfUrl, setCurrentPdfUrl] = useState(null);
const countryCode = useSelector((state) => state.common.httpHeader.cntry_cd);
const cursorVisible = useSelector((state) => state.common.appStatus.cursorVisible);
const { activePopup, popupVisible } = useSelector((state) => state.common.popup);
const convert = useSelector((state) => state.convert);
const serverHOST = useSelector((state) => state.common.appStatus.serverHOST);
const serverType = useSelector((state) => state.localSettings.serverType);
// 컴포넌트 unmount 시 메모리 정리
useEffect(() => {
return () => {
if (convert?.convertedImage) {
URL.revokeObjectURL(convert.convertedImage);
}
};
}, [convert?.convertedImage]);
useEffect(() => {
if (!imageSource) {
if (type === 'videoShow') {
setDefaultImage(
imgType === IMAGETYPES.imgHorizontal ? defaultimgHorizontal : defaultimgVertical
);
} else {
setDefaultImage(defaultImageItem);
}
}
}, [imageSource, type, imgType]);
// ⚠️ 자동 변환 비활성화 (클릭 시에만 변환 테스트)
// useEffect(() => {
// if (euEnrgLblInfos && euEnrgLblInfos.length > 0) {
// const pdfUrls = euEnrgLblInfos
// .filter((info) => info?.enrgLblUrl && !info.enrgLblUrl.endsWith('.png'))
// .map((info) => info.enrgLblUrl);
//
// if (pdfUrls.length > 0) {
// console.log(`🔄 [EnergyLabel] Auto-converting ${pdfUrls.length} PDFs for product:`, productId);
// dispatch(convertMultiplePdfs(pdfUrls, (errors, results) => {
// if (errors) {
// console.error(`❌ [EnergyLabel] Some conversions failed for product:`, productId, errors);
// } else {
// console.log(`✅ [EnergyLabel] All conversions successful for product:`, productId);
// }
// }));
// }
// }
// }, [euEnrgLblInfos, productId, dispatch]);
const { originalPrice, discountedPrice, discountRate } = usePriceInfo(priceInfo) || {};
const _onBlur = useCallback(() => {
if (onBlur) {
onBlur();
}
}, [onBlur]);
const _onClick = useCallback(
(e) => {
if (disabled) {
e.stopPropagation();
return;
}
if (onClick) {
onClick(e);
if (contextName && messageId) {
const params = {
contextName: contextName,
messageId: messageId,
shelfLocation: shelfLocation,
shelfId: shelfId,
shelfTitle: shelfTitle,
productId: productId,
productTitle: productName,
nowProductId: nowProductId,
nowCategory: nowCategory,
nowProductTitle: nowProductTitle,
partner: patnerName,
brand: brandName,
price: originalPrice,
discount: discountRate,
location: order,
category: category ? category : catNm,
contentTitle: contentTitle,
curationId: curationId,
curationTitle: curationTitle,
};
dispatch(sendLogTotalRecommend(params));
}
}
},
[
onClick,
disabled,
contextName,
messageId,
shelfLocation,
shelfId,
shelfTitle,
productId,
productName,
nowProductId,
nowCategory,
nowProductTitle,
patnerName,
brandName,
originalPrice,
discountRate,
order,
category,
catNm,
contentTitle,
curationId,
curationTitle,
dispatch,
]
);
const _onFocus = useCallback(() => {
if (onFocus) {
onFocus();
}
}, [onFocus]);
const addDefaultImg = useCallback(
(e) => {
if (onError) {
onError(e);
}
},
[onError]
);
const ariaLabel = useMemo(() => {
const soldOutText = soldoutFlag === 'Y' ? 'Sold Out ' : '';
const firstLabelText = firstLabel ? `${firstLabel} ` : '';
const discountLabel = discountRate ? `${discountRate} discount, ` : '';
const discountPriceLabel = discountRate ? `Sale price ${discountedPrice}, ` : '';
const parsedPrice = parsePrice(originalPrice);
const priceLabel =
parsedPrice === 0
? offerInfo
? ` ${offerInfo}`
: ''
: originalPrice
? ` Original price ${originalPrice}, `
: '';
const productLabel = label || '';
const lastLabelText = lastLabel || '';
return `${soldOutText}${firstLabelText}${discountLabel}${productName}${discountPriceLabel}${priceLabel}${productLabel}${lastLabelText}`;
}, [
soldoutFlag,
firstLabel,
discountRate,
productName,
discountedPrice,
originalPrice,
offerInfo,
label,
lastLabel,
]);
const productNameDangerousHTML = useMemo(() => {
const sanitizedString = removeSpecificTags(productName);
return sanitizedString;
}, [productName]);
// 🔽 팝업 닫기 + 메모리 정리
const handleClosePopup = useCallback(() => {
// Object URL 메모리 해제
if (convert?.convertedImage) {
URL.revokeObjectURL(convert.convertedImage);
}
dispatch(setHidePopup());
dispatch(clearConvertedImage());
setCurrentPdfUrl(null);
}, [dispatch, convert?.convertedImage]);
const { setupPinUrl } = useMemo(() => {
return getQRCodeUrl({ serverHOST, serverType });
}, [serverHOST, serverType]);
const onEnergyClick = useCallback(
(e, pdfUrl) => {
e.stopPropagation();
setCurrentPdfUrl(pdfUrl);
// PNG 이미지는 직접 표시
if (pdfUrl.endsWith('.png')) {
dispatch({
type: 'CONVERT_PDF_TO_IMAGE_SUCCESS',
payload: { pdfUrl, imageUrl: pdfUrl },
});
dispatch(setShowPopup(Config.ACTIVE_POPUP.energyPopup));
return;
}
// PDF 변환 시작 (성공 시에만 팝업)
dispatch(
convertPdfToImage(pdfUrl, (error, imageUrl) => {
if (error) {
console.error('[EnergyLabel] 변환 실패:', error.message || error);
} else {
dispatch(setShowPopup(Config.ACTIVE_POPUP.energyPopup));
setTimeout(() => {
Spotlight.focus(SpotlightIds.TPOPUP);
}, 250);
}
})
);
},
[dispatch]
);
return (
<>
<SpottableComponent
className={classNames(
css[type],
nonPosition && css.nonPosition,
type === 'videoShow' && css[imgType],
className && className
)}
onBlur={_onBlur}
onClick={_onClick}
onFocus={_onFocus}
spotlightId={spotlightId ?? 'spotlightId-' + removeDotAndColon(productId)}
aria-label={ariaLabel}
role="button"
{...rest}
>
<div className={css.imageWrap}>
<CustomImage
alt={imageAlt}
delay={0}
src={imageSource}
fallbackSrc={
type === 'videoShow'
? imgType === IMAGETYPES.imgHorizontal
? defaultimgHorizontal
: defaultimgVertical
: defaultImageItem
}
onError={addDefaultImg}
/>
{priceInfo && discountRate && Number(discountRate.replace('%', '')) > 4 && (
<span className={css.discount}>{discountRate}</span>
)}
{soldoutFlag && soldoutFlag === 'Y' && (
<div className={classNames(css.soldout, countryCode === 'DE' && css.de)}>
{$L(STRING_CONF.SOLD_OUT)}
</div>
)}
{isLive && <img className={css.liveTag} src={IcLiveShow} alt="Live Show" />}
</div>
<div className={css.flexBox}>
<div
className={classNames(
css.descWrap,
catNm && css.hstNmWrap,
euEnrgLblInfos &&
euEnrgLblInfos.length > 0 &&
euEnrgLblInfos[0]?.enrgLblIcnUrl !== null &&
css.labelBox
)}
>
{logoDisplay && logo && (
<div className={css.logo}>
<CustomImage src={logo} fallbackSrc={defaultLogoImg} />
</div>
)}
<div className={css.title}>
<h3
className={css.productNameTitle}
dangerouslySetInnerHTML={{ __html: productNameDangerousHTML }}
/>
</div>
{children}
{priceInfo ? (
<p className={css.priceInfo}>
{parseFloat(originalPrice?.replace(/[^0-9.-]+/g, '') || '0') === 0 ? (
<strong>{offerInfo}</strong>
) : discountRate ? (
discountedPrice
) : (
originalPrice
)}
{discountRate && <span className={css.originalPrice}>{originalPrice}</span>}
</p>
) : (
<p className={css.offerInfo}>{offerInfo}</p>
)}
{originPrice && (
<p className={css.priceInfo}>
{dcPrice ? dcPrice : originPrice}
{dcPrice && <span className={css.originalPrice}>{originPrice}</span>}
</p>
)}
</div>
{/* 🧪 테스트: 모든 상품에 [EnergyLabel] 표시 (test.pdf 변환 확인용) */}
{/* ✅ 실제 운영: 아래 주석 삭제하고 원래 조건문으로 복구 */}
{(() => {
let energyLabels;
// 실제 API 데이터가 있으면 사용
if (euEnrgLblInfos?.length > 0 && euEnrgLblInfos[0]?.enrgLblIcnUrl !== null) {
energyLabels = euEnrgLblInfos;
} else {
// 🧪 테스트: 랜덤으로 1~3개 생성
// productId 전체를 해시해서 균등한 분산 (같은 상품은 항상 같은 개수)
const hashCode = (str) => {
let hash = 0;
const s = String(str);
for (let i = 0; i < s.length; i++) {
hash = (hash << 5) - hash + s.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
};
const seed = productId ? hashCode(productId) : Math.floor(Math.random() * 1000);
const randomCount = (seed % 3) + 1; // 1, 2, 3 중 하나
const testIcons = [testEnergyIconA, testEnergyIconB, testEnergyIconC];
const testGrades = ['A (TEST)', 'B (TEST)', 'C (TEST)'];
energyLabels = Array.from({ length: randomCount }, (_, index) => ({
enrgLblUrl: testEnergyPdf,
enrgLblIcnUrl: testIcons[index],
enrgGrade: testGrades[index],
}));
}
// 하나의 labelImgBox 안에 모든 라벨 배치 (세로로 쌓임)
return (
<div className={css.labelImgBox}>
{energyLabels
.filter((info, index) => index < 3)
.map((info, index) => (
<SpottableTemp
key={index}
spotlightDisabled={Boolean(!cursorVisible)}
onClick={(e) => onEnergyClick(e, info.enrgLblUrl)}
aria-label={`Energy Efficiency ${info.enrgGrade || ''}`}
>
<CustomImage
alt={`Energy Label ${info.enrgGrade || index + 1}`}
delay={0}
src={info.enrgLblIcnUrl}
/>
</SpottableTemp>
))}
</div>
);
})()}
{/*
원래 코드 (테스트 완료 후 복구):
{euEnrgLblInfos &&
euEnrgLblInfos.length > 0 &&
euEnrgLblInfos[0]?.enrgLblIcnUrl !== null &&
euEnrgLblInfos.map(
(info, index) =>
index < 3 && (
<div key={index} className={css.labelImgBox}>
<SpottableTemp
spotlightDisabled={Boolean(!cursorVisible)}
onClick={(e) => onEnergyClick(e, info.enrgLblUrl)}
aria-label={`Energy Efficiency ${info.enrgGrade || ""}`}
>
<CustomImage
alt={`Energy Label ${info.enrgGrade || index + 1}`}
delay={0}
src={info.enrgLblIcnUrl}
/>
</SpottableTemp>
</div>
)
)}
*/}
</div>
{isBestSeller && rank && (
<div className={css.bestSeller}>
<span>{rank}</span>
</div>
)}
</SpottableComponent>
{(() => {
const showPopup = activePopup === Config.ACTIVE_POPUP.energyPopup && currentPdfUrl;
if (!showPopup) return null;
return (
<TPopUp
kind="energyPopup"
title={$L('Energy Efficiency')}
hasText
open={popupVisible}
hasButton
button1Text={$L('CLOSE')}
onClose={handleClosePopup}
>
<div className={css.energyPopupContent}>
{convert ? (
<>
<div className={css.energyImagesContainer}>
{convert.convertedImage ? (
<img
alt="Energy Label"
src={convert.convertedImage}
className={css.energyImage}
/>
) : convert.error ? (
<div>
<p>{$L(STRING_CONF.ENERGY_ERROR)}</p>
<p style={{ fontSize: '0.8em', marginTop: '10px' }}>
{convert.error?.message || String(convert.error)}
</p>
</div>
) : (
<div>
<p>{$L(STRING_CONF.ENERGY_LOADING)}</p>
</div>
)}
</div>
</>
) : (
<div>
<p>{$L(STRING_CONF.ENERGY_ERROR)}</p>
</div>
)}
</div>
</TPopUp>
);
})()}
</>
);
});
export { IMAGETYPES, TYPES };