diff --git a/src/pages/generate/components/filterChip/FilterChip.css.ts b/src/pages/generate/components/filterChip/FilterChip.css.ts index 7429d71c1..11d2ed68a 100644 --- a/src/pages/generate/components/filterChip/FilterChip.css.ts +++ b/src/pages/generate/components/filterChip/FilterChip.css.ts @@ -6,10 +6,11 @@ import { colorVars } from '@styles/tokens/color.css'; export const filterChip = recipe({ base: { height: '3.6rem', - padding: '0rem 1.4rem', + padding: '0 1.2rem', textAlign: 'center', borderRadius: '999px', - ...fontStyle('body_r_14'), + ...fontStyle('body_r_13'), + backgroundColor: colorVars.color.gray000, color: colorVars.color.gray500, border: `1px solid ${colorVars.color.gray300}`, transition: @@ -18,10 +19,10 @@ export const filterChip = recipe({ variants: { selected: { true: { - ...fontStyle('body_m_14'), - backgroundColor: colorVars.color.primary_light2, - color: colorVars.color.primary, - border: `1px solid ${colorVars.color.primary}`, + ...fontStyle('body_m_13'), + backgroundColor: colorVars.color.gray999, + color: colorVars.color.gray000, + borderColor: 'transparent', }, false: {}, }, diff --git a/src/pages/generate/pages/result/ResultPage.css.ts b/src/pages/generate/pages/result/ResultPage.css.ts index 40e1a86ec..8ab3b3ad1 100644 --- a/src/pages/generate/pages/result/ResultPage.css.ts +++ b/src/pages/generate/pages/result/ResultPage.css.ts @@ -4,21 +4,24 @@ import { recipe } from '@vanilla-extract/recipes'; import { fontStyle } from '@/shared/styles/fontStyle'; import { animationTokens } from '@/shared/styles/tokens/animation.css'; +import { layoutVars } from '@styles/global.css'; import { colorVars } from '@styles/tokens/color.css'; export const wrapper = style({ display: 'flex', flexDirection: 'column', - alignItems: 'center', - minHeight: '66.7rem', width: '100%', + height: `calc(100dvh - ${layoutVars.titleNavBarHeight})`, // TitleNavBar height + overflow: 'hidden', }); export const resultSection = style({ display: 'flex', flexDirection: 'column', - alignItems: 'center', width: '100%', + height: '100%', + minHeight: 0, + overflow: 'hidden', }); export const imgArea = recipe({ diff --git a/src/pages/generate/pages/result/ResultPage.tsx b/src/pages/generate/pages/result/ResultPage.tsx index 6db06d04c..25e2529f4 100644 --- a/src/pages/generate/pages/result/ResultPage.tsx +++ b/src/pages/generate/pages/result/ResultPage.tsx @@ -9,18 +9,10 @@ import type { MyPageUserData, } from '@/pages/mypage/types/apis/MyPage'; import { createImageDetailPlaceholder } from '@/pages/mypage/utils/resultNavigation'; -import DislikeButton from '@/shared/components/button/likeButton/DislikeButton'; -import LikeButton from '@/shared/components/button/likeButton/LikeButton'; import Loading from '@components/loading/Loading'; import { useABTest } from '@pages/generate/hooks/useABTest'; -import { - useResultPreferenceMutation, - useDeleteResultPreferenceMutation, - useFactorsQuery, - useFactorPreferenceMutation, - useGetResultDataQuery, -} from '@pages/generate/hooks/useGenerate'; +import { useGetResultDataQuery } from '@pages/generate/hooks/useGenerate'; import { useCurationStore } from '@pages/generate/stores/useCurationStore'; import GeneratedImgA from './components/GeneratedImgA.tsx'; @@ -32,7 +24,6 @@ import type { DetectionCacheEntry } from '@pages/generate/stores/useDetectionCac import type { GenerateImageAResponse, GenerateImageBResponse, - ResultPageLikeState, GenerateImageData, } from '@pages/generate/types/generate'; @@ -68,16 +59,7 @@ const ResultPage = () => { const location = useLocation(); const [searchParams] = useSearchParams(); const { isMultipleImages } = useABTest(); - const [isLastSlide, setIsLastSlide] = useState(false); const [currentImgId, setCurrentImgId] = useState(0); - // 각 이미지별로 좋아요/싫어요 상태를 관리 (imageId를 키로 사용) - const [imageLikeStates, setImageLikeStates] = useState<{ - [imageId: number]: ResultPageLikeState; - }>({}); - // 각 이미지별로 factor 선택 상태를 관리 (imageId를 키로 사용) - const [imageFactorStates, setImageFactorStates] = useState<{ - [imageId: number]: number | null; - }>({}); const setActiveImage = useCurationStore((state) => state.setActiveImage); const resetCuration = useCurationStore((state) => state.resetAll); const activeImageIdInStore = useCurationStore((state) => state.activeImageId); @@ -190,76 +172,6 @@ const ResultPage = () => { ]); const result = resolvedResult; - // 마이페이지 히스토리를 imageId로 빠르게 조회하기 위한 Map (O(1) 조회) - const historyById = useMemo | null>( - () => - isFromMypage && mypageHistories - ? new Map( - mypageHistories.map((history: MyPageImageDetail) => [ - history.imageId, - history, - ]) - ) - : null, - [isFromMypage, mypageHistories] - ); - - // 현재 슬라이드의 좋아요/싫어요 상태를 직접 계산 - const currentLikeState = (() => { - // 1. 로컬 상태가 있으면 사용 (null도 포함) - if (imageLikeStates[currentImgId] !== undefined) { - return imageLikeStates[currentImgId]; - } - - // 2. 마이페이지 히스토리에서 찾기 (imageId로 매칭) - if (historyById) { - const currentHistory = historyById.get(currentImgId); - if (currentHistory && currentHistory.isLike !== undefined) { - // isLike가 null이면 null 반환, 그렇지 않으면 boolean 값에 따라 변환 - return currentHistory.isLike === null - ? null - : currentHistory.isLike - ? 'like' - : 'dislike'; - } - } - - return null; - })(); - - // 현재 슬라이드의 선택된 factor ID를 직접 계산 - const currentFactorId = (() => { - // 1. 로컬 상태가 있으면 사용 (null도 포함) - if (imageFactorStates[currentImgId] !== undefined) { - return imageFactorStates[currentImgId]; - } - - // 2. 마이페이지 히스토리에서 찾기 (imageId로 매칭) - if (historyById) { - const currentHistory = historyById.get(currentImgId); - if (currentHistory && currentHistory.factorId) { - return currentHistory.factorId; - } - } - - return null; - })(); - - // result가 있을 때만 mutation hook들 호출 - const { mutate: sendPreference } = useResultPreferenceMutation(); - const { mutate: deletePreference } = useDeleteResultPreferenceMutation(); - const { mutate: sendFactorPreference } = useFactorPreferenceMutation(); - - // 요인 문구 데이터 가져오기 (좋아요용) - 좋아요가 선택되었을 때만 호출 - const { data: likeFactorsData } = useFactorsQuery(true, { - enabled: currentLikeState === 'like', - }); - - // 요인 문구 데이터 가져오기 (싫어요용) - 싫어요가 선택되었을 때만 호출 - const { data: dislikeFactorsData } = useFactorsQuery(false, { - enabled: currentLikeState === 'dislike', - }); - // currentImgId가 변경될 때마다 로그 출력 // useEffect(() => { // console.log('currentImgId 변경됨:', currentImgId); @@ -295,127 +207,6 @@ const ResultPage = () => { return ; } - /** - * 좋아요/싫어요 토글 핸들러 - * - 동일 버튼 재클릭 시 상태 해제 - * - 상태 변경 시 factor 선택 초기화 및 API 연동 - */ - const handleVote = (isLike: boolean) => { - const imageId = currentImgId; - - // currentLikeState를 사용하여 현재 상태 확인 - const currentState = currentLikeState; - const newState = isLike ? 'like' : 'dislike'; - - // 같은 상태를 다시 클릭하면 취소 (null로 설정) - const finalState = currentState === newState ? null : newState; - - // 좋아요/싫어요가 취소되면 factor 선택도 초기화 - if (finalState === null) { - setImageFactorStates((prev) => ({ - ...prev, - [imageId]: null, - })); - // 취소 요청 API 호출 (DELETE) - deletePreference(imageId, { - onSuccess: () => { - setImageLikeStates((prev) => ({ - ...prev, - [imageId]: null, - })); - }, - // onError: (error) => { - // console.log('취소 API 실패:', error); - // }, - }); - } else { - // 좋아요/싫어요 상태가 바뀌었다면 현재 선택된 factor 취소 - if ( - currentState !== null && - currentState !== newState && - currentFactorId - ) { - // console.log( - // '좋아요/싫어요 상태 변경으로 factor 취소:', - // currentFactorId - // ); - sendFactorPreference({ imageId, factorId: currentFactorId }); - setImageFactorStates((prev) => ({ - ...prev, - [imageId]: null, - })); - } - - // 새로운 선택 요청 API 호출 - const apiValue = finalState === 'like'; - sendPreference( - { imageId, isLike: apiValue }, - { - onSuccess: () => { - setImageLikeStates((prev) => ({ - ...prev, - [imageId]: finalState, - })); - }, - onError: () => { - // console.log('선택 API 실패:', error); - }, - } - ); - } - }; - - // 태그 버튼 클릭 핸들러 (좋아요/싫어요 상태 변경 시 factor 취소 및 선택) - /** - * factor(선호 요인) 선택 핸들러 - * - 선택/해제에 따라 API 호출 및 로컬 상태 동기화 - */ - const handleFactorClick = (factorId: number) => { - const imageId = currentImgId; - const isSelected = currentFactorId === factorId; - - if (isSelected) { - // 이미 선택된 factor를 다시 클릭하면 선택 해제 - sendFactorPreference( - { imageId, factorId }, - { - onSuccess: () => { - setImageFactorStates((prev) => ({ - ...prev, - [imageId]: null, - })); - }, - // onError: (error) => { - // console.log('factor 취소 API 실패:', error); - // }, - } - ); - } else { - // 새로운 factor 선택 - sendFactorPreference( - { imageId, factorId }, - { - onSuccess: () => { - setImageFactorStates((prev) => ({ - ...prev, - [imageId]: factorId, - })); - }, - // onError: (error) => { - // console.log('factor 선택 API 실패:', error); - // }, - } - ); - } - }; - - /** - * 슬라이드 변경 시 마지막 슬라이드 여부를 갱신 - */ - const handleSlideChange = (currentIndex: number, totalCount: number) => { - setIsLastSlide(currentIndex === totalCount - 1); - }; - return (
@@ -423,121 +214,20 @@ const ResultPage = () => { {isMultipleImages ? ( ) : ( )} - -
-
-

이미지가 마음에 드셨나요?

-
- handleVote(true)} - isSelected={currentLikeState === 'like'} - typeVariant={'onlyIcon'} - aria-label="이미지 좋아요 버튼" - /> - handleVote(false)} - isSelected={currentLikeState === 'dislike'} - typeVariant={'onlyIcon'} - aria-label="이미지 싫어요 버튼" - /> -
- {currentLikeState === 'like' && - likeFactorsData && - likeFactorsData.length > 0 && ( -
-
- {likeFactorsData.slice(0, 2).map((factor) => ( - - ))} -
-
- {likeFactorsData.slice(2, 4).map((factor) => ( - - ))} -
-
- )} - {currentLikeState === 'dislike' && - dislikeFactorsData && - dislikeFactorsData.length > 0 && ( -
-
- {dislikeFactorsData.slice(0, 2).map((factor) => ( - - ))} -
-
- {dislikeFactorsData.slice(2, 4).map((factor) => ( - - ))} -
-
- )} -
-
+
-
); }; diff --git a/src/pages/generate/pages/result/components/DetectionHotspots.tsx b/src/pages/generate/pages/result/components/DetectionHotspots.tsx index 5018dd116..a66a0f081 100644 --- a/src/pages/generate/pages/result/components/DetectionHotspots.tsx +++ b/src/pages/generate/pages/result/components/DetectionHotspots.tsx @@ -1,31 +1,16 @@ // DetectionHotspots -// - 역할: 훅(useFurnitureHotspots)이 만든 가구 핫스팟을 렌더 -// - 파이프라인 요약: Obj365 → 가구만 선별 → cabinet만 리파인 → 가구 전체 핫스팟 렌더 -// - 비고: 스토어로 핫스팟 상태를 전달해 바텀시트와 연계 +// - 역할: 훅(useFurnitureHotspots) 기반으로 객체 인식을 수행하고, 결과를 스토어에 반영 +// - 비고: 스팟 UI는 임시 제거(성능/UX) import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - resolveFurnitureCode, - type FurnitureCategoryCode, -} from '@pages/generate/constants/furnitureCategoryMapping'; -import { useABTest } from '@pages/generate/hooks/useABTest'; import { useDetectionCache } from '@pages/generate/hooks/useDetectionCache'; -import { useGeneratedCategoriesQuery } from '@pages/generate/hooks/useFurnitureCuration'; -import { useOpenCurationSheet } from '@pages/generate/hooks/useFurnitureCuration'; import { useFurnitureHotspots } from '@pages/generate/hooks/useFurnitureHotspots'; import { useCurationStore } from '@pages/generate/stores/useCurationStore'; -import { logResultImgClickBtnSpot } from '@pages/generate/utils/analytics'; import { filterAllowedDetectedObjects, mapHotspotsToDetectedObjects, } from '@pages/generate/utils/detectedObjectMapper'; import { logFurniturePipelineEvent } from '@pages/generate/utils/furniturePipelineMonitor'; -import { - buildDetectedCodeToCategoryId, - resolveCategoryIdForHotspot, -} from '@pages/generate/utils/hotspotCategoryResolver'; -import HotspotColor from '@shared/assets/icons/icnHotspotColor.svg?react'; -import HotspotGray from '@shared/assets/icons/icnHotspotGray.svg?react'; import * as styles from './DetectionHotspots.css.ts'; @@ -33,8 +18,6 @@ import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspot import type { DetectionCacheEntry } from '@pages/generate/stores/useDetectionCacheStore'; import type { ProcessedDetections } from '@pages/generate/types/detection'; -const EMPTY_DETECTED_CODES: FurnitureCategoryCode[] = []; - const isSameHotspotArray = ( prev: FurnitureHotspot[] | null, next: FurnitureHotspot[] @@ -68,7 +51,6 @@ interface DetectionHotspotsProps { mirrored?: boolean; shouldInferHotspots?: boolean; cachedDetection?: DetectionCacheEntry | null; - groupId?: number | null; } const DetectionHotspots = ({ @@ -77,32 +59,14 @@ const DetectionHotspots = ({ mirrored = false, shouldInferHotspots = true, cachedDetection, - groupId, }: DetectionHotspotsProps) => { const [isImageLoaded, setIsImageLoaded] = useState(false); const setImageDetection = useCurationStore( (state) => state.setImageDetection ); const resetImageState = useCurationStore((state) => state.resetImageState); - const selectHotspot = useCurationStore((state) => state.selectHotspot); - const selectCategory = useCurationStore((state) => state.selectCategory); - const selectedHotspotId = useCurationStore((state) => - imageId !== null ? (state.images[imageId]?.selectedHotspotId ?? null) : null - ); - const detectedObjects = useCurationStore((state) => - imageId !== null - ? (state.images[imageId]?.detectedObjects ?? EMPTY_DETECTED_CODES) - : EMPTY_DETECTED_CODES - ); - const openSheet = useOpenCurationSheet(); - const categoriesQuery = useGeneratedCategoriesQuery( - groupId ?? null, - imageId ?? null - ); - const pendingCategoryIdRef = useRef(null); const lastSyncedHotspotsRef = useRef(null); const lastDetectionsRef = useRef(null); - const { variant } = useABTest(); const { prefetchedDetections, saveEntry } = useDetectionCache( imageId, imageUrl, @@ -124,18 +88,11 @@ const DetectionHotspots = ({ ); }; - // 훅으로 로직 이동: refs/hotspots/isLoading/error 제공 + // 훅으로 로직 이동: refs/hotspots/error 제공 // 페이지 시나리오별로 추론 사용 여부 제어 - const handleInferenceComplete = useCallback( - (result: ProcessedDetections, latestHotspots: FurnitureHotspot[]) => { - lastDetectionsRef.current = result; - saveEntry({ - processedDetections: result, - hotspots: latestHotspots, - }); - }, - [saveEntry] - ); + const handleInferenceComplete = useCallback((result: ProcessedDetections) => { + lastDetectionsRef.current = result; + }, []); useEffect(() => { if (!prefetchedDetections) return; @@ -155,51 +112,12 @@ const DetectionHotspots = ({ [prefetchedDetections, handleInferenceComplete] ); - const { imgRef, containerRef, hotspots, isLoading, error } = - useFurnitureHotspots( - imageUrl, - mirrored, - shouldInferHotspots, - hotspotOptions - ); - const allowedCategories = categoriesQuery.data?.categories; - - // 서버 응답 순서를 신뢰해 detectedObjects 와 카테고리를 1:1 매칭 - const detectedCodeToCategoryId = useMemo(() => { - return buildDetectedCodeToCategoryId(allowedCategories, detectedObjects); - }, [allowedCategories, detectedObjects]); - - type DisplayHotspot = { - hotspot: FurnitureHotspot; - resolvedCode: FurnitureCategoryCode | null; - }; - - const displayHotspots: DisplayHotspot[] = useMemo(() => { - // 서버가 허용한 카테고리와 매칭되는 핫스팟만 유지 - if (!allowedCategories || allowedCategories.length === 0) { - return []; - } - return hotspots - .map((hotspot) => { - const resolvedCode = resolveFurnitureCode({ - finalLabel: hotspot.finalLabel, - obj365Label: hotspot.label ?? null, - refinedLabel: hotspot.refinedLabel, - refinedConfidence: hotspot.confidence, - }); - const categoryId = resolveCategoryIdForHotspot( - hotspot, - resolvedCode, - allowedCategories, - detectedCodeToCategoryId - ); - if (!categoryId) return null; - return { hotspot, resolvedCode }; - }) - .filter((item): item is DisplayHotspot => Boolean(item)); - }, [hotspots, allowedCategories, detectedCodeToCategoryId]); - - const hasHotspots = displayHotspots.length > 0; + const { imgRef, containerRef, hotspots, error } = useFurnitureHotspots( + imageUrl, + mirrored, + shouldInferHotspots, + hotspotOptions + ); useEffect(() => { if (imageId === null) return; @@ -249,90 +167,6 @@ const DetectionHotspots = ({ setIsImageLoaded(false); }, [imageUrl]); - const handleHotspotClick = (hotspot: FurnitureHotspot) => { - if (imageId === null) return; - const next = - selectedHotspotId !== null && selectedHotspotId === hotspot.id - ? null - : hotspot.id; - selectHotspot(imageId, next); - if (next) { - logResultImgClickBtnSpot(variant); - logDetectionEvent('hotspot-selected', { - hotspotId: hotspot.id, - score: hotspot.score, - confidence: hotspot.confidence, - label: { - final: hotspot.finalLabel, - rawIndex: hotspot.label ?? null, - refinedKey: hotspot.refinedLabel ?? null, - }, - coords: { cx: hotspot.cx, cy: hotspot.cy }, - }); - // 요구사항: 해당 핫스팟이 바텀시트 카테고리에 존재하면 선택 + 바텀시트 확장 - const resolvedCode = resolveFurnitureCode({ - finalLabel: hotspot.finalLabel, - obj365Label: hotspot.label ?? null, - refinedLabel: hotspot.refinedLabel, - refinedConfidence: hotspot.confidence, - }); - const categoryId = resolveCategoryIdForHotspot( - hotspot, - resolvedCode, - allowedCategories, - detectedCodeToCategoryId - ); - // 매핑 디버그 로그 항상 출력 - const allowed = categoriesQuery.data?.categories ?? []; - const resolvedCategory = allowed.find((c) => c.id === categoryId); - logDetectionEvent('hotspot-mapping', { - hotspot: { - finalLabel: hotspot.finalLabel, - className: hotspot.className, - }, - resolvedCode, - allowedCategories: allowed.map((c) => ({ - id: c.id, - name: c.categoryName, - })), - resolvedCategoryId: categoryId, - resolvedCategoryName: resolvedCategory?.categoryName ?? null, - }); - if (!categoryId) return; - const inChips = categoriesQuery.data?.categories?.some( - (c) => c.id === categoryId - ); - if (inChips) { - openSheet('mid'); - selectCategory(imageId, categoryId); - pendingCategoryIdRef.current = null; - } else { - // 아직 카테고리 목록이 로딩되지 않았을 수 있어 후처리 예약 - pendingCategoryIdRef.current = categoryId; - if (!categoriesQuery.isFetching) { - categoriesQuery.refetch(); - } - } - } else { - openSheet('collapsed'); - logDetectionEvent('hotspot-cleared', { hotspotId: hotspot.id }); - } - }; - - // 후처리: 카테고리 데이터 도착 후 보류 중인 선택 적용 - useEffect(() => { - const imageIdVal = imageId; - if (!imageIdVal) return; - const pending = pendingCategoryIdRef.current; - if (!pending) return; - const has = categoriesQuery.data?.categories?.some((c) => c.id === pending); - if (has) { - openSheet('mid'); - selectCategory(imageIdVal, pending); - pendingCategoryIdRef.current = null; - } - }, [categoriesQuery.data, imageId, openSheet, selectCategory]); - if (error) { // 모델 로드 실패 시에도 이미지 자체는 보여주도록 logDetectionEvent( @@ -346,21 +180,6 @@ const DetectionHotspots = ({ 'warn' ); } - if (isLoading) { - return ( -
- {!isImageLoaded &&
} - generated setIsImageLoaded(true)} - /> -
- ); - } return (
@@ -373,23 +192,6 @@ const DetectionHotspots = ({ className={styles.image({ mirrored, loaded: isImageLoaded })} onLoad={() => setIsImageLoaded(true)} /> -
- {displayHotspots.map(({ hotspot }) => ( - - ))} -
); }; diff --git a/src/pages/generate/pages/result/components/GeneratedImg.css.ts b/src/pages/generate/pages/result/components/GeneratedImg.css.ts index badce5874..c269b0c9b 100644 --- a/src/pages/generate/pages/result/components/GeneratedImg.css.ts +++ b/src/pages/generate/pages/result/components/GeneratedImg.css.ts @@ -82,8 +82,8 @@ export const slidePrevBtn = style({ justifyContent: 'center', left: '1.2rem', bottom: '50%', - width: '2.4rem', - height: '2.4rem', + width: '3.6rem', + height: '3.6rem', backgroundColor: colorVars.color.gray999_30, borderRadius: '99.9rem', zIndex: 1, @@ -104,8 +104,8 @@ export const slideNextBtn = style({ justifyContent: 'center', right: '1.2rem', bottom: '50%', - width: '2.4rem', - height: '2.4rem', + width: '3.6rem', + height: '3.6rem', backgroundColor: colorVars.color.gray999_30, borderRadius: '99.9rem', zIndex: 1, @@ -168,21 +168,3 @@ export const moreBtn = style({ ...fontStyle('body_m_14'), color: colorVars.color.gray000, }); - -export const tagBtn = style({ - position: 'absolute', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - right: '1.2rem', - bottom: '2.4rem', - width: '2.8rem', - height: '2.8rem', - backgroundColor: colorVars.color.gray999_30, - borderRadius: '99.9rem', - zIndex: 1, - - ':active': { - backgroundColor: colorVars.color.gray999_50, - }, -}); diff --git a/src/pages/generate/pages/result/components/GeneratedImgA.tsx b/src/pages/generate/pages/result/components/GeneratedImgA.tsx index 42f5c5c4a..6dc650990 100644 --- a/src/pages/generate/pages/result/components/GeneratedImgA.tsx +++ b/src/pages/generate/pages/result/components/GeneratedImgA.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { overlay } from 'overlay-kit'; import { useNavigate } from 'react-router-dom'; @@ -9,13 +9,8 @@ import 'swiper/css/navigation'; import 'swiper/css/pagination'; import { useABTest } from '@/pages/generate/hooks/useABTest'; -import { - useOpenCurationSheet, - useSheetSnapState, -} from '@/pages/generate/hooks/useFurnitureCuration'; import { logResultImgClickBtnMoreImg, - logResultImgClickBtnTag, logResultImgClickMoreModalBack, logResultImgClickMoreModalMakeNew, logResultImgSwipeSlideLeft, @@ -31,12 +26,10 @@ import SlideNext from '@shared/assets/icons/nextAbled.svg?react'; import SlideNextDisabled from '@shared/assets/icons/nextDisabled.svg?react'; import SlidePrev from '@shared/assets/icons/prevAbled.svg?react'; import SlidePrevDisabled from '@shared/assets/icons/prevDisabled.svg?react'; -import Tag from '@shared/assets/icons/tagIcon.svg?react'; import DetectionHotspots from './DetectionHotspots'; import * as styles from './GeneratedImg.css.ts'; -import type { CurationSnapState } from '@pages/generate/stores/useCurationStore'; import type { DetectionCacheEntry } from '@pages/generate/stores/useDetectionCacheStore'; import type { GenerateImageData, @@ -61,7 +54,6 @@ interface GeneratedImgAProps { userProfile?: MyPageUserData | null; detectionCache?: Record | null; isSlideCountLoading?: boolean; - groupId?: number | null; } /** @@ -77,17 +69,12 @@ const GeneratedImgA = ({ userProfile, detectionCache, isSlideCountLoading = false, - groupId, }: GeneratedImgAProps) => { const navigate = useNavigate(); const [swiper, setSwiper] = useState(null); const [currentSlideIndex, setCurrentSlideIndex] = useState(0); const [currentImgId, setCurrentImgId] = useState(0); - const openSheet = useOpenCurationSheet(); - const { snapState, setSnapState } = useSheetSnapState(); const { variant } = useABTest(); - const prevSnapStateRef = useRef('collapsed'); - const isSheetHiddenByImageMoreRef = useRef(false); // 마이페이지 사용자 정보 (크레딧 정보 포함) const { data: fetchedUserData } = useMyPageUser({ @@ -128,37 +115,9 @@ const GeneratedImgA = ({ const lastImage = images[images.length - 1]; const totalSlideCount = lastImage ? images.length + 1 : images.length; - const isImageMoreSlide = - Boolean(lastImage) && currentSlideIndex === totalSlideCount - 1; - - const restoreSheetSnapState = useCallback(() => { - const targetState = - prevSnapStateRef.current && prevSnapStateRef.current !== 'hidden' - ? prevSnapStateRef.current - : 'collapsed'; - setSnapState(targetState); - }, [setSnapState]); - - useEffect(() => { - if (!isImageMoreSlide) { - if (isSheetHiddenByImageMoreRef.current) { - isSheetHiddenByImageMoreRef.current = false; - restoreSheetSnapState(); - } - return; - } - - if (snapState !== 'hidden') { - if (!isSheetHiddenByImageMoreRef.current) { - prevSnapStateRef.current = snapState; - } - setSnapState('hidden'); - } - isSheetHiddenByImageMoreRef.current = true; - }, [isImageMoreSlide, snapState, restoreSheetSnapState, setSnapState]); /** - * 더보기 모달을 열고 바텀시트 상태를 함께 관리 + * 더보기 모달 오픈 */ const handleOpenModal = () => { logResultImgClickBtnMoreImg(variant); @@ -166,17 +125,8 @@ const GeneratedImgA = ({ ( { unmount } // @toss/overlay-kit 사용 ) => { - const closeModal = ( - afterClose?: () => void, - options?: { restoreSnap?: boolean } - ) => { - const shouldRestore = options?.restoreSnap ?? true; + const closeModal = (afterClose?: () => void) => { unmount(); - if (shouldRestore) { - restoreSheetSnapState(); - } else { - setSnapState('collapsed'); - } afterClose?.(); }; @@ -197,9 +147,8 @@ const GeneratedImgA = ({ }} onConfirm={() => { logResultImgClickMoreModalMakeNew(variant); - closeModal( - () => navigate(ROUTES.GENERATE_START, { replace: true }), - { restoreSnap: false } + closeModal(() => + navigate(ROUTES.GENERATE_START, { replace: true }) ); }} onClose={() => { @@ -271,7 +220,6 @@ const GeneratedImgA = ({ // 결과 페이지 플래그로 추론 on/off 제어 shouldInferHotspots={shouldInferHotspots} cachedDetection={cachedDetection} - groupId={groupId} /> ); @@ -310,16 +258,6 @@ const GeneratedImgA = ({ )} -
); diff --git a/src/pages/generate/pages/result/components/GeneratedImgB.tsx b/src/pages/generate/pages/result/components/GeneratedImgB.tsx index ac833757c..3a2037220 100644 --- a/src/pages/generate/pages/result/components/GeneratedImgB.tsx +++ b/src/pages/generate/pages/result/components/GeneratedImgB.tsx @@ -1,11 +1,5 @@ import { useEffect } from 'react'; -import { useABTest } from '@/pages/generate/hooks/useABTest'; -import { useOpenCurationSheet } from '@/pages/generate/hooks/useFurnitureCuration'; -import { logResultImgClickBtnTag } from '@/pages/generate/utils/analytics'; - -import Tag from '@shared/assets/icons/tagIcon.svg?react'; - import DetectionHotspots from './DetectionHotspots'; import * as styles from './GeneratedImg.css.ts'; @@ -30,7 +24,6 @@ interface GeneratedImgBProps { onCurrentImgIdChange?: (currentImgId: number) => void; shouldInferHotspots?: boolean; detectionCache?: Record | null; - groupId?: number | null; } const GeneratedImgB = ({ @@ -38,7 +31,6 @@ const GeneratedImgB = ({ onCurrentImgIdChange, shouldInferHotspots = true, detectionCache, - groupId, }: GeneratedImgBProps) => { const result = propResult; @@ -51,8 +43,6 @@ const GeneratedImgB = ({ } const imageId = image?.imageId ?? 0; - const openSheet = useOpenCurationSheet(); - const { variant } = useABTest(); // currentImgId를 부모에게 전달하는 useEffect useEffect(() => { @@ -81,18 +71,7 @@ const GeneratedImgB = ({ // 결과 페이지 플래그로 추론 on/off 제어 shouldInferHotspots={shouldInferHotspots} cachedDetection={cachedDetection} - groupId={groupId} /> - ); }; diff --git a/src/pages/generate/pages/result/curationSheet/CardProductItem.tsx b/src/pages/generate/pages/result/curationSheet/CardProductItem.tsx index 54db51e59..104d28da9 100644 --- a/src/pages/generate/pages/result/curationSheet/CardProductItem.tsx +++ b/src/pages/generate/pages/result/curationSheet/CardProductItem.tsx @@ -8,6 +8,8 @@ import { logResultImgClickCurationSheetBtnGoSite, logResultImgClickCurationSheetBtnSave, logResultImgClickCurationSheetCard, + logResultImgClickCurationSheetCardImage, + logResultImgClickCurationSheetCardTitle, } from '@/pages/generate/utils/analytics'; import CardProduct from '@/shared/components/card/cardProduct/CardProduct'; import { useToast } from '@/shared/components/toast/useToast'; @@ -15,6 +17,27 @@ import { SESSION_STORAGE_KEYS } from '@/shared/constants/bottomSheet'; import { TOAST_TYPE } from '@/shared/types/toast'; import { useSavedItemsStore } from '@/store/useSavedItemsStore'; +const buildCurationOutboundUrl = (url: string) => { + const utmQuery = import.meta.env.VITE_CURATION_OUTBOUND_UTM_QUERY; + if (!utmQuery) return url; + + try { + const parsed = new URL(url); + const normalized = utmQuery.startsWith('?') ? utmQuery.slice(1) : utmQuery; + const params = new URLSearchParams(normalized); + + params.forEach((value, key) => { + if (!parsed.searchParams.has(key)) { + parsed.searchParams.set(key, value); + } + }); + + return parsed.toString(); + } catch { + return url; + } +}; + interface CardProductItemProps { product: { id?: number; // recommendFurnitureId @@ -23,6 +46,11 @@ interface CardProductItemProps { furnitureProductMallName: string; furnitureProductImageUrl: string; furnitureProductSiteUrl: string; + furnitureProductOriginalPrice?: number; + furnitureProductDiscountPrice?: number; + furnitureProductDiscountRate?: number; + furnitureProductColorHexes?: string[]; + furnitureProductSaveCount?: number; }; onGotoMypage: () => void; } @@ -102,14 +130,28 @@ const CardProductItem = memo( title={product.furnitureProductName} brand={product.furnitureProductMallName} imageUrl={product.furnitureProductImageUrl} - linkHref={product.furnitureProductSiteUrl} + linkHref={buildCurationOutboundUrl(product.furnitureProductSiteUrl)} isSaved={isSaved} onToggleSave={handleToggle} disabled={isMutating || !hasRecommendId} + enableWholeCardLink={true} + originalPrice={product.furnitureProductOriginalPrice} + discountPrice={product.furnitureProductDiscountPrice} + discountRate={product.furnitureProductDiscountRate} + colorHexes={product.furnitureProductColorHexes} + saveCount={product.furnitureProductSaveCount} onLinkClick={() => { logResultImgClickCurationSheetBtnGoSite(variant); }} - onCardClick={() => { + onCardClick={(area) => { + if (area === 'image') { + logResultImgClickCurationSheetCardImage(variant); + return; + } + if (area === 'title') { + logResultImgClickCurationSheetCardTitle(variant); + return; + } logResultImgClickCurationSheetCard(variant); }} /> diff --git a/src/pages/generate/pages/result/curationSheet/CurationSheet.css.ts b/src/pages/generate/pages/result/curationSheet/CurationSheet.css.ts index 5e271beee..488f5c8a0 100644 --- a/src/pages/generate/pages/result/curationSheet/CurationSheet.css.ts +++ b/src/pages/generate/pages/result/curationSheet/CurationSheet.css.ts @@ -2,24 +2,32 @@ import { style, styleVariants } from '@vanilla-extract/css'; import { fontStyle } from '@/shared/styles/fontStyle'; import { animationTokens } from '@/shared/styles/tokens/animation.css'; -import { zIndex } from '@/shared/styles/tokens/zIndex'; import { colorVars } from '@styles/tokens/color.css'; +export const container = style({ + width: '100%', + flex: '1 1 auto', + minHeight: 0, + display: 'flex', + flexDirection: 'column', + padding: '2rem 2rem 0', + backgroundColor: colorVars.color.gray000, + overflow: 'hidden', +}); + +export const title = style({ + ...fontStyle('title_m_16'), + color: colorVars.color.gray900, +}); + export const filterSection = style({ display: 'flex', gap: '0.4rem', - padding: '0.8rem 1.6rem', - margin: '0 -1.6rem', + marginTop: '0.8rem', + padding: '0.8rem 0', alignItems: 'center', - width: 'calc(100% + 3.2rem)', - minWidth: '34.3rem', backgroundColor: colorVars.color.gray000, - overflow: 'hidden', - - position: 'sticky', - top: 0, - zIndex: zIndex.sticky, overflowX: 'auto', whiteSpace: 'nowrap', @@ -54,12 +62,14 @@ export const filterSkeletonChipWidth = styleVariants({ wide: { width: '10.4rem' }, }); -export const scrollContentBase = style({ +export const content = style({ + flex: 1, + minHeight: 0, display: 'flex', flexDirection: 'column', overflowY: 'auto', - maxHeight: '52rem', - overscrollBehavior: 'contain', // 내부 스크롤 - 상위 시트 간 드래그 간섭 완화 + marginTop: '0.8rem', + overscrollBehavior: 'contain', selectors: { '&::-webkit-scrollbar': { @@ -70,31 +80,15 @@ export const scrollContentBase = style({ msOverflowStyle: 'none', // IE and Edge }); -export const scrollContentArea = styleVariants({ - mid: { height: '29rem' }, - expanded: { height: '52rem' }, -}); - -export const headerText = style({ - ...fontStyle('title_m_16'), - color: colorVars.color.gray900, - marginTop: '0.8rem', -}); - -export const curationSection = style({ - display: 'flex', - gap: '1.2rem', - marginTop: '1.6rem', - flex: 1, -}); - export const gridbox = style({ width: '100%', height: 'fit-content', display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(16.6rem, 1fr))', - columnGap: '1.1rem', - justifyItems: 'center', + gridTemplateColumns: 'repeat(2, 16.4rem)', + columnGap: '0.7rem', + rowGap: 0, + justifyContent: 'space-between', + justifyItems: 'start', }); export const statusContainer = style({ diff --git a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx index d3e3dd07b..5af0a4c46 100644 --- a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx +++ b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import clsx from 'clsx'; import { useNavigate } from 'react-router-dom'; import FilterChip from '@/pages/generate/components/filterChip/FilterChip'; @@ -11,7 +10,6 @@ import { useActiveImageId, useGeneratedCategoriesQuery, useGeneratedProductsQuery, - useSheetSnapState, } from '@/pages/generate/hooks/useFurnitureCuration'; import { useCurationCacheStore } from '@/pages/generate/stores/useCurationCacheStore'; import { useCurationStore } from '@/pages/generate/stores/useCurationStore'; @@ -20,17 +18,11 @@ import { useGetJjymListQuery } from '@/pages/mypage/hooks/useSaveItemList'; import { ROUTES } from '@/routes/paths'; import { QUERY_KEY } from '@/shared/constants/queryKey'; import { useSavedItemsStore } from '@/store/useSavedItemsStore'; -import { useUserStore } from '@/store/useUserStore'; import { getGeneratedImageProducts } from '@pages/generate/apis/furniture'; -import { - buildDetectedCodeToCategoryId, - pickHotspotIdByCategory, -} from '@pages/generate/utils/hotspotCategoryResolver'; import CardProductItem from './CardProductItem'; import * as styles from './CurationSheet.css'; -import { CurationSheetWrapper } from './CurationSheetWrapper'; import type { FurnitureProductsInfoResponse } from '@pages/generate/types/furniture'; @@ -51,27 +43,20 @@ interface CurationSheetProps { } /** - * 결과 페이지 하단 큐레이션 시트 - * - 감지된 가구 카테고리/상품을 표시하고 바텀시트 스냅 상태와 연동 + * 결과 페이지 인라인 큐레이션 섹션 + * - 감지된 가구 카테고리/상품을 고정 영역에 표시 * - 그룹 기반 진입 시 groupId를 통해 캐시·프리패치 범위를 확정 */ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { - // 전역상태 사용 - const displayName = useUserStore((state) => state.userName ?? '사용자'); const activeImageId = useActiveImageId(); const imageState = useActiveImageCurationState(); const selectedCategoryId = imageState?.selectedCategoryId ?? null; const selectCategory = useCurationStore((state) => state.selectCategory); - const selectHotspot = useCurationStore((state) => state.selectHotspot); - const hotspots = useMemo( - () => imageState?.hotspots ?? [], - [imageState?.hotspots] - ); const detectedObjects = useMemo( () => imageState?.detectedObjects ?? [], [imageState?.detectedObjects] ); - const { snapState, setSnapState } = useSheetSnapState(); + const hasDetectionCodes = detectedObjects.length > 0; const navigate = useNavigate(); const { variant } = useABTest(); @@ -98,11 +83,6 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { groupId !== null ? (state.groups[groupId]?.products ?? null) : null ); const productsData = productsQuery.data?.products; - const headerName = productsQuery.data?.userName ?? displayName; - const detectedCodeToCategoryId = useMemo( - () => buildDetectedCodeToCategoryId(categories, detectedObjects), - [categories, detectedObjects] - ); const normalizedProducts = useMemo(() => { return (productsData ?? []).map((product, index) => { @@ -116,6 +96,11 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { ? byProductId : index + 1; + const originalPrice = Number(product.furnitureProductOriginalPrice); + const discountPrice = Number(product.furnitureProductDiscountPrice); + const discountRate = Number(product.furnitureProductDiscountRate); + const saveCount = Number(product.furnitureProductSaveCount); + return { id: recommendId, isRecommendId: Boolean(recommendId), @@ -125,6 +110,23 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { furnitureProductImageUrl: product.furnitureProductImageUrl || product.baseFurnitureImageUrl, furnitureProductSiteUrl: product.furnitureProductSiteUrl, + furnitureProductOriginalPrice: Number.isFinite(originalPrice) + ? originalPrice + : undefined, + furnitureProductDiscountPrice: Number.isFinite(discountPrice) + ? discountPrice + : undefined, + furnitureProductDiscountRate: Number.isFinite(discountRate) + ? discountRate + : undefined, + furnitureProductColorHexes: Array.isArray( + product.furnitureProductColorHexes + ) + ? product.furnitureProductColorHexes + : undefined, + furnitureProductSaveCount: Number.isFinite(saveCount) + ? saveCount + : undefined, }; }); }, [productsData]); @@ -139,16 +141,6 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { setSavedProductIds(ids); }, [jjymItems, setSavedProductIds]); - useEffect(() => { - if ( - activeImageId === null && - snapState !== 'collapsed' && - snapState !== 'hidden' - ) { - setSnapState('collapsed'); - } - }, [activeImageId, snapState, setSnapState]); - // 카테고리 사전 로딩 이후, 각 카테고리별 상품을 백그라운드에서 프리패치 // - 요구사항: 객체 추론 직후 요청 가능한 값(상품 리스트)을 미리 로딩 const queryClient = useQueryClient(); @@ -199,30 +191,19 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { variables.categoryId ); }, - staleTime: 30 * 1000, + staleTime: 5 * 60 * 1000, }); }); }, [queryClient, activeImageId, categories, groupId, groupProductCache]); /** - * 카테고리 선택 시 핫스팟 동기화 및 시트 펼침 + * 카테고리 선택 */ const handleCategorySelect = (categoryId: number) => { if (activeImageId === null) return; if (selectedCategoryId === categoryId) return; logResultImgClickCurationSheetFilter(variant); selectCategory(activeImageId, categoryId); - const hotspotId = - pickHotspotIdByCategory( - categoryId, - hotspots, - categories, - detectedCodeToCategoryId - ) ?? null; - selectHotspot(activeImageId, hotspotId); - if (snapState === 'collapsed') { - setSnapState('mid'); - } }; // const LoadingDots = () => ( @@ -271,9 +252,12 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { if (activeImageId === null) { return renderStatus( '가구 추천을 보려면 생성된 이미지를 먼저 선택해 주세요', - '결과 이미지에서 핫스팟을 선택하면 추천이 표시돼요' + '상단 가구 필터에서 원하는 가구를 선택해 주세요' ); } + if (!hasDetectionCodes) { + return renderStatus('가구를 분석 중이에요', '잠시만 기다려 주세요'); + } if (categoriesQuery.isLoading) { return renderStatus( '감지된 가구를 분석 중이에요', @@ -292,7 +276,7 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { if (categories.length === 0) { return renderStatus( '감지된 가구가 없어 추천을 제공할 수 없어요', - '다른 이미지를 생성하거나 핫스팟을 다시 선택해 주세요' + '다른 이미지를 선택하거나 새로 생성해 보세요' ); } if (!selectedCategoryId) { @@ -336,62 +320,30 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { }; return ( - { - if (activeImageId === null) return; - // 시트 완전히 닫힌 뒤에만 선택 상태 해제해 목록이 사라지는 시점을 늦춤 - selectCategory(activeImageId, null); - selectHotspot(activeImageId, null); - }} - > - {(snapState) => ( - <> -
- {categories.length === 0 ? ( - // 추론 중에는 세 번째 길이(long) 스켈레톤 칩 하나만 노출 - - ) : ( - categories.map((category) => ( - handleCategorySelect(category.id)} - > - {category.categoryName} - - )) - )} -
-
-

- {headerName}님의 취향에 딱 맞는 가구 추천 -

- {/* 그리드 영역 */} -
- {renderProductSection()} -
-
- - )} -
+
+

이 공간의 가구 큐레이션

+ +
+ {categories.length === 0 ? ( + // 감지/로딩 중에는 세 번째 길이(long) 스켈레톤 칩 하나만 노출 + + ) : ( + categories.map((category) => ( + handleCategorySelect(category.id)} + > + {category.categoryName} + + )) + )} +
+ +
{renderProductSection()}
+
); }; diff --git a/src/pages/generate/types/furniture.ts b/src/pages/generate/types/furniture.ts index 0c59d4a7b..866edef12 100644 --- a/src/pages/generate/types/furniture.ts +++ b/src/pages/generate/types/furniture.ts @@ -23,6 +23,12 @@ export interface FurnitureProductInfo { furnitureProductMallName: string; furnitureProductId: string; similarity: number; + // optional fields: cardProduct/large/maximal UI + furnitureProductOriginalPrice?: number; + furnitureProductDiscountPrice?: number; + furnitureProductDiscountRate?: number; + furnitureProductColorHexes?: string[]; + furnitureProductSaveCount?: number; } // 생성 이미지 추천 상품 응답 래퍼 정의 diff --git a/src/pages/generate/utils/analytics.ts b/src/pages/generate/utils/analytics.ts index fa469e4fe..05773c619 100644 --- a/src/pages/generate/utils/analytics.ts +++ b/src/pages/generate/utils/analytics.ts @@ -233,6 +233,44 @@ export const logResultImgClickCurationSheetCard = ( }); }; +/** + * ResultImg 큐레이션 카드 이미지 클릭 이벤트 + * + * 이벤트 코드: resultImg_click_curationSheetCardImage + * - Page: resultImg + * - Action: click + * - Component: curationSheet + * - Function: CardImage + * + * 카드 이미지 영역 클릭 시 전송 (외부 링크 이동) + */ +export const logResultImgClickCurationSheetCardImage = ( + variant: ImageGenerationVariant +) => { + logAnalyticsEvent('resultImg_click_curationSheetCardImage', { + ab_variant: variant, + }); +}; + +/** + * ResultImg 큐레이션 카드 타이틀 클릭 이벤트 + * + * 이벤트 코드: resultImg_click_curationSheetCardTitle + * - Page: resultImg + * - Action: click + * - Component: curationSheet + * - Function: CardTitle + * + * 카드 타이틀/텍스트 영역 클릭 시 전송 (외부 링크 이동) + */ +export const logResultImgClickCurationSheetCardTitle = ( + variant: ImageGenerationVariant +) => { + logAnalyticsEvent('resultImg_click_curationSheetCardTitle', { + ab_variant: variant, + }); +}; + /** * 이미지 생성 시작 페이지 CTA 버튼 클릭 이벤트 * diff --git a/src/shared/assets/icons/icnHeartGrayXS.svg b/src/shared/assets/icons/icnHeartGrayXS.svg new file mode 100644 index 000000000..79361bb68 --- /dev/null +++ b/src/shared/assets/icons/icnHeartGrayXS.svg @@ -0,0 +1,12 @@ + + + diff --git a/src/shared/components/button/linkButton/LinkButton.css.ts b/src/shared/components/button/linkButton/LinkButton.css.ts index 8a67638ee..1a719ac08 100644 --- a/src/shared/components/button/linkButton/LinkButton.css.ts +++ b/src/shared/components/button/linkButton/LinkButton.css.ts @@ -6,7 +6,6 @@ import { colorVars } from '@styles/tokens/color.css'; export const linkButton = recipe({ base: { - width: '100%', height: '3rem', padding: '0.6rem', display: 'flex', @@ -25,8 +24,10 @@ export const linkButton = recipe({ variants: { type: { withText: { - width: '6.5rem', + width: 'fit-content', + minWidth: '6.5rem', gap: '0.3rem', + whiteSpace: 'nowrap', ...fontStyle('caption_r_12'), color: colorVars.color.gray700, }, diff --git a/src/shared/components/card/cardProduct/CardProduct.css.ts b/src/shared/components/card/cardProduct/CardProduct.css.ts index 47ae2b171..4c256ba75 100644 --- a/src/shared/components/card/cardProduct/CardProduct.css.ts +++ b/src/shared/components/card/cardProduct/CardProduct.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { fontStyle } from '@/shared/styles/fontStyle'; @@ -14,17 +14,27 @@ export const wrapper = recipe({ }, variants: { size: { - large: { minWidth: '16.6rem' }, + large: { minWidth: '16.4rem' }, small: { minWidth: '10.8rem' }, }, }, }); +export const clickable = style({ + cursor: 'pointer', + selectors: { + '&:focus-visible': { + outline: `2px solid ${colorVars.color.primary}`, + outlineOffset: '2px', + borderRadius: '0.8rem', + }, + }, +}); + export const imgSection = recipe({ base: { position: 'relative', // 내부 absolute(링크 버튼)의 기준 overflow: 'hidden', // 모서리 밖으로 이미지 안 튀어나오게 - borderRadius: '12px', border: `1px solid ${colorVars.color.gray200}`, width: '100%', aspectRatio: '1 / 1', // 이미지 영역만 정사각형 @@ -32,8 +42,8 @@ export const imgSection = recipe({ }, variants: { size: { - large: {}, - small: {}, + large: { borderRadius: '0.8rem' }, + small: { borderRadius: '1.2rem' }, }, }, }); @@ -67,11 +77,24 @@ export const skeleton = style({ animation: `${animationTokens.skeletonWave} 2s linear infinite`, }); -export const linkBtnContainer = style({ +export const linkBtnContainer = recipe({ + base: { + position: 'absolute', + zIndex: zIndex.button, + }, + variants: { + size: { + large: { left: '0.6rem', bottom: '0.6rem' }, + small: { left: '0.8rem', bottom: '0.8rem' }, + }, + }, +}); + +export const saveBtnOverlay = style({ position: 'absolute', + top: '0.6rem', + right: '0.6rem', zIndex: zIndex.button, - left: '0.8rem', - bottom: '0.8rem', }); export const bottomSection = style({ @@ -111,3 +134,109 @@ export const brandText = style({ ...fontStyle('caption_r_11'), color: colorVars.color.gray700, }); + +export const infoSection = style({ + display: 'flex', + flexDirection: 'column', + paddingTop: '1.2rem', + gap: '0.8rem', +}); + +export const colorRow = style({ + display: 'flex', + alignItems: 'center', + gap: '0.2rem', +}); + +export const colorChip = style({ + width: '1.4rem', + height: '1.4rem', + borderRadius: '50%', + border: `1px solid ${colorVars.color.gray999_30}`, + boxSizing: 'border-box', +}); + +export const colorChipCount = style({ + ...fontStyle('caption_r_11'), + color: colorVars.color.gray500, + marginLeft: '0.2rem', +}); + +export const productInfo = style({ + display: 'flex', + flexDirection: 'column', + gap: '0.4rem', +}); + +export const brandTextLarge = style({ + ...fontStyle('caption_r_11'), + color: colorVars.color.gray500, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const productTextLarge = style({ + ...fontStyle('body_r_13'), + color: colorVars.color.gray900, + maxHeight: '3.6rem', + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +}); + +export const priceSection = style({ + display: 'flex', + flexDirection: 'column', + gap: '0.4rem', +}); + +export const originalPriceText = style({ + ...fontStyle('caption_r_11'), + color: colorVars.color.gray500, +}); + +export const discountRow = style({ + display: 'flex', + alignItems: 'center', + gap: '0.2rem', +}); + +export const discountRateText = style({ + ...fontStyle('title_sb_15'), + color: colorVars.color.primary, +}); + +export const discountPriceText = style({ + ...fontStyle('title_sb_15'), + color: colorVars.color.gray900, +}); + +export const saveCountRow = style({ + display: 'flex', + alignItems: 'center', + gap: '0.2rem', +}); + +export const saveCountIcon = style({ + width: '1.4rem', + height: '1.4rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +globalStyle(`${saveCountIcon} svg`, { + width: '100%', + height: '100%', +}); + +globalStyle(`${saveCountIcon} path`, { + fill: colorVars.color.gray400, +}); + +export const saveCountText = style({ + ...fontStyle('caption_r_11'), + color: colorVars.color.gray400, +}); diff --git a/src/shared/components/card/cardProduct/CardProduct.tsx b/src/shared/components/card/cardProduct/CardProduct.tsx index e52d0462c..5b6e17718 100644 --- a/src/shared/components/card/cardProduct/CardProduct.tsx +++ b/src/shared/components/card/cardProduct/CardProduct.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import HeartGrayXSIcon from '@assets/icons/icnHeartGrayXS.svg?react'; import CardImage from '@assets/images/cardExImg.svg?url'; import LinkButton from '@components/button/linkButton/LinkButton'; import SaveButton from '@components/button/saveButton/SaveButton'; @@ -8,6 +9,8 @@ import * as styles from './CardProduct.css'; type CardSize = 'small' | 'large'; +type CardClickArea = 'card' | 'image' | 'title'; + interface CardProductProps { size: CardSize; title: string; @@ -19,7 +22,13 @@ interface CardProductProps { linkLabel?: string; disabled?: boolean; onLinkClick?: () => void; - onCardClick?: () => void; + onCardClick?: (area?: CardClickArea) => void; + enableWholeCardLink?: boolean; + originalPrice?: number; + discountRate?: number; + discountPrice?: number; + colorHexes?: string[]; + saveCount?: number; } const CardProduct = ({ @@ -34,13 +43,79 @@ const CardProduct = ({ disabled = false, onLinkClick, onCardClick, + enableWholeCardLink = false, + originalPrice, + discountRate, + discountPrice, + colorHexes, + saveCount, }: CardProductProps) => { const isLarge = size === 'large'; const [isLoaded, setIsLoaded] = useState(false); + useEffect(() => { + setIsLoaded(false); + }, [imageUrl]); + + const formatKrw = (value?: number) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return `${value.toLocaleString('ko-KR')}원`; + }; + + const originalPriceText = formatKrw(originalPrice); + const discountPriceText = formatKrw(discountPrice); + const discountRateText = + typeof discountRate === 'number' && Number.isFinite(discountRate) + ? `${discountRate}%` + : null; + + const visibleColors = Array.isArray(colorHexes) + ? colorHexes.filter(Boolean).slice(0, 3) + : []; + const extraColorCount = + Array.isArray(colorHexes) && colorHexes.length > 3 + ? colorHexes.length - 3 + : 0; + + const handleWrapperClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement | null; + const areaElement = target?.closest?.('[data-click-area]') as HTMLElement; + const area = areaElement?.dataset?.clickArea as CardClickArea | undefined; + const resolvedArea: CardClickArea = + area === 'image' || area === 'title' ? area : 'card'; + + onCardClick?.(resolvedArea); + + if (!enableWholeCardLink) return; + if (!linkHref) return; + if (typeof window === 'undefined') return; + + window.open(linkHref, '_blank', 'noopener,noreferrer'); + }; + + const handleWrapperKeyDown = (event: React.KeyboardEvent) => { + if (!enableWholeCardLink) return; + if (!linkHref) return; + if (typeof window === 'undefined') return; + if (event.key !== 'Enter' && event.key !== ' ') return; + + event.preventDefault(); + onCardClick?.('card'); + window.open(linkHref, '_blank', 'noopener,noreferrer'); + }; + return ( -
-
+
+
{!isLoaded &&
} 카드 이미지 setIsLoaded(true)} /> -
+ +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + role="presentation" + > {linkHref && ( )}
+ + {isLarge && ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + role="presentation" + > + +
+ )}
-
-
-

{title}

- {isLarge && !!brand &&

{brand}

} -
-
- -
-
+ + {isLarge ? ( +
+ {(visibleColors.length > 0 || extraColorCount > 0) && ( +
+ {visibleColors.map((hex, index) => ( + + ))} + {extraColorCount > 0 && ( + + +{extraColorCount} + + )} +
+ )} + +
+ {!!brand &&

{brand}

} +

{title}

+
+ + {(originalPriceText || discountPriceText) && ( +
+ {originalPriceText && ( +

{originalPriceText}

+ )} + {discountPriceText && ( +
+ {discountRateText && ( + + {discountRateText} + + )} + + {discountPriceText} + +
+ )} +
+ )} + + {typeof saveCount === 'number' && Number.isFinite(saveCount) && ( +
+ + + + + {saveCount.toLocaleString('ko-KR')} + +
+ )} +
+ ) : ( +
+
+

{title}

+ {!!brand &&

{brand}

} +
+
+
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + role="presentation" + > + +
+
+
+ )}
); }; diff --git a/src/shared/components/navBar/TitleNavBar.css.ts b/src/shared/components/navBar/TitleNavBar.css.ts index 0d7bc1ed8..0fdb58cbe 100644 --- a/src/shared/components/navBar/TitleNavBar.css.ts +++ b/src/shared/components/navBar/TitleNavBar.css.ts @@ -1,13 +1,14 @@ import { style } from '@vanilla-extract/css'; import { fontStyle } from '@/shared/styles/fontStyle'; +import { layoutVars } from '@/shared/styles/global.css'; import { colorVars } from '@/shared/styles/tokens/color.css'; import { zIndex } from '@/shared/styles/tokens/zIndex'; export const container = style({ display: 'flex', width: '100%', - height: '4.8rem', + height: layoutVars.titleNavBarHeight, justifyContent: 'space-between', alignItems: 'center', textAlign: 'center', @@ -21,7 +22,7 @@ export const leftdiv = style({ justifyContent: 'center', alignItems: 'center', width: '4.8rem', - height: '4.8rem', + height: layoutVars.titleNavBarHeight, padding: '1.2rem', }); @@ -45,7 +46,7 @@ export const rightdiv = style({ display: 'flex', justifyContent: 'center', alignItems: 'center', - height: '4.8rem', + height: layoutVars.titleNavBarHeight, padding: '1.2rem 1.6rem', ...fontStyle('body_r_14'), }); diff --git a/src/shared/styles/global.css.ts b/src/shared/styles/global.css.ts index fa1c54319..c7bb0015b 100644 --- a/src/shared/styles/global.css.ts +++ b/src/shared/styles/global.css.ts @@ -28,6 +28,7 @@ export const layoutVars = createGlobalTheme(':root', { minWidth: '375px', maxWidth: '440px', height: '100dvh', + titleNavBarHeight: '4.8rem', }); /* ===== 앱 루트 컨테이너 ===== */ diff --git a/src/shared/styles/tokens/font.css.ts b/src/shared/styles/tokens/font.css.ts index 52c423746..f4ae96b98 100644 --- a/src/shared/styles/tokens/font.css.ts +++ b/src/shared/styles/tokens/font.css.ts @@ -62,6 +62,12 @@ export const fontVars = createGlobalTheme(':root', { lineHeight: '140%', letterSpacing: '-0.01em', }, + body_m_13: { + size: '1.3rem', + weight: '500', + lineHeight: '150%', + letterSpacing: '-0.01em', + }, // Caption caption_sb_12: { diff --git a/src/stories/CardProduct.stories.tsx b/src/stories/CardProduct.stories.tsx index 519d7ceaa..85e95981c 100644 --- a/src/stories/CardProduct.stories.tsx +++ b/src/stories/CardProduct.stories.tsx @@ -19,6 +19,12 @@ const meta: Meta = { linkHref: { control: 'text' }, linkLabel: { control: 'text' }, disabled: { control: 'boolean' }, + enableWholeCardLink: { control: 'boolean' }, + originalPrice: { control: 'number' }, + discountRate: { control: 'number' }, + discountPrice: { control: 'number' }, + saveCount: { control: 'number' }, + colorHexes: { control: 'object' }, }, parameters: { docs: { @@ -73,6 +79,12 @@ export const Large: Story = { isSaved: true, linkHref: 'https://example.com', linkLabel: '공식 사이트', + enableWholeCardLink: true, + originalPrice: 129000, + discountRate: 20, + discountPrice: 99000, + saveCount: 1234, + colorHexes: ['#FFFFFF', '#000000', '#A696FF', '#E7EAEF'], }, render: (args) => , }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index de1df7d01..523e3e90c 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ interface ImportMetaEnv { readonly VITE_SENTRY_DSN?: string; readonly VITE_SENTRY_ENVIRONMENT?: string; readonly VITE_SENTRY_RELEASE?: string; + readonly VITE_CURATION_OUTBOUND_UTM_QUERY?: string; } declare const __APP_VERSION__: string;