🕐 커밋 시간: 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 컴포넌트 아키텍처 개선
564 lines
17 KiB
JavaScript
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 };
|