diff --git a/src/main.tsx b/src/main.tsx index d58dff45a..caaf07f88 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,13 @@ import '@styles/global.css'; import App from './App'; +// TODO: UI 테스트용 MSW 활성화 — API 배포 후 제거 +async function enableMocking() { + if (!import.meta.env.DEV) return; + const { worker } = await import('./mocks/browser'); + return worker.start({ onUnhandledRequest: 'bypass' }); +} + initSentry(); initClarity(); @@ -43,18 +50,22 @@ if (!rootElement) { throw new Error('Root element not found'); } -createRoot(rootElement, getSentryReactErrorHandlerOptions()).render( - // - - - - - - - {import.meta.env.DEV && } - - - - - // -); +enableMocking().then(() => { + createRoot(rootElement, getSentryReactErrorHandlerOptions()).render( + // + + + + + + + {import.meta.env.DEV && ( + + )} + + + + + // + ); +}); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index b52bf9971..639b5e968 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -28,24 +28,54 @@ export const handlers = [ }); }), - // GET /api/v1/dashboard-info — 활동 옵션 (prefetchStaticData에서 호출) - http.get('*/api/v1/dashboard-info', () => { + // GET /api/v2/dashboard/activities — 주요활동 + 활동별 필수 가구 + http.get('*/api/v2/dashboard/activities', () => { return HttpResponse.json({ code: 200, message: 'OK', data: { activities: [ - { code: 'COOKING', label: '요리' }, - { code: 'READING', label: '독서' }, + { + code: 'REMOTE_WORK', + label: '재택근무형', + furnitures: [{ id: 7, code: 'DESK', label: '업무용 책상' }], + }, + { + code: 'READING', + label: '독서형', + furnitures: [{ id: 12, code: 'BOOKSHELF', label: '책 선반' }], + }, + { + code: 'FLOOR_LIVING', + label: '좌식 생활형', + furnitures: [{ id: 9, code: 'LOW_TABLE', label: '좌식 테이블' }], + }, + { + code: 'HOME_CAFE', + label: '홈카페형', + furnitures: [{ id: 8, code: 'DINING_TABLE', label: '식탁' }], + }, ], + }, + }); + }), + + // GET /api/v2/dashboard/categories — 가구 카테고리 + 카테고리별 가구 + http.get('*/api/v2/dashboard/categories', () => { + return HttpResponse.json({ + code: 200, + message: 'OK', + data: { categories: [ { categoryId: 1, - nameKr: '침대', + nameKr: '침대/프레임', nameEng: 'BED', furnitures: [ - { id: 1, code: 'BED_SINGLE', label: '싱글 침대' }, - { id: 2, code: 'BED_DOUBLE', label: '더블 침대' }, + { id: 1, code: 'BED_SINGLE', label: '싱글' }, + { id: 2, code: 'BED_SUPER_SINGLE', label: '슈퍼싱글' }, + { id: 3, code: 'BED_DOUBLE', label: '더블' }, + { id: 4, code: 'BED_QUEEN_OR_LARGER', label: '퀸 이상' }, ], }, { @@ -53,35 +83,29 @@ export const handlers = [ nameKr: '소파', nameEng: 'SOFA', furnitures: [ - { id: 3, code: 'SOFA_2', label: '2인 소파' }, - { id: 4, code: 'SOFA_3', label: '3인 소파' }, + { id: 5, code: 'SOFA_SINGLE', label: '1인용' }, + { id: 6, code: 'SOFA_DOUBLE', label: '2인용' }, ], }, { categoryId: 3, - nameKr: '수납', - nameEng: 'STORAGE', - furnitures: [ - { id: 5, code: 'STORAGE_SHELF', label: '선반' }, - { id: 6, code: 'STORAGE_WARDROBE', label: '옷장' }, - ], - }, - { - categoryId: 4, nameKr: '테이블', nameEng: 'TABLE', furnitures: [ - { id: 7, code: 'TABLE_DINING', label: '식탁' }, - { id: 8, code: 'TABLE_COFFEE', label: '커피 테이블' }, + { id: 7, code: 'DESK', label: '업무용 책상' }, + { id: 8, code: 'DINING_TABLE', label: '식탁' }, + { id: 9, code: 'LOW_TABLE', label: '좌식 테이블' }, ], }, { - categoryId: 5, - nameKr: '선택 가구', + categoryId: 4, + nameKr: '그 외', nameEng: 'SELECTIVE', furnitures: [ - { id: 9, code: 'SELECTIVE_PLANT', label: '화분' }, - { id: 10, code: 'SELECTIVE_LAMP', label: '스탠드 조명' }, + { id: 10, code: 'MOVABLE_TV', label: '이동식 TV' }, + { id: 11, code: 'FULL_MIRROR', label: '전신 거울' }, + { id: 12, code: 'BOOKSHELF', label: '책 선반' }, + { id: 13, code: 'DISPLAY_CABINET', label: '장식장' }, ], }, ], diff --git a/src/pages/imageSetup/apis/queries/useActivitiesQuery.ts b/src/pages/imageSetup/apis/queries/useActivitiesQuery.ts new file mode 100644 index 000000000..8834aac9e --- /dev/null +++ b/src/pages/imageSetup/apis/queries/useActivitiesQuery.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; + +import { HTTPMethod, request } from '@apis/config/request'; + +import { API_ENDPOINT } from '@constants/apiEndpoints'; +import { queryKeys } from '@constants/queryKey'; + +import type { ActivitiesResponse } from '../../types/apis/activityInfo'; + +export const getActivities = async (): Promise => { + return request({ + method: HTTPMethod.GET, + url: API_ENDPOINT.IMAGE_SETUP.ACTIVITIES, + }); +}; + +export const useActivitiesQuery = () => { + return useQuery({ + queryKey: queryKeys.imageSetup.activities(), + queryFn: getActivities, + staleTime: Infinity, + gcTime: 1000 * 60 * 60 * 24, + }); +}; diff --git a/src/pages/imageSetup/apis/queries/useActivityOptionsQuery.ts b/src/pages/imageSetup/apis/queries/useActivityOptionsQuery.ts deleted file mode 100644 index d3cde358e..000000000 --- a/src/pages/imageSetup/apis/queries/useActivityOptionsQuery.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { HTTPMethod, request } from '@apis/config/request'; - -import { API_ENDPOINT } from '@constants/apiEndpoints'; -import { queryKeys } from '@constants/queryKey'; - -import type { ActivityOptionsResponse } from '../../types/apis/activityInfo'; - -export const getActivityOptions = - async (): Promise => { - return request({ - method: HTTPMethod.GET, - url: API_ENDPOINT.IMAGE_SETUP.ACTIVITY_OPTIONS, - }); - }; - -export const useActivityOptionsQuery = () => { - return useQuery({ - queryKey: queryKeys.imageSetup.activityOptions(), - queryFn: getActivityOptions, - staleTime: Infinity, - gcTime: 1000 * 60 * 60 * 24, - }); -}; diff --git a/src/pages/imageSetup/apis/queries/useFurnitureCategoriesQuery.ts b/src/pages/imageSetup/apis/queries/useFurnitureCategoriesQuery.ts new file mode 100644 index 000000000..4f0cfa0cd --- /dev/null +++ b/src/pages/imageSetup/apis/queries/useFurnitureCategoriesQuery.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { HTTPMethod, request } from '@apis/config/request'; + +import { API_ENDPOINT } from '@constants/apiEndpoints'; +import { queryKeys } from '@constants/queryKey'; + +import type { FurnitureCategoriesResponse } from '../../types/apis/activityInfo'; + +export const getFurnitureCategories = + async (): Promise => { + return request({ + method: HTTPMethod.GET, + url: API_ENDPOINT.IMAGE_SETUP.FURNITURE_CATEGORIES, + }); + }; + +export const useFurnitureCategoriesQuery = () => { + return useQuery({ + queryKey: queryKeys.imageSetup.furnitureCategories(), + queryFn: getFurnitureCategories, + staleTime: Infinity, + gcTime: 1000 * 60 * 60 * 24, + }); +}; diff --git a/src/pages/imageSetup/components/layout/FunnelLayout.tsx b/src/pages/imageSetup/components/layout/FunnelLayout.tsx index b80830dab..c0d15a31f 100644 --- a/src/pages/imageSetup/components/layout/FunnelLayout.tsx +++ b/src/pages/imageSetup/components/layout/FunnelLayout.tsx @@ -1,9 +1,10 @@ import { overlay } from 'overlay-kit'; import { useNavigate } from 'react-router-dom'; -import TitleNavBar from '@components/navBar/TitleNavBar'; import Popup from '@components/overlay/popup/Popup'; +import TitleNavBar from '@/shared/components/v2/navBar/TitleNavBar'; + import * as styles from './FunnelLayout.css'; import { logSelectHouseInfoClickModalContinue, @@ -11,14 +12,24 @@ import { logSelectHouseInfoViewModal, } from '../../utils/analytics'; +type FunnelStepKey = 'FloorPlanSelect' | 'InteriorStyle' | 'ActivityInfo'; + +// 퍼널 스텝 별 NavBar 타이틀 매핑 +const NAVBAR_TITLE_BY_STEP: Record = { + FloorPlanSelect: '공간 선택하기', + InteriorStyle: '취향 선택하기', + ActivityInfo: '가구 선택하기', +}; + interface FunnelLayoutProps { children: React.ReactNode; - currentStep: 'FloorPlanSelect' | 'InteriorStyle' | 'ActivityInfo'; + currentStep: FunnelStepKey; } const FunnelLayout = ({ children, currentStep }: FunnelLayoutProps) => { const navigate = useNavigate(); + // TODO: 퍼널 전체 탈출 가드 useBlocker로 바꾸기 const handleBackClick = () => { if (currentStep === 'FloorPlanSelect') { logSelectHouseInfoViewModal(); @@ -43,10 +54,8 @@ const FunnelLayout = ({ children, currentStep }: FunnelLayoutProps) => { return (
{ +/** + * - 주요활동/가구 카테고리 쿼리 호출 + * - 사용자 입력값 관리 (formData 상태 + Zustand persist) + * - 주요활동 선택 / 카테고리별 가구 토글 / 전역 제약 훅 위임 + * - 활동 변경 시 가구 초기화 + 필수 가구 자동 선택 + * - 제출(크레딧 체크 → sessionStorage → /generate) + */ +export const useActivityInfo = (context: ImageSetupSteps['ActivityInfo']) => { const navigate = useNavigate(); // 크레딧 가드 훅 (이미지 생성 시 1크레딧 필요) @@ -32,6 +38,28 @@ export const useActivityInfo = ( // 버튼 비활성화 상태 (토스트 표시 후 비활성화) const [isButtonDisabled, setIsButtonDisabled] = useState(false); + // 서버 데이터 — 활동 목록 + 가구 카테고리 (두 쿼리 독립 호출) + const { + data: activitiesData, + isPending: isActivitiesPending, + isError: isActivitiesError, + refetch: refetchActivities, + } = useActivitiesQuery(); + const { + data: categoriesData, + isPending: isCategoriesPending, + isError: isCategoriesError, + refetch: refetchCategories, + } = useFurnitureCategoriesQuery(); + + // 둘 중 하나라도 pending/error면 전체가 pending/error + const isPending = isActivitiesPending || isCategoriesPending; + const isError = isActivitiesError || isCategoriesError; + const refetch = () => { + if (isActivitiesError) refetchActivities(); + if (isCategoriesError) refetchCategories(); + }; + // Zustand store에서 저장된 데이터 const savedActivityInfo = useFunnelStore((state) => state.activityInfo); const savedFloorPlan = useFunnelStore((state) => state.floorPlan); @@ -39,101 +67,77 @@ export const useActivityInfo = ( // 초기값 설정: Zustand에 값이 있으면 사용, 없으면 context 사용 const [formData, setFormData] = useState({ - activityType: savedActivityInfo?.activityType ?? context.activityType, - selectiveIds: savedActivityInfo?.selectiveIds ?? context.selectiveIds ?? [], + activity: savedActivityInfo?.activity ?? context.activity, + furnitureIds: savedActivityInfo?.furnitureIds ?? context.furnitureIds ?? [], }); - // ActivityInfo는 마지막 스텝이므로 formData가 변경될 때마다 값 저장하도록 아래 로직 구현(다른 스텝에서는 스텝 이동 핸들러에서 값 저장) + // ActivityInfo는 마지막 스텝이므로 formData가 변경될 때마다 실시간으로 persist 저장 useEffect(() => { useFunnelStore.getState().setActivityInfo({ - activityType: formData.activityType, - selectiveIds: formData.selectiveIds, + activity: formData.activity, + furnitureIds: formData.furnitureIds, }); - }, [formData.activityType, formData.selectiveIds]); + }, [formData.activity, formData.furnitureIds]); - // 주요활동 선택 훅 + // 주요활동 선택 훅 — 활동 객체 + 필수 가구 추출 담당 const activitySelection = useActivitySelection( - activityOptionsData, - formData.activityType, - (activityType) => { - setFormData((prev) => ({ ...prev, activityType: activityType })); - } + activitiesData?.activities, + formData.activity ); - // 전역 제약조건 훅 + // 전역 제약조건 훅 (최대 6개 + 필수 가구 보호) const globalConstraints = useGlobalConstraints( - formData.selectiveIds, + formData.furnitureIds, activitySelection.getRequiredFurnitureIds(), - !!formData.activityType + !!formData.activity ); - // 각 카테고리별 가구 선택 훅 + // 카테고리별 가구 선택 훅 (API 카테고리 순서 기준 인덱싱) const bed = useCategorySelection( - activityOptionsData?.categories[0] || null, + categoriesData?.categories[0] ?? null, + CATEGORY_SELECTION_MODE.BED, formData, setFormData, globalConstraints ); const sofa = useCategorySelection( - activityOptionsData?.categories[1] || null, - formData, - setFormData, - globalConstraints - ); - const storage = useCategorySelection( - activityOptionsData?.categories[2] || null, + categoriesData?.categories[1] ?? null, + CATEGORY_SELECTION_MODE.SOFA, formData, setFormData, globalConstraints ); const table = useCategorySelection( - activityOptionsData?.categories[3] || null, + categoriesData?.categories[2] ?? null, + CATEGORY_SELECTION_MODE.TABLE, formData, setFormData, globalConstraints ); const selective = useCategorySelection( - activityOptionsData?.categories[4] || null, + categoriesData?.categories[3] ?? null, + CATEGORY_SELECTION_MODE.SELECTIVE, formData, setFormData, globalConstraints ); // 카테고리 선택 객체 구성 - const categorySelections = activityOptionsData - ? { bed, sofa, storage, table, selective } + const categorySelections = categoriesData + ? { bed, sofa, table, selective } : null; - // 선택된 주요 활동의 라벨을 반환하는 메서드 - const selectedActivityLabel = activityOptionsData?.activities.find( - (activity) => activity.code === formData.activityType - )?.label; - // 선택된 주요 활동의 필수 가구 라벨들을 반환하는 메서드 - const getRequiredFurnitureLabels = (): string[] => { - if (!formData.activityType || !activityOptionsData) return []; - - const requiredIds = activitySelection.getRequiredFurnitureIds(); - const labels: string[] = []; - - for (const category of activityOptionsData.categories) { - for (const furniture of category.furnitures) { - if (requiredIds.includes(furniture.id)) { - labels.push(furniture.label); - } - } - } - - return labels; - }; + // 선택된 주요 활동의 라벨 + const selectedActivityLabel = activitySelection.selectedActivityItem?.label; // 타입 가드: 완료된 데이터인지 확인 const isCompleteActivityInfo = ( data: ActivityInfoFormData ): data is Required => { return !!( - data.activityType && - Array.isArray(data.selectiveIds) && - data.selectiveIds.length > 0 + data.activity && + Array.isArray(data.furnitureIds) && + data.furnitureIds.length > 0 ); }; @@ -142,31 +146,32 @@ export const useActivityInfo = ( // 주요활동 변경 시 기존 가구 초기화 후 필수 가구 자동 선택 useEffect(() => { - // Zustand에 저장된 데이터가 있으면 해당 데이터 유지 - if (savedActivityInfo?.activityType === formData.activityType) { + // Zustand에 저장된 데이터가 있으면 해당 데이터 유지 (복원 케이스 스킵) + if (savedActivityInfo?.activity === formData.activity) { return; } - if (formData.activityType) { + if (formData.activity) { const requiredIds = activitySelection.getRequiredFurnitureIds(); setFormData((prev) => ({ ...prev, - selectiveIds: requiredIds, + furnitureIds: requiredIds, })); } else { // 주요활동이 해제된 경우 모든 가구 선택 해제 setFormData((prev) => ({ ...prev, - selectiveIds: [], + furnitureIds: [], })); } - }, [formData.activityType, savedActivityInfo]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData.activity, savedActivityInfo]); // 제출 핸들러 const handleSubmit = async () => { if (!isFormCompleted) return; - // 중복 클릭 방지 (CreditBox 패턴) + // 중복 클릭 방지 if (isChecking || isButtonDisabled) return; // CTA 버튼 클릭 시 GA 이벤트 전송 @@ -175,14 +180,13 @@ export const useActivityInfo = ( // 이미지 생성 전 크레딧 확인 const hasCredit = await checkCredit(); if (!hasCredit) { - // 크레딧 부족 시 CreditError 이벤트 전송 logSelectFurnitureClickBtnCTACreditError(); - // console.log('크레딧이 부족하여 이미지 생성을 중단합니다'); - setIsButtonDisabled(true); // 크레딧 부족 시 버튼 비활성화 + setIsButtonDisabled(true); return; } - // TODO: 경로별 이미지 생성 API 분리 시 재설계 필요 + // TODO: 이미지 생성 API 명세 확정 시 selectiveIds → furnitureIds 직접 대입으로 변경 + // (현재는 서버가 selectiveIds로 받고 있어 클라이언트 furnitureIds를 매핑) const generateImageRequest: GenerateImageRequest = { houseId: 0, // TODO: 경로별 API 분리 후 제거 equilibrium: '', // TODO: 경로별 API 분리 후 제거 @@ -192,8 +196,8 @@ export const useActivityInfo = ( isMirror: savedFloorPlan?.isMirror ?? context.floorPlan.isMirror, }, moodBoardIds: savedMoodBoardIds ?? context.moodBoardIds, - activity: formData.activityType!, - selectiveIds: formData.selectiveIds!, + activity: formData.activity!, + selectiveIds: formData.furnitureIds!, }; // sessionStorage에 저장 @@ -201,9 +205,7 @@ export const useActivityInfo = ( 'generate_image_request', JSON.stringify(generateImageRequest) ); - // console.log('ActivityInfo: sessionStorage에 requestData 저장'); - // navigate(ROUTES.GENERATE, { state: { generateImageRequest } }); navigate(ROUTES.GENERATE); // 퍼널 완료 후 Zustand 초기화 @@ -211,7 +213,12 @@ export const useActivityInfo = ( }; return { - // 상태 + // 서버 데이터 상태 + isPending, + isError, + refetch, + + // 폼 상태 formData, setFormData, isFormCompleted, @@ -219,9 +226,13 @@ export const useActivityInfo = ( // 주요활동 관련 activitySelection, selectedActivityLabel, - getRequiredFurnitureLabels, + getRequiredFurnitureLabels: activitySelection.getRequiredFurnitureLabels, + + // 활동 목록 (바텀시트용) + activities: activitiesData?.activities ?? [], - // 가구 카테고리 선택 관련 + // 가구 카테고리 데이터, 선택 상태 + categories: categoriesData?.categories ?? [], categorySelections, // 전역 제약조건 diff --git a/src/pages/imageSetup/hooks/activityInfo/useActivitySelection.ts b/src/pages/imageSetup/hooks/activityInfo/useActivitySelection.ts index 69b08e3e7..48a79d628 100644 --- a/src/pages/imageSetup/hooks/activityInfo/useActivitySelection.ts +++ b/src/pages/imageSetup/hooks/activityInfo/useActivitySelection.ts @@ -1,61 +1,32 @@ -import { MAIN_ACTIVITY_VALIDATION } from '../../types/funnel/validation'; - -import type { ActivityOptionsResponse } from '../../types/apis/activityInfo'; +import type { ActivityItem } from '../../types/apis/activityInfo'; /** * 주요활동 선택 로직을 담당하는 훅 */ export const useActivitySelection = ( - activityOptionsData?: ActivityOptionsResponse, - selectedActivity?: string, - onActivityChange?: (activityType?: string) => void + activities: ActivityItem[] | undefined, + selectedActivity: string | undefined ) => { - // 타입 가드: 유효한 '주요활동' 카테고리 내의 값인지 체크 - const isValidActivityKey = ( - usage: string - ): usage is keyof typeof MAIN_ACTIVITY_VALIDATION.combinationRules => { - return usage in MAIN_ACTIVITY_VALIDATION.combinationRules; - }; + // 현재 선택된 주요활동 객체 + const selectedActivityItem = selectedActivity + ? activities?.find((activity) => activity.code === selectedActivity) + : undefined; - // 현재 선택된 활동의 필수 가구 ID 리스트 반환 + // 선택된 활동의 필수 가구 ID 리스트 const getRequiredFurnitureIds = (): number[] => { - if ( - !selectedActivity || - !isValidActivityKey(selectedActivity) || - !activityOptionsData - ) - return []; - - const requiredFurnitureCodes = - MAIN_ACTIVITY_VALIDATION.combinationRules[selectedActivity] - ?.requiredFurnitures || []; - - // 모든 카테고리의 모든 가구에서 필수 가구 찾기 - return requiredFurnitureCodes - .map((code) => { - for (const category of activityOptionsData.categories) { - const furniture = category.furnitures.find((f) => f.code === code); - if (furniture) return furniture.id; - } - return undefined; - }) - .filter((id): id is number => id !== undefined); // API 데이터와 로컬 validation 데이터 불일치 시 undefined 반환, 해당 케이스 처리하는 로직 + if (!selectedActivityItem) return []; + return selectedActivityItem.furnitures.map((furniture) => furniture.id); }; - // ButtonGroup 배열 인터페이스와 단일 선택 비즈니스 로직 간 어댑터 - const handleActivityChange = (values: string[]) => { - // 주요활동은 단일선택만 가능하지만, ButtonGroup의 onSelectionChange를 다중선택 기준으로 설계(T[]) → values의 타입을 배열로 선언, 메서드 내에서 values[0]으로 단일값으로 처리 - const newActivity = values[0] || undefined; - onActivityChange?.(newActivity); + // 선택된 활동의 필수 가구 label 리스트 (토스트/안내 문구 용도) + const getRequiredFurnitureLabels = (): string[] => { + if (!selectedActivityItem) return []; + return selectedActivityItem.furnitures.map((furniture) => furniture.label); }; - // ButtonGroup에서 사용할 selectedValues - const selectedValues = selectedActivity ? [selectedActivity] : []; // 단일 값을 ButtonGroup 인터페이스에 맞게 배열로 변환 - return { - selectedValues, - handleActivityChange, + selectedActivityItem, getRequiredFurnitureIds, - isValidActivityKey, + getRequiredFurnitureLabels, }; }; diff --git a/src/pages/imageSetup/hooks/activityInfo/useCategorySelection.ts b/src/pages/imageSetup/hooks/activityInfo/useCategorySelection.ts index 70cbef535..1d5e6791a 100644 --- a/src/pages/imageSetup/hooks/activityInfo/useCategorySelection.ts +++ b/src/pages/imageSetup/hooks/activityInfo/useCategorySelection.ts @@ -1,55 +1,77 @@ import type { useGlobalConstraints } from './useGlobalConstraints'; import type { FurnitureCategory } from '../../types/apis/activityInfo'; -import type { ActivityInfoFormData } from '../../types/funnel/activityInfo'; +import type { + ActivityInfoFormData, + CategorySelectionMode, +} from '../../types/funnel/activityInfo'; /** * 특정 카테고리의 가구 선택 로직을 관리하는 훅 + * Chip 가구 토글 인터페이스 제공 + * - single모드: 카테고리 내 하나만 선택 가능 (재선택 시 해제, 다른 항목 선택 시 기존 항목 교체) + * - multiple모드: 카테고리 내 여러 개 선택 가능 (전역 최대 6개 + 필수 가구 제약 적용) */ export const useCategorySelection = ( category: FurnitureCategory | null, + mode: CategorySelectionMode, formData: ActivityInfoFormData, setFormData: React.Dispatch>, - globalConstraints: ReturnType // 훅의 반환타입 - // +) globalConstraints의 applyConstraints(), canSelectFurniture() 두 가지 함수만 사용하므로 Duck Typing을 사용한 globalConstraints의 타입 선언도 가능(유연성 증가, 안전성 감소) + globalConstraints: ReturnType ) => { - // 카테고리가 없는 경우 빈 배열 반환 + // 카테고리가 없는 경우 빈 인터페이스 반환 if (!category) { return { - selectedValues: [], - handleChange: () => {}, - furnitureStatus: [], + selectedValues: [] as number[], + toggleFurniture: () => {}, + furnitureStatus: [] as { id: number; isActive: boolean }[], }; } // 현재 카테고리에서 선택된 가구 ID들 const selectedValues = - formData.selectiveIds?.filter((id) => + formData.furnitureIds?.filter((id) => category.furnitures.some((f) => f.id === id) ) || []; - // 카테고리 가구 선택 변경 핸들러 - // ButtonGroup 내부 로직에서 단일/다중 선택 분기처리 후 ids파라미터에 선택된 값이 전달됨 - const handleChange = (ids: number[]) => { - // ids: 해당 카테고리에서 선택한 모든 가구들(ButtonGroup에서 전달) + // 가구 토글 + // - 이미 선택된 가구: 선택 해제 (필수 가구는 해제 불가) + // - 미선택 가구 + single: 같은 카테고리 기존 선택 제거 후 추가 + // - 미선택 가구 + multiple: 추가 후 전역 제약(최대 6개 + 필수 가구 포함) 적용 + const toggleFurniture = (furnitureId: number) => { + const currentIds = formData.furnitureIds || []; + const isSelected = currentIds.includes(furnitureId); - // 현재 선택된 모든 가구들 - const currentIds = formData.selectiveIds || []; + // 1. 선택한 값 해제: single/multiple 공통 + if (isSelected) { + if (!globalConstraints.canDeselect(furnitureId)) return; + const updatedFurnitureIds = currentIds.filter((id) => id !== furnitureId); + setFormData((prev) => ({ ...prev, furnitureIds: updatedFurnitureIds })); + return; + } - // 현재 카테고리 이외의 카테고리에서 선택된 모든 가구들 - const otherCategoryIds = currentIds.filter( - (id) => !category.furnitures.some((f) => f.id === id) - ); + // 2. 선택값 추가: single/multiple 분기 + if (mode === 'single') { + // 이 카테고리에 속한 기존 선택을 모두 제거하고 새 항목만 추가 + const preservedIds = currentIds.filter( + (id) => !category.furnitures.some((f) => f.id === id) + ); + const updatedFurnitureIds = globalConstraints.applyConstraints([ + ...preservedIds, + furnitureId, + ]); + setFormData((prev) => ({ ...prev, furnitureIds: updatedFurnitureIds })); + return; + } - // 기존에 선택한 가구와 새롭게 선택한 가구를 합치고, 제약조건 적용 - const updatedIds = globalConstraints.applyConstraints([ - ...otherCategoryIds, - ...ids, + // multiple: 현재 선택에 새 항목 추가 후 전역 제약 적용 + const updatedFurnitureIds = globalConstraints.applyConstraints([ + ...currentIds, + furnitureId, ]); - - setFormData((prev) => ({ ...prev, selectiveIds: updatedIds })); + setFormData((prev) => ({ ...prev, furnitureIds: updatedFurnitureIds })); }; - // 각 가구별 활성화 상태 정보 + // 각 가구별 활성화 상태 정보 (Chip의 disabled 매핑) const furnitureStatus = category.furnitures.map((furniture) => ({ id: furniture.id, isActive: globalConstraints.canSelectFurniture(furniture.id), @@ -57,7 +79,7 @@ export const useCategorySelection = ( return { selectedValues, - handleChange, + toggleFurniture, furnitureStatus, }; }; diff --git a/src/pages/imageSetup/steps/activityInfo/ActivityInfo.css.ts b/src/pages/imageSetup/steps/activityInfo/ActivityInfo.css.ts index 26f8b1af3..52b46c09b 100644 --- a/src/pages/imageSetup/steps/activityInfo/ActivityInfo.css.ts +++ b/src/pages/imageSetup/steps/activityInfo/ActivityInfo.css.ts @@ -1,32 +1,70 @@ import { style } from '@vanilla-extract/css'; -import { animationTokens } from '@styles/tokens/animation.css'; +import { colorVars } from '@styles/tokensV2/color.css'; +import { fontVars } from '@styles/tokensV2/font.css'; +import { unitVars } from '@styles/tokensV2/unit.css'; export const container = style({ display: 'flex', flex: 1, flexDirection: 'column', + gap: unitVars.unit.gapPadding['800'], + backgroundColor: colorVars.color.bg.primary, width: '100%', }); -export const contents = style({ +export const mainArea = style({ + display: 'flex', + flex: 1, + flexDirection: 'column', + gap: unitVars.unit.gapPadding['800'], + padding: unitVars.unit.gapPadding['500'], +}); + +// 주요활동 섹션 +export const activitySection = style({ display: 'flex', flexDirection: 'column', - gap: '4rem', - padding: '2rem', - animation: animationTokens.fadeInUpFast, + gap: unitVars.unit.gapPadding['400'], }); -export const activityButton = style({ - marginTop: '1.6rem', +// 가구 섹션 +export const furnitureSection = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['400'], }); -export const caption = style({ - marginTop: '1.4rem', +// 카테고리 리스트 (세로 배열) +export const furList = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['400'], }); -export const furnitures = style({ +// 개별 카테고리 (제목 + chip 리스트) +export const categoryGroup = style({ display: 'flex', flexDirection: 'column', - gap: '2.4rem', + gap: unitVars.unit.gapPadding['200'], +}); + +// 카테고리 제목 +export const furTitle = style({ + ...fontVars.font.body_m_14, + padding: `0 ${unitVars.unit.gapPadding['100']}`, + color: colorVars.color.text.secondary, +}); + +// Chip 리스트 (flex-wrap) +export const chipList = style({ + display: 'flex', + flexWrap: 'wrap', + gap: unitVars.unit.gapPadding['100'], +}); + +// CTA 버튼 영역 +export const buttonWrapper = style({ + padding: `0 ${unitVars.unit.gapPadding['500']}`, + paddingBottom: unitVars.unit.gapPadding['500'], }); diff --git a/src/pages/imageSetup/steps/activityInfo/ActivityInfo.tsx b/src/pages/imageSetup/steps/activityInfo/ActivityInfo.tsx index e182c3566..7e1c2045a 100644 --- a/src/pages/imageSetup/steps/activityInfo/ActivityInfo.tsx +++ b/src/pages/imageSetup/steps/activityInfo/ActivityInfo.tsx @@ -1,17 +1,17 @@ -import { useActivityOptionsQuery } from '@pages/imageSetup/apis/queries/useActivityOptionsQuery'; -import { FUNNELHEADER_IMAGES } from '@pages/imageSetup/constants/headerImages'; +import { overlay } from 'overlay-kit'; + import { useActivityInfo } from '@pages/imageSetup/hooks/activityInfo/useActivityInfo'; -import { useCreditCheck } from '@pages/imageSetup/hooks/useCreditCheck'; -import CtaButton from '@components/button/ctaButton/CtaButton'; import InlineError from '@components/inlineError/InlineError'; import Loading from '@components/loading/Loading'; +import ActionButton from '@components/v2/button/actionButton/ActionButton'; +import Chip from '@components/v2/chip/Chip'; +import Icon from '@components/v2/icon/Icon'; +import TextHeading from '@components/v2/textHeading/TextHeading'; import * as styles from './ActivityInfo.css'; -import ButtonGroup from '../../components/buttonGroup/ButtonGroup'; -import Caption from '../../components/caption/Caption'; -import FunnelHeader from '../../components/header/FunnelHeader'; -import HeadingText from '../../components/headingText/HeadingText'; +import ActivityTypeSheet from './ActivityTypeSheet'; +import SelectTrigger from './SelectTrigger'; import type { ImageSetupSteps } from '../../types/funnel/steps'; @@ -21,166 +21,136 @@ interface ActivityInfoProps { const ActivityInfo = ({ context }: ActivityInfoProps) => { const { - data: activityOptionsData, isPending, isError, refetch, - } = useActivityOptionsQuery(); - - // console.log(activityOptionsData); - - const { - handleSubmit, + setFormData, isFormCompleted, - selectedActivityLabel, - getRequiredFurnitureLabels, activitySelection, + selectedActivityLabel, + activities, + categories, categorySelections, - } = useActivityInfo(context, activityOptionsData); - - // 크레딧 체크 훅 - const { hasCredit, isCreditChecked, checkCredit } = useCreditCheck(); + globalConstraints, + handleSubmit, + } = useActivityInfo(context); + + const handleActivityTriggerClick = () => { + overlay.open(({ unmount }) => ( + { + setFormData((prev) => ({ ...prev, activity: activityCode })); + unmount(); + }} + onClose={unmount} + /> + )); + }; - // 이미지 생성 핸들러 - const handleImageGeneration = () => { - const isValid = checkCredit(); // 크레딧 확인 및 CTA 버튼 상태 관리 + if (isError) return ; + if (isPending || !categorySelections) return ; - if (isValid) { - handleSubmit(); - } + // nameEng → selection 매핑 + const selectionByNameEng: Record = { + BED: categorySelections.bed, + SOFA: categorySelections.sofa, + TABLE: categorySelections.table, + SELECTIVE: categorySelections.selective, }; - const isReady = - !isError && !isPending && activityOptionsData && categorySelections; - return (
- - - {isError ? ( - - ) : !isReady ? ( - - ) : ( -
-
- -
- - options={activityOptionsData.activities} - selectedValues={activitySelection.selectedValues} - onSelectionChange={activitySelection.handleActivityChange} - keyExtractor={(option) => option.code} - selectionMode="single" - buttonSize="large" - layout="grid-2" - /> -
- {selectedActivityLabel && ( -
- -
- )} -
+
+ + +
+ + +
-
- + - - title={activityOptionsData.categories[0].nameKr} - titleSize="small" - hasBorder={true} - options={activityOptionsData.categories[0].furnitures} - selectedValues={categorySelections.bed.selectedValues} - onSelectionChange={categorySelections.bed.handleChange} - keyExtractor={(option) => option.id!} - selectionMode="single" - buttonSize="xsmall" - layout="grid-4" - buttonStatuses={categorySelections.bed.furnitureStatus} - /> - - - title={activityOptionsData.categories[1].nameKr} - titleSize="small" - hasBorder={true} - options={activityOptionsData.categories[1].furnitures} - selectedValues={categorySelections.sofa.selectedValues} - onSelectionChange={categorySelections.sofa.handleChange} - keyExtractor={(option) => option.id!} - selectionMode="single" - buttonSize="medium" - layout="grid-2" - buttonStatuses={categorySelections.sofa.furnitureStatus} - /> - - - title={activityOptionsData.categories[2].nameKr} - titleSize="small" - options={activityOptionsData.categories[2].furnitures} - selectedValues={categorySelections.storage.selectedValues} - onSelectionChange={categorySelections.storage.handleChange} - keyExtractor={(option) => option.id!} - selectionMode="multiple" - buttonSize="large" - layout="grid-2" - buttonStatuses={categorySelections.storage.furnitureStatus} + caption="선택한 가구들로 이미지를 생성해드려요. (최대 6개)" /> - - - title={activityOptionsData.categories[3].nameKr} - titleSize="small" - options={activityOptionsData.categories[3].furnitures} - selectedValues={categorySelections.table.selectedValues} - onSelectionChange={categorySelections.table.handleChange} - keyExtractor={(option) => option.id!} - selectionMode="multiple" - buttonSize="small" - layout="grid-3" - buttonStatuses={categorySelections.table.furnitureStatus} - /> - - - title={activityOptionsData.categories[4].nameKr} - titleSize="small" - options={activityOptionsData.categories[4].furnitures} - selectedValues={categorySelections.selective.selectedValues} - onSelectionChange={categorySelections.selective.handleChange} - keyExtractor={(option) => option.id!} - selectionMode="multiple" - buttonSize="large" - layout="grid-2" - buttonStatuses={categorySelections.selective.furnitureStatus} - /> -
- -
- - 이미지 생성하기 - +
+ {/* TODO: 추후 Chip 최신화하기 (아이콘 포함 Chip 반영) */} + {categories.map((category) => { + const selection = selectionByNameEng[category.nameEng]; + if (!selection) return null; + + return ( +
+ {category.nameKr} +
+ {category.furnitures.map((furniture) => { + const isSelected = selection.selectedValues.includes( + furniture.id + ); + const status = selection.furnitureStatus.find( + (s) => s.id === furniture.id + ); + const isRequired = + globalConstraints.isRequiredFurniture(furniture.id); + + return ( + + ) : undefined + } + onClick={() => + selection.toggleFurniture(furniture.id) + } + > + {furniture.label} + + ); + })} +
+
+ ); + })} +
+ )} +
+ + {selectedActivityLabel && ( +
+ + 이미지 생성하기 +
)}
diff --git a/src/pages/imageSetup/steps/activityInfo/ActivityTypeSheet.css.ts b/src/pages/imageSetup/steps/activityInfo/ActivityTypeSheet.css.ts new file mode 100644 index 000000000..8daf8cb13 --- /dev/null +++ b/src/pages/imageSetup/steps/activityInfo/ActivityTypeSheet.css.ts @@ -0,0 +1,96 @@ +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +import { colorVars } from '@styles/tokensV2/color.css'; +import { fontVars } from '@styles/tokensV2/font.css'; +import { unitVars } from '@styles/tokensV2/unit.css'; + +export const contents = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['500'], + width: '100%', +}); + +export const radioList = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['100'], + width: '100%', +}); + +export const radioItem = recipe({ + base: { + display: 'flex', + alignItems: 'center', + transition: 'transform 120ms ease, background-color 120ms ease', + border: 'none', + borderRadius: unitVars.unit.radius.full, + cursor: 'pointer', + padding: `${unitVars.unit.gapPadding['200']} ${unitVars.unit.gapPadding['400']}`, + width: '100%', + minWidth: '6.4rem', + height: '4.4rem', + font: 'inherit', + selectors: { + '&:active': { + transform: 'scale(0.95)', + }, + }, + }, + variants: { + selected: { + false: { + backgroundColor: colorVars.color.fill.inverse, + }, + true: { + backgroundColor: colorVars.color.fill.weak, + }, + }, + }, +}); + +export const radioContents = style({ + display: 'flex', + alignItems: 'center', + gap: unitVars.unit.gapPadding['100'], +}); + +export const radioLabel = style({ + ...fontVars.font.body_r_14, + color: colorVars.color.text.primary, +}); + +export const divider = recipe({ + base: { + borderRadius: '50%', + width: '0.3rem', + height: '0.3rem', + }, + variants: { + selected: { + false: { + backgroundColor: colorVars.color.text.tertiary, + }, + true: { + backgroundColor: colorVars.color.text.secondary, + }, + }, + }, +}); + +export const requiredLabel = recipe({ + base: { + ...fontVars.font.body_r_14, + }, + variants: { + selected: { + false: { + color: colorVars.color.text.tertiary, + }, + true: { + color: colorVars.color.text.secondary, + }, + }, + }, +}); diff --git a/src/pages/imageSetup/steps/activityInfo/ActivityTypeSheet.tsx b/src/pages/imageSetup/steps/activityInfo/ActivityTypeSheet.tsx new file mode 100644 index 000000000..6811a1dd3 --- /dev/null +++ b/src/pages/imageSetup/steps/activityInfo/ActivityTypeSheet.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; + +import DragHandleBottomSheet from '@components/v2/bottomSheet/DragHandleBottomSheet'; +import ActionButton from '@components/v2/button/actionButton/ActionButton'; +import Icon from '@components/v2/icon/Icon'; +import TextHeading from '@components/v2/textHeading/TextHeading'; + +import { getActivityIconName } from './activityIcons'; +import * as styles from './ActivityTypeSheet.css'; + +import type { ActivityItem } from '../../types/apis/activityInfo'; + +interface ActivityTypeSheetProps { + open: boolean; + activities: ActivityItem[]; + selectedActivityCode?: string; + onConfirm: (activityCode: string) => void; + onClose: () => void; +} + +const ActivityTypeSheet = ({ + open, + activities, + selectedActivityCode, + onConfirm, + onClose, +}: ActivityTypeSheetProps) => { + // 로컬 선택 상태 (확인 버튼 클릭 전까지 context에 미반영) + const [localSelected, setLocalSelected] = useState( + selectedActivityCode + ); + + const handleConfirm = () => { + if (!localSelected) return; + onConfirm(localSelected); + }; + + return ( + + +
+ {activities.map((activity) => { + const isSelected = localSelected === activity.code; + const iconName = getActivityIconName( + activity.code, + isSelected ? 'black' : 'gray' + ); + const requiredFurnitureLabel = activity.furnitures[0]?.label; + + return ( + + ); + })} +
+
+ } + primaryButton={ + + 선택하기 + + } + /> + ); +}; + +export default ActivityTypeSheet; diff --git a/src/pages/imageSetup/steps/activityInfo/SelectTrigger.css.ts b/src/pages/imageSetup/steps/activityInfo/SelectTrigger.css.ts new file mode 100644 index 000000000..588524504 --- /dev/null +++ b/src/pages/imageSetup/steps/activityInfo/SelectTrigger.css.ts @@ -0,0 +1,60 @@ +import { style } from '@vanilla-extract/css'; + +import { colorVars } from '@styles/tokensV2/color.css'; +import { fontVars } from '@styles/tokensV2/font.css'; +import { unitVars } from '@styles/tokensV2/unit.css'; + +export const trigger = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + transition: 'transform 120ms ease', + border: 'none', + borderRadius: unitVars.unit.radius.full, + backgroundColor: colorVars.color.fill.weak, + cursor: 'pointer', + padding: `${unitVars.unit.gapPadding['300']} ${unitVars.unit.gapPadding['400']}`, + width: '100%', + height: '4.8rem', + selectors: { + '&:active': { + transform: 'scale(0.95)', + }, + }, +}); + +export const leftContainer = style({ + display: 'flex', + alignItems: 'center', + gap: unitVars.unit.gapPadding['100'], +}); + +export const labelContainer = style({ + display: 'flex', + alignItems: 'center', + gap: unitVars.unit.gapPadding['100'], + padding: `0 ${unitVars.unit.gapPadding['100']}`, +}); + +export const placeholderLabel = style({ + ...fontVars.font.body_r_14, + color: colorVars.color.text.secondary, +}); + +export const selectedLabel = style({ + ...fontVars.font.body_m_14, + color: colorVars.color.text.primary, +}); + +export const requiredLabel = style({ + ...fontVars.font.body_m_14, + color: colorVars.color.text.secondary, +}); + +export const divider = style({ + margin: '0.15rem', + borderRadius: '50%', + backgroundColor: colorVars.color.text.secondary, + width: '0.3rem', + height: '0.3rem', +}); diff --git a/src/pages/imageSetup/steps/activityInfo/SelectTrigger.tsx b/src/pages/imageSetup/steps/activityInfo/SelectTrigger.tsx new file mode 100644 index 000000000..a42f69612 --- /dev/null +++ b/src/pages/imageSetup/steps/activityInfo/SelectTrigger.tsx @@ -0,0 +1,48 @@ +import Icon from '@components/v2/icon/Icon'; + +import { getActivityIconName } from './activityIcons'; +import * as styles from './SelectTrigger.css'; + +import type { ActivityItem } from '../../types/apis/activityInfo'; + +interface SelectTriggerProps { + selectedActivity?: ActivityItem; + onClick: () => void; +} + +const SelectTrigger = ({ selectedActivity, onClick }: SelectTriggerProps) => { + const activityIconName = selectedActivity + ? getActivityIconName(selectedActivity.code, 'black') + : null; + const requiredFurnitureLabel = selectedActivity?.furnitures[0]?.label; + + return ( + + ); +}; + +export default SelectTrigger; diff --git a/src/pages/imageSetup/steps/activityInfo/activityIcons.ts b/src/pages/imageSetup/steps/activityInfo/activityIcons.ts new file mode 100644 index 000000000..67eda1771 --- /dev/null +++ b/src/pages/imageSetup/steps/activityInfo/activityIcons.ts @@ -0,0 +1,22 @@ +// 주요활동 code → v2 Icon name 매핑 +// 서버가 아이콘을 내려주지 않으므로, 클라이언트에서 code 기반으로 매핑 +import type { IconName } from '@components/v2/icon/Icon'; + +interface ActivityIconNames { + gray: IconName; + black: IconName; +} + +const ACTIVITY_ICON_MAP: Record = { + REMOTE_WORK: { gray: 'MouseGray', black: 'MouseBlack' }, + READING: { gray: 'BookGray', black: 'BookBlack' }, + FLOOR_LIVING: { gray: 'DeskGray', black: 'DeskBlack' }, + HOME_CAFE: { gray: 'CupGray', black: 'CupBlack' }, +}; + +export const getActivityIconName = ( + activityCode: string, + variant: 'gray' | 'black' = 'black' +): IconName | null => { + return ACTIVITY_ICON_MAP[activityCode]?.[variant] ?? null; +}; diff --git a/src/pages/imageSetup/steps/interiorStyle/InteriorStyle.css.ts b/src/pages/imageSetup/steps/interiorStyle/InteriorStyle.css.ts index 1b787e2dd..9c6fa6a91 100644 --- a/src/pages/imageSetup/steps/interiorStyle/InteriorStyle.css.ts +++ b/src/pages/imageSetup/steps/interiorStyle/InteriorStyle.css.ts @@ -1,6 +1,7 @@ import { style } from '@vanilla-extract/css'; import { zIndex } from '@styles/tokens/zIndex'; +import { unitVars } from '@styles/tokensV2/unit.css'; export const container = style({ position: 'relative', @@ -8,20 +9,24 @@ export const container = style({ flex: 1, flexDirection: 'column', alignItems: 'center', + gap: unitVars.unit.gapPadding['800'], marginBottom: '9.6rem', + padding: unitVars.unit.gapPadding['500'], width: '100%', }); +export const headingWrapper = style({ + display: 'flex', + width: '100%', +}); + +// 데스크탑·모바일 모두에서 44rem 가상 프레임의 우하단에 2rem씩 띄운 floating 버튼 +// - 모바일(뷰포트 ≤ 44rem): right: 2rem — 뷰포트 우측에서 2rem +// - 데스크탑(뷰포트 > 44rem): right: calc((100vw - 44rem) / 2 + 2rem) — 프레임 우측에서 2rem 안쪽 +// - max()로 두 경우를 한 식에 통합 export const buttonWrapper = style({ position: 'fixed', zIndex: zIndex.button, - right: 0, - bottom: '2rem', // CtaButton 최대 너비 설정 - left: 0, - display: 'flex', - justifyContent: 'center', - margin: '0 auto', - padding: '0 2rem 0 2rem', - width: '100%', - maxWidth: '44rem', + right: 'max(2rem, calc((100vw - 44rem) / 2 + 2rem))', + bottom: '2rem', }); diff --git a/src/pages/imageSetup/steps/interiorStyle/InteriorStyle.tsx b/src/pages/imageSetup/steps/interiorStyle/InteriorStyle.tsx index f392167d0..89c0d99b0 100644 --- a/src/pages/imageSetup/steps/interiorStyle/InteriorStyle.tsx +++ b/src/pages/imageSetup/steps/interiorStyle/InteriorStyle.tsx @@ -1,19 +1,15 @@ // Step 3 import { useMoodBoardQuery } from '@pages/imageSetup/apis/queries/useMoodBoardQuery'; -import { FUNNELHEADER_IMAGES } from '@pages/imageSetup/constants/headerImages'; import { useInteriorStyle } from '@pages/imageSetup/hooks/useInteriorStyle'; -import { - logSelectMoodboardClickBtnCTA, - logSelectMoodboardClickBtnCTAInactive, -} from '@pages/imageSetup/utils/analytics'; +import { logSelectMoodboardClickBtnCTA } from '@pages/imageSetup/utils/analytics'; -import CtaButton from '@components/button/ctaButton/CtaButton'; import InlineError from '@components/inlineError/InlineError'; import Loading from '@components/loading/Loading'; +import ActionButton from '@components/v2/button/actionButton/ActionButton'; +import TextHeading from '@components/v2/textHeading/TextHeading'; import * as styles from './InteriorStyle.css'; import MoodBoard from './MoodBoard'; -import FunnelHeader from '../../components/header/FunnelHeader'; import type { CompletedInteriorStyle, @@ -37,26 +33,22 @@ const InteriorStyle = ({ context, onNext }: InteriorStyleProps) => { } = useMoodBoardQuery(); const images = moodBoardData?.moodBoardResponseList || []; - // CTA 버튼 클릭 핸들러 + // CTA 버튼 클릭 핸들러 (현재 native disabled로 비활성 시 클릭 자체가 차단됨) + // TODO: ActionButton에 visuallyDisabled prop이 추가되면(별도 PR) + // logSelectMoodboardClickBtnCTAInactive 로깅을 다시 복원할 것 const handleCtaButtonClick = () => { - if (isDataComplete) { - // 활성 상태 버튼 클릭 - logSelectMoodboardClickBtnCTA(); - handleNext(); - } else { - // 비활성 상태 버튼 클릭 - logSelectMoodboardClickBtnCTAInactive(); - } + logSelectMoodboardClickBtnCTA(); + handleNext(); }; return (
- +
+ +
{isError ? ( { onImageSelect={handleImageSelect} />
- - 주요 활동 선택하기 - + + 다음 +
)} diff --git a/src/pages/imageSetup/steps/interiorStyle/MoodBoard.css.ts b/src/pages/imageSetup/steps/interiorStyle/MoodBoard.css.ts index 21b6595f5..5ddb1a118 100644 --- a/src/pages/imageSetup/steps/interiorStyle/MoodBoard.css.ts +++ b/src/pages/imageSetup/steps/interiorStyle/MoodBoard.css.ts @@ -14,7 +14,6 @@ export const container = style({ display: 'flex', alignItems: 'center', justifyContent: 'center', - padding: '2rem', width: '100%', height: '100%', }); diff --git a/src/pages/imageSetup/steps/interiorStyle/MoodBoard.tsx b/src/pages/imageSetup/steps/interiorStyle/MoodBoard.tsx index e5256fcda..592b7da34 100644 --- a/src/pages/imageSetup/steps/interiorStyle/MoodBoard.tsx +++ b/src/pages/imageSetup/steps/interiorStyle/MoodBoard.tsx @@ -11,9 +11,8 @@ import { type MoodBoardImageItem, } from '@pages/imageSetup/types/apis/interiorStyle'; -import CardImage from '@components/card/cardImage/CardImage'; - import * as styles from './MoodBoard.css'; +import MoodboardCard from './MoodboardCard'; interface MoodBoardProps { images: MoodBoardImageItem[]; @@ -48,7 +47,7 @@ const MoodBoard = ({ !isSelected; return ( - , 'children' | 'type'> { + src: string; + alt: string; + selectOrder?: number; +} + +const MoodboardCard = ({ + src, + alt, + selectOrder = 0, + disabled = false, + className, + ...rest +}: MoodboardCardProps) => { + const isSelected = selectOrder > 0; + + // 카드 컨테이너 스타일 상태 (disabled가 우선) + const cardState = disabled ? 'disabled' : isSelected ? 'selected' : 'default'; + // 체크박스(index 동그라미) 스타일 상태 (선택 여부만 반영) + const checkState = isSelected ? 'selected' : 'default'; + + return ( + + ); +}; + +export default MoodboardCard; diff --git a/src/pages/imageSetup/stores/useFunnelStore.ts b/src/pages/imageSetup/stores/useFunnelStore.ts index 26695734b..e0fb70a5f 100644 --- a/src/pages/imageSetup/stores/useFunnelStore.ts +++ b/src/pages/imageSetup/stores/useFunnelStore.ts @@ -13,16 +13,16 @@ interface FunnelStore { activityInfo: { // ActivityInfo 스텝은 마지막 단계이므로 '다음' 버튼 X -> formData 값 바뀔 때마다 실시간으로 값 변경 반영 필요 // 따라서 undefined도 저장할 수 있도록 optional 프로퍼티로 선언 - activityType?: string; - selectiveIds?: number[]; + activity?: string; + furnitureIds?: number[]; } | null; // 각 스텝의 데이터 저장 setFloorPlan: (data: { floorPlanId: number; isMirror: boolean }) => void; setMoodBoardIds: (ids: number[]) => void; setActivityInfo: (data: { - activityType?: string; - selectiveIds?: number[]; + activity?: string; + furnitureIds?: number[]; }) => void; // 초기화 diff --git a/src/pages/imageSetup/types/apis/activityInfo.ts b/src/pages/imageSetup/types/apis/activityInfo.ts index f4c6129b9..87b43dd4f 100644 --- a/src/pages/imageSetup/types/apis/activityInfo.ts +++ b/src/pages/imageSetup/types/apis/activityInfo.ts @@ -1,15 +1,24 @@ -// Activity Options API 타입 정의 -export interface ActivityOptionItem { - code: string; - label: string; -} +// Activity 스텝 API 타입 정의 +// 가구 단일 항목 export interface FurnitureOptionItem { - id: number; - code: string; - label: string; + id: number; // 7 + code: string; // DESK + label: string; // 업무용 책상 +} + +// GET /api/v2/dashboard/activities — 주요활동 조회 +export interface ActivityItem { + code: string; // REMOTE_WORK + label: string; // 재택근무형 + furnitures: FurnitureOptionItem[]; // 필수 가구 +} + +export interface ActivitiesResponse { + activities: ActivityItem[]; } +// GET /api/v2/dashboard/categories — 가구 카테고리 조회 export interface FurnitureCategory { categoryId: number; nameKr: string; @@ -17,7 +26,6 @@ export interface FurnitureCategory { furnitures: FurnitureOptionItem[]; } -export interface ActivityOptionsResponse { - activities: ActivityOptionItem[]; +export interface FurnitureCategoriesResponse { categories: FurnitureCategory[]; } diff --git a/src/pages/imageSetup/types/funnel/activityInfo.ts b/src/pages/imageSetup/types/funnel/activityInfo.ts index 25ea46fb3..007a87ee4 100644 --- a/src/pages/imageSetup/types/funnel/activityInfo.ts +++ b/src/pages/imageSetup/types/funnel/activityInfo.ts @@ -1,42 +1,18 @@ -// ActivityInfo 도메인 관련 모든 타입 통합 관리 - -// 폼 데이터 타입 (사용자 입력값) +// ActivityInfo 스텝 폼 데이터 타입 (사용자 입력값) export type ActivityInfoFormData = { - activityType?: string; - selectiveIds?: number[]; -}; - -// funnel 스텝 컨텍스트 타입 -export type ActivityInfoContext = { - houseType: string; - roomType: string; - areaType: string; - houseId: number; - floorPlan: { - floorPlanId: number; - isMirror: boolean; - }; - moodBoardIds: number[]; - activityType?: string; - selectiveIds?: number[]; + activity?: string; + furnitureIds?: number[]; }; -// 완성된 ActivityInfo 데이터 타입 -export type CompletedActivityInfo = Required; - -// 에러 타입 -export type ActivityInfoErrors = { - activityType?: string; - selectiveIds?: string; -}; - -// 카테고리별 선택 설정 +// 가구 카테고리별 선택 모드 +// - single: 하나만 선택 가능 (재선택 시 해제) +// - multiple: 여러 개 선택 가능 export type CategorySelectionMode = 'single' | 'multiple'; -export const CATEGORY_SELECTION_CONFIG = { - BED: 'single' as CategorySelectionMode, // 침대 - SOFA: 'single' as CategorySelectionMode, // 소파 - STORAGE: 'multiple' as CategorySelectionMode, // 수납 - TABLE: 'multiple' as CategorySelectionMode, // 테이블 - ETC: 'multiple' as CategorySelectionMode, // 그외 -} as const; +// 가구 카테고리별 선택 모드 매핑 (API 응답의 nameEng를 키로 사용) +export const CATEGORY_SELECTION_MODE: Record = { + BED: 'single', + SOFA: 'multiple', + TABLE: 'multiple', + SELECTIVE: 'multiple', +}; diff --git a/src/pages/imageSetup/types/funnel/steps.ts b/src/pages/imageSetup/types/funnel/steps.ts index fed8cf509..1b961c86b 100644 --- a/src/pages/imageSetup/types/funnel/steps.ts +++ b/src/pages/imageSetup/types/funnel/steps.ts @@ -14,12 +14,12 @@ export type ImageSetupSteps = { floorPlan: FloorPlan; moodBoardIds?: number[]; }; - // 이전 단계 입력값 누적 + activityType, selectiveIds + // 이전 단계 입력값 누적 + activity, furnitureIds ActivityInfo: { floorPlan: FloorPlan; moodBoardIds: number[]; - activityType?: string; - selectiveIds?: number[]; + activity?: string; + furnitureIds?: number[]; }; }; diff --git a/src/pages/imageSetup/utils/staticDataPrefetch.ts b/src/pages/imageSetup/utils/staticDataPrefetch.ts index 37918a74e..1fff69e43 100644 --- a/src/pages/imageSetup/utils/staticDataPrefetch.ts +++ b/src/pages/imageSetup/utils/staticDataPrefetch.ts @@ -1,6 +1,7 @@ import { queryKeys } from '@constants/queryKey'; -import { getActivityOptions } from '../apis/queries/useActivityOptionsQuery'; +import { getActivities } from '../apis/queries/useActivitiesQuery'; +import { getFurnitureCategories } from '../apis/queries/useFurnitureCategoriesQuery'; import { getHousingOptions } from '../apis/queries/useHousingOptionsQuery'; import { getMoodBoardImage } from '../apis/queries/useMoodBoardQuery'; import { MOOD_BOARD_CONSTANTS } from '../types/apis/interiorStyle'; @@ -19,10 +20,18 @@ export const prefetchStaticData = (queryClient: QueryClient) => { gcTime: 1000 * 60 * 60 * 24, // 24시간 가비지 컬렉션 }); - // 활동 옵션 데이터 (주요 활동, 침대 타입, 가구 옵션) + // 주요활동 데이터 (활동 목록 + 활동별 필수 가구) queryClient.prefetchQuery({ - queryKey: queryKeys.imageSetup.activityOptions(), - queryFn: getActivityOptions, + queryKey: queryKeys.imageSetup.activities(), + queryFn: getActivities, + staleTime: Infinity, + gcTime: 1000 * 60 * 60 * 24, + }); + + // 가구 카테고리 데이터 (카테고리 + 카테고리별 가구) + queryClient.prefetchQuery({ + queryKey: queryKeys.imageSetup.furnitureCategories(), + queryFn: getFurnitureCategories, staleTime: Infinity, gcTime: 1000 * 60 * 60 * 24, }); diff --git a/src/shared/assets/v2/svg/BookBlack.svg b/src/shared/assets/v2/svg/BookBlack.svg new file mode 100644 index 000000000..c756e0ba9 --- /dev/null +++ b/src/shared/assets/v2/svg/BookBlack.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/v2/svg/BookGray.svg b/src/shared/assets/v2/svg/BookGray.svg new file mode 100644 index 000000000..c1b5b63b9 --- /dev/null +++ b/src/shared/assets/v2/svg/BookGray.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/v2/svg/ChevronDownFill.svg b/src/shared/assets/v2/svg/ChevronDownFill.svg new file mode 100644 index 000000000..b29d6215e --- /dev/null +++ b/src/shared/assets/v2/svg/ChevronDownFill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/v2/svg/CupBlack.svg b/src/shared/assets/v2/svg/CupBlack.svg new file mode 100644 index 000000000..35ad246fd --- /dev/null +++ b/src/shared/assets/v2/svg/CupBlack.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/v2/svg/CupGray.svg b/src/shared/assets/v2/svg/CupGray.svg new file mode 100644 index 000000000..a9c223a97 --- /dev/null +++ b/src/shared/assets/v2/svg/CupGray.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/v2/svg/DeskBlack.svg b/src/shared/assets/v2/svg/DeskBlack.svg new file mode 100644 index 000000000..a309eba2d --- /dev/null +++ b/src/shared/assets/v2/svg/DeskBlack.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/v2/svg/DeskGray.svg b/src/shared/assets/v2/svg/DeskGray.svg new file mode 100644 index 000000000..5644a7722 --- /dev/null +++ b/src/shared/assets/v2/svg/DeskGray.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/v2/svg/Lock.svg b/src/shared/assets/v2/svg/Lock.svg new file mode 100644 index 000000000..305ddd591 --- /dev/null +++ b/src/shared/assets/v2/svg/Lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/v2/svg/MouseBlack.svg b/src/shared/assets/v2/svg/MouseBlack.svg new file mode 100644 index 000000000..c51e21d86 --- /dev/null +++ b/src/shared/assets/v2/svg/MouseBlack.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/shared/assets/v2/svg/MouseGray.svg b/src/shared/assets/v2/svg/MouseGray.svg new file mode 100644 index 000000000..2da41002b --- /dev/null +++ b/src/shared/assets/v2/svg/MouseGray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/shared/components/card/cardImage/CardImage.css.ts b/src/shared/components/card/cardImage/CardImage.css.ts deleted file mode 100644 index 2fb871923..000000000 --- a/src/shared/components/card/cardImage/CardImage.css.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { keyframes, style } from '@vanilla-extract/css'; -import { recipe } from '@vanilla-extract/recipes'; - -import { fontStyle } from '@styles/fontStyle'; -import { colorVars } from '@styles/tokens/color.css'; -const shimmer = keyframes({ - '0%': { transform: 'translateX(-100%)' }, - '100%': { transform: 'translateX(100%)' }, -}); - -export const cardcontainer = recipe({ - base: { - boxSizing: 'border-box', - position: 'relative', - outline: 'none', - borderRadius: '1.6rem', - cursor: 'pointer', - width: '100%', - minWidth: '16rem', - maxWidth: '19.4rem', - overflow: 'hidden', - selectors: { - '&::after': { - position: 'absolute', - top: 0, - left: 0, - transition: 'background-color 0.3s ease', - borderRadius: '16px', - backgroundColor: 'transparent', - pointerEvents: 'none', - width: '100%', - height: '100%', - content: '', - }, - }, - }, - variants: { - state: { - default: { backgroundColor: colorVars.color.gray100 }, - pressed: { - outline: 'none', - selectors: { - '&::after': { - backgroundColor: 'rgba(0, 0, 0, 0.3)', - }, - }, - }, - selected: { - outline: `1.5px solid ${colorVars.color.primary}`, - outlineOffset: '-1.5px', - selectors: { - '&::after': { - backgroundColor: 'transparent', - }, - }, - }, - disabled: { - outline: 'none', - cursor: 'not-allowed', - selectors: { - '&::after': { - backgroundColor: 'transparent', - }, - }, - }, - }, - }, - defaultVariants: { - state: 'default', - }, -}); - -export const cardimg = style({ - boxSizing: 'border-box', - display: 'block', - objectFit: 'cover', - objectPosition: 'center', - width: '100%', - height: '100%', -}); - -export const disabledcardimg = style([ - cardimg, - { - opacity: 0.15, - }, -]); - -export const checkbox = recipe({ - base: { - position: 'absolute', - zIndex: 1, - top: '1rem', - right: '1rem', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: - 'width 0.2s ease, height 0.2s ease, background-color 0.2s ease, color 0.2s ease', - borderRadius: '9999px', - width: '2rem', - ...fontStyle('body_m_14'), - height: '2rem', - }, - variants: { - state: { - default: { - border: 'none', - backgroundColor: colorVars.color.gray000, - color: 'transparent', - }, - pressed: { - border: 'none', - backgroundColor: colorVars.color.gray000, - color: 'transparent', - }, - selected: { - border: 'none', - backgroundColor: colorVars.color.primary, - width: '2.4rem', - height: '2.4rem', - color: colorVars.color.gray000, - }, - disabled: { - display: 'none', - }, - }, - }, -}); - -export const skeletonCardImg = style({ - position: 'relative', - borderRadius: '12px', - backgroundColor: colorVars.color.gray100, - width: '100%', - height: '100%', - overflow: 'hidden', - - selectors: { - '&::after': { - position: 'absolute', - top: 0, - left: 0, - background: - 'linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent)', - width: '60%', - height: '100%', - animation: `${shimmer} 1s infinite`, - content: '""', - }, - }, -}); diff --git a/src/shared/components/card/cardImage/CardImage.tsx b/src/shared/components/card/cardImage/CardImage.tsx deleted file mode 100644 index 6e48eab96..000000000 --- a/src/shared/components/card/cardImage/CardImage.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from 'react'; - -import * as styles from './CardImage.css'; - -interface CardImageProps extends React.ComponentProps<'div'> { - src: string; - alt: string; - selectOrder?: number; - disabled?: boolean; - onClick?: () => void; -} - -const CardImage = ({ - src, - selectOrder = 0, - disabled = false, - onClick, -}: CardImageProps) => { - const [isPressed, setIsPressed] = useState(false); - - const isSelected = selectOrder > 0; - - // 상태 결정: disabled > pressed > selected > default - const visualState = disabled - ? 'disabled' - : isPressed - ? 'pressed' - : isSelected - ? 'selected' - : 'default'; - - const handleMouseDown = () => { - if (disabled) return; - setIsPressed(true); - }; - - const handleMouseUp = () => { - if (disabled) return; - setIsPressed(false); - }; - - const handleClick = () => { - if (disabled) return; - onClick?.(); // 클릭 시 이미지 선택 함수 호출 - }; - - return ( -
- 카드 이미지 - {visualState !== 'disabled' && ( - - )} -
- ); -}; - -export default CardImage; diff --git a/src/shared/components/card/cardImage/SkeletonCardImage.tsx b/src/shared/components/card/cardImage/SkeletonCardImage.tsx deleted file mode 100644 index 975400dd5..000000000 --- a/src/shared/components/card/cardImage/SkeletonCardImage.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import * as styles from './CardImage.css'; - -const SkeletonCard = () => { - return ( -
-
-
- ); -}; - -export default SkeletonCard; diff --git a/src/shared/components/v2/bottomSheet/DragHandleBottomSheet.tsx b/src/shared/components/v2/bottomSheet/DragHandleBottomSheet.tsx index 90fb11b74..300f84ff0 100644 --- a/src/shared/components/v2/bottomSheet/DragHandleBottomSheet.tsx +++ b/src/shared/components/v2/bottomSheet/DragHandleBottomSheet.tsx @@ -130,7 +130,9 @@ const DragHandleBottomSheet = ({ const panelStyle: React.CSSProperties = isDragging && dragHeight != null ? { height: `${dragHeight}px` } - : { height: expanded ? EXPANDED_HEIGHT : (collapsedHeight ?? '0') }; + : isPersistent + ? { height: expanded ? EXPANDED_HEIGHT : collapsedHeight } + : {}; // Dismissible: content 높이(auto) + CSS maxHeight가 상한 const handleOverlayClick = () => { if (isPersistent) { diff --git a/src/shared/components/v2/chip/Chip.css.ts b/src/shared/components/v2/chip/Chip.css.ts index 6f1d396eb..c43118a9a 100644 --- a/src/shared/components/v2/chip/Chip.css.ts +++ b/src/shared/components/v2/chip/Chip.css.ts @@ -12,8 +12,8 @@ export const chip = recipe({ justifyContent: 'center', gap: unitVars.unit.gapPadding['050'], transition: - 'transform 100ms ease, background-color 100ms ease, color 100ms ease', - borderWidth: '0.1rem', + 'transform 120ms ease, background-color 120ms ease, color 120ms ease, border-color 120ms ease', + borderWidth: '1px', borderStyle: 'solid', borderRadius: unitVars.unit.gapPadding.full, background: 'transparent', @@ -37,7 +37,32 @@ export const chip = recipe({ color: colorVars.color.text.inverse, }, }, + color: { + strong: {}, + weak: {}, + }, + disabled: { + true: { + borderColor: colorVars.color.border.tertiary, + cursor: 'default', + selectors: { + '&:active': { + transform: 'none', + }, + }, + }, + }, }, + compoundVariants: [ + { + variants: { color: 'weak', selected: true }, + style: { + borderColor: 'transparent', + backgroundColor: colorVars.color.fill.weak, + color: colorVars.color.text.primary, + }, + }, + ], }); export const label = recipe({ @@ -59,6 +84,15 @@ export const label = recipe({ ...fontVars.font.body_m_13, }, }, + disabled: { + true: { + color: colorVars.color.text.disabled, + }, + }, + color: { + strong: {}, + weak: {}, + }, hasSuffix: { false: { paddingRight: unitVars.unit.gapPadding['300'], @@ -68,6 +102,14 @@ export const label = recipe({ }, }, }, + compoundVariants: [ + { + variants: { color: 'weak', selected: true }, + style: { + color: colorVars.color.text.primary, + }, + }, + ], }); export const suffix = style({ diff --git a/src/shared/components/v2/chip/Chip.tsx b/src/shared/components/v2/chip/Chip.tsx index fe23b5d5f..5bfedec77 100644 --- a/src/shared/components/v2/chip/Chip.tsx +++ b/src/shared/components/v2/chip/Chip.tsx @@ -2,9 +2,13 @@ import type { ReactNode } from 'react'; import * as styles from './Chip.css'; -interface ChipProps extends Omit, 'children'> { +export type ChipColor = 'strong' | 'weak'; + +interface ChipProps + extends Omit, 'children' | 'color'> { children: ReactNode; selected?: boolean; + color?: ChipColor; suffixIcon?: ReactNode; suffixAriaLabel?: string; onSuffixClick?: () => void; @@ -13,26 +17,39 @@ interface ChipProps extends Omit, 'children'> { const Chip = ({ children, selected = false, + color = 'strong', suffixIcon, suffixAriaLabel, onSuffixClick, type = 'button', className, + disabled, onClick, ...props }: ChipProps) => { + const isDisabled = disabled === true; const hasSuffix = suffixIcon !== undefined; - const chipClassName = `${styles.chip({ selected })}${className ? ` ${className}` : ''}`; + const chipClassName = `${styles.chip({ selected, color, disabled: isDisabled })}${className ? ` ${className}` : ''}`; return (