diff --git a/src/pages/home/components/explore/RoomTypeSection/RoomTypeSection.css.ts b/src/pages/home/components/explore/RoomTypeSection/RoomTypeSection.css.ts index e951c497..7017ee94 100644 --- a/src/pages/home/components/explore/RoomTypeSection/RoomTypeSection.css.ts +++ b/src/pages/home/components/explore/RoomTypeSection/RoomTypeSection.css.ts @@ -43,3 +43,8 @@ export const cardList = style({ padding: `0 ${unitVars.unit.gapPadding['500']}`, width: 'max-content', }); + +export const cardItem = style({ + flexShrink: 0, + width: '16rem', +}); diff --git a/src/pages/home/components/explore/RoomTypeSection/RoomTypeSection.tsx b/src/pages/home/components/explore/RoomTypeSection/RoomTypeSection.tsx index 962abe18..d0541eed 100644 --- a/src/pages/home/components/explore/RoomTypeSection/RoomTypeSection.tsx +++ b/src/pages/home/components/explore/RoomTypeSection/RoomTypeSection.tsx @@ -50,16 +50,21 @@ const RoomTypeSection = () => {
{ROOM_TYPE_MOCK.map((room) => ( - {}} - /> + // cardList가 가로 스크롤 가능한 컴포넌트(width: max-content + flex-wrap:nowrap) + RoomTypeCard는 반응형 대응 가능(width: 100%) + // => cardItem으로 명시적으로 너비를 설정해야 RoomTypeCard의 너비가 무한히 커지지 않음 +
+ {}} + /> +
))} - {}} /> +
+ {}} /> +
diff --git a/src/pages/imageSetup/stores/useFunnelStore.ts b/src/pages/imageSetup/stores/useFunnelStore.ts index d664f2a9..540afdc3 100644 --- a/src/pages/imageSetup/stores/useFunnelStore.ts +++ b/src/pages/imageSetup/stores/useFunnelStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import type { CompletedHouseInfo } from '../types/funnel/houseInfo'; +// TODO: FunnelStore 수정 +// moodBoard, activityInfo 스텝은 이미지 생성에 필요할 수도 있고 아닐 수도 있음 interface FunnelStore { // 각 스텝 데이터(각 스텝 별 요구되는 데이터만 저장) houseInfo: CompletedHouseInfo | null; diff --git a/src/pages/imageSetup/v2/FloorPlanSelectTest.tsx b/src/pages/imageSetup/v2/FloorPlanSelectTest.tsx new file mode 100644 index 00000000..9d78117a --- /dev/null +++ b/src/pages/imageSetup/v2/FloorPlanSelectTest.tsx @@ -0,0 +1,37 @@ +/** + * FloorPlanSelectStep 독립 테스트 페이지 + * 퍼널 없이 도면 선택 UI + 로직을 검증하기 위한 임시 페이지 + * + * 사용법: /test/floor-plan 접속 + * + * 검증 항목: + * - 그리드 렌더 + 카드 클릭 → FloorPlanSheet 오픈 + * - 필터 칩 → FilterSheet → 적용/초기화 + * - 다중뷰 prev/next 전환 + 뷰 이름/평형 표시 + * - 좌우반전 토글 + * - 최근 생성 시트 (DUMMY_RECENT_FLOOR_PLAN에 값 넣어서 확인) + * - "공간 선택하기" → console.log 출력 + * + * TODO: 검증 완료 후 이 파일 + router.tsx 테스트 경로 삭제 + */ + +import FloorPlanSelectStep from './steps/floorPlanSelect/FloorPlanSelectStep'; + +import type { CompletedFloorPlan } from '../types/funnel/steps'; + +const MOCK_CONTEXT = { + houseType: 'OFFICETEL', + roomType: 'ONE_ROOM', + areaType: 'TENS', + houseId: 1, +}; + +const handleNext = (data: CompletedFloorPlan) => { + console.log('[FloorPlanSelectTest] onNext 호출됨:', data); +}; + +const FloorPlanSelectTest = () => { + return ; +}; + +export default FloorPlanSelectTest; diff --git a/src/pages/imageSetup/v2/constants/floorPlanDummy.ts b/src/pages/imageSetup/v2/constants/floorPlanDummy.ts new file mode 100644 index 00000000..c67f621e --- /dev/null +++ b/src/pages/imageSetup/v2/constants/floorPlanDummy.ts @@ -0,0 +1,241 @@ +import type { + FilterCategory, + FloorPlanData, + FloorPlanDetailView, + RecentFloorPlanData, +} from '../types/floorPlan'; + +export const DUMMY_FILTER_CATEGORIES: FilterCategory[] = [ + { + id: 'residenceType', + label: '주거 형태', + options: [ + { id: 'ALL', label: '전체' }, + { id: 'OFFICETEL', label: '오피스텔' }, + { id: 'VILLA', label: '빌라/다세대' }, + { id: 'APARTMENT', label: '아파트' }, + { id: 'ETC', label: '그 외' }, + ], + }, + { + id: 'layoutType', + label: '구조', + options: [ + { id: 'ALL', label: '전체' }, + { id: 'OPEN_ONE_ROOM', label: '오픈형 원룸' }, + { id: 'SEPARATE_ONE_ROOM', label: '분리형 원룸' }, + { id: 'DUPLEX', label: '복층형' }, + { id: 'TWO_ROOM', label: '투룸' }, + { id: 'THREE_ROOM_PLUS', label: '쓰리룸 이상' }, + { id: 'ETC', label: '그 외' }, + ], + }, + { + id: 'areaSize', + label: '평형', + options: [ + { id: 'ALL', label: '전체' }, + { id: 'UNDER_4', label: '4평 이하' }, + { id: 'FROM_5_TO_10', label: '5-10평' }, + { id: 'TENS', label: '10평대' }, + { id: 'TWENTIES', label: '20평대' }, + { id: 'OVER_30', label: '30평 이상' }, + ], + }, +]; + +export const DUMMY_FLOOR_PLANS: FloorPlanData[] = [ + { + id: 1, + name: '일자형 원룸', + imageUrl: 'https://placehold.co/164x164?text=1', + isLatest: true, + residenceType: 'OFFICETEL', + layoutType: 'OPEN_ONE_ROOM', + areaSize: 'FROM_5_TO_10', + }, + { + id: 2, + name: '분리형 원룸', + imageUrl: 'https://placehold.co/164x164?text=2', + isLatest: false, + residenceType: 'VILLA', + layoutType: 'SEPARATE_ONE_ROOM', + areaSize: 'TENS', + }, + { + id: 3, + name: 'ㄱ자형 투룸', + imageUrl: 'https://placehold.co/164x164?text=3', + isLatest: false, + residenceType: 'APARTMENT', + layoutType: 'TWO_ROOM', + areaSize: 'TENS', + }, + { + id: 4, + name: '복층형 쓰리룸', + imageUrl: 'https://placehold.co/164x164?text=4', + isLatest: false, + residenceType: 'OFFICETEL', + layoutType: 'THREE_ROOM_PLUS', + areaSize: 'TWENTIES', + }, + { + id: 5, + name: '오픈형 원룸', + imageUrl: 'https://placehold.co/164x164?text=5', + isLatest: false, + residenceType: 'OFFICETEL', + layoutType: 'OPEN_ONE_ROOM', + areaSize: 'FROM_5_TO_10', + }, + { + id: 6, + name: '소형 원룸', + imageUrl: 'https://placehold.co/164x164?text=6', + isLatest: false, + residenceType: 'ETC', + layoutType: 'OPEN_ONE_ROOM', + areaSize: 'UNDER_4', + }, + { + id: 7, + name: '복층형 투룸', + imageUrl: 'https://placehold.co/164x164?text=7', + isLatest: false, + residenceType: 'VILLA', + layoutType: 'DUPLEX', + areaSize: 'TENS', + }, + { + id: 8, + name: '대형 아파트', + imageUrl: 'https://placehold.co/164x164?text=8', + isLatest: false, + residenceType: 'APARTMENT', + layoutType: 'THREE_ROOM_PLUS', + areaSize: 'OVER_30', + }, +]; + +// 도면 상세 뷰 더미 (카드 클릭 시 반환, 추후 API 교체) +// key: floorPlanId → value: FloorPlanDetailView[] +export const DUMMY_FLOOR_PLAN_DETAILS: Record = { + 1: [ + { + id: 1, + name: '일자형 원룸', + imageUrl: 'https://placehold.co/343x343?text=1-A', + equilibrium: '5-10평', + view: '정면 뷰', + }, + { + id: 1, + name: '일자형 원룸', + imageUrl: 'https://placehold.co/343x343?text=1-B', + equilibrium: '5-10평', + view: '창가 뷰', + }, + ], + 2: [ + { + id: 2, + name: '분리형 원룸', + imageUrl: 'https://placehold.co/343x343?text=2-A', + equilibrium: '10평대', + view: '정면 뷰', + }, + ], + 3: [ + { + id: 3, + name: 'ㄱ자형 투룸', + imageUrl: 'https://placehold.co/343x343?text=3-A', + equilibrium: '10평대', + view: '정면 뷰', + }, + { + id: 3, + name: 'ㄱ자형 투룸', + imageUrl: 'https://placehold.co/343x343?text=3-B', + equilibrium: '10평대', + view: '창가 뷰', + }, + { + id: 3, + name: 'ㄱ자형 투룸', + imageUrl: 'https://placehold.co/343x343?text=3-C', + equilibrium: '10평대', + view: '주방 뷰', + }, + ], + 4: [ + { + id: 4, + name: '복층형 쓰리룸', + imageUrl: 'https://placehold.co/343x343?text=4-A', + equilibrium: '20평대', + view: '정면 뷰', + }, + ], + 5: [ + { + id: 5, + name: '오픈형 원룸', + imageUrl: 'https://placehold.co/343x343?text=5-A', + equilibrium: '5-10평', + view: '정면 뷰', + }, + { + id: 5, + name: '오픈형 원룸', + imageUrl: 'https://placehold.co/343x343?text=5-B', + equilibrium: '5-10평', + view: '창가 뷰', + }, + ], + 6: [ + { + id: 6, + name: '소형 원룸', + imageUrl: 'https://placehold.co/343x343?text=6-A', + equilibrium: '4평 이하', + view: '정면 뷰', + }, + ], + 7: [ + { + id: 7, + name: '복층형 투룸', + imageUrl: 'https://placehold.co/343x343?text=7-A', + equilibrium: '10평대', + view: '정면 뷰', + }, + ], + 8: [ + { + id: 8, + name: '대형 아파트', + imageUrl: 'https://placehold.co/343x343?text=8-A', + equilibrium: '30평 이상', + view: '정면 뷰', + }, + { + id: 8, + name: '대형 아파트', + imageUrl: 'https://placehold.co/343x343?text=8-B', + equilibrium: '30평 이상', + view: '창가 뷰', + }, + ], +}; + +// 최근 생성 도면 (별도 API 응답) +export const DUMMY_RECENT_FLOOR_PLAN: RecentFloorPlanData | null = { + id: 1, + name: '일자형 원룸', + imageUrl: 'https://placehold.co/343x343?text=Recent', + equilibrium: '5-10평', + view: '정면 뷰', +}; diff --git a/src/pages/imageSetup/v2/hooks/useFloorPlanSelect.ts b/src/pages/imageSetup/v2/hooks/useFloorPlanSelect.ts new file mode 100644 index 00000000..e419176c --- /dev/null +++ b/src/pages/imageSetup/v2/hooks/useFloorPlanSelect.ts @@ -0,0 +1,153 @@ +import { useEffect } from 'react'; + +import { useToast } from '@components/toast/useToast'; + +import { useFunnelStore } from '../../stores/useFunnelStore'; +import { + DUMMY_FILTER_CATEGORIES, + DUMMY_FLOOR_PLAN_DETAILS, + DUMMY_FLOOR_PLANS, + DUMMY_RECENT_FLOOR_PLAN, +} from '../constants/floorPlanDummy'; +import { useFloorPlanStore } from '../stores/useFloorPlanStore'; + +import type { + CompletedFloorPlan, + ImageSetupSteps, +} from '../../types/funnel/steps'; +import type { FloorPlanFilters, RecentFloorPlanData } from '../types/floorPlan'; + +export const useFloorPlanSelect = ( + context: ImageSetupSteps['FloorPlan'], + onNext: (data: CompletedFloorPlan) => void +) => { + const store = useFloorPlanStore(); + const savedHouseInfo = useFunnelStore((state) => state.houseInfo); + const { notify } = useToast(); + + // 더미 데이터 (추후 useFloorPlanQuery / useRecentFloorPlanQuery로 교체) + const filterCategories = DUMMY_FILTER_CATEGORIES; + const allFloorPlans = DUMMY_FLOOR_PLANS; + // 최근 생성 도면 (별도 API 응답) + const recentFloorPlan: RecentFloorPlanData | null = DUMMY_RECENT_FLOOR_PLAN; + + // 최근 생성 공간이 있으면 초기 시트 표시 + 토스트 알림 + useEffect(() => { + if (recentFloorPlan) { + store.openRecentSheet(); + // TODO: 토스트 높이 수정하기 + notify({ text: '저장된 내 공간을 불러왔어요.' }); + } + // 마운트 시 1회만 실행 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // TODO: API 연동 후에는 불필요한 함수 + const matchesFilter = (selected: string[], value: string): boolean => + selected.length === 0 || selected.includes(value); + + // TODO: API 연동 후 서버 필터링으로 전환 (API 연동 후에는 불필요한 함수) + const filteredFloorPlans = allFloorPlans.filter((plan) => { + const f: FloorPlanFilters = store.appliedFilters; + return ( + matchesFilter(f.residenceType, plan.residenceType) && + matchesFilter(f.layoutType, plan.layoutType) && + matchesFilter(f.areaSize, plan.areaSize) + ); + }); + + // TODO: API 연동 시 도면 상세 조회 API(GET /explore/house-templates/{id})로 교체 + // selectedFloorPlan, selectedDetailViews 모두 하나의 API 응답에서 추출 + const selectedFloorPlan = + allFloorPlans.find((p) => p.id === store.selectedFloorPlanId) ?? null; + + const selectedDetailViews = store.selectedFloorPlanId + ? (DUMMY_FLOOR_PLAN_DETAILS[store.selectedFloorPlanId] ?? []) + : []; + + /** + * handleConfirmFloorPlan / handleConfirmRecentFloorPlan에서 + * payload 생성 + funnelStore 저장 로직이 동일하므로 헬퍼로 추출 + * savedHouseInfo가 있으면 우선 사용하고, 없으면 context(퍼널 진입 시 전달받은 값)로 폴백. + * TODO: 전체 플로우 skeleton 설계 시 퍼널 관련 로직 점검 필요 + */ + const confirmFloorPlan = (floorPlanData: CompletedFloorPlan['floorPlan']) => { + useFunnelStore.getState().setFloorPlan(floorPlanData); + + onNext({ + houseType: savedHouseInfo?.houseType ?? context.houseType, + roomType: savedHouseInfo?.roomType ?? context.roomType, + areaType: savedHouseInfo?.areaType ?? context.areaType, + houseId: savedHouseInfo?.houseId ?? context.houseId, + floorPlan: floorPlanData, + }); + }; + + const handleCardClick = (floorPlanId: number) => { + store.selectFloorPlan(floorPlanId); + store.openFloorPlanSheet(); + }; + + // 도면 선택 후 "공간 선택하기" CTA + const handleConfirmFloorPlan = () => { + if (!selectedFloorPlan) return; + confirmFloorPlan({ + floorPlanId: selectedFloorPlan.id, + isMirror: store.isMirror, + }); + }; + + // 최근 생성 공간 바텀시트 "선택 완료" CTA + const handleConfirmRecentFloorPlan = () => { + if (!recentFloorPlan) return; + store.closeRecentSheet(); + confirmFloorPlan({ + floorPlanId: recentFloorPlan.id, + isMirror: store.isMirror, + }); + }; + + // 컴포넌트별로 필요한 상태/액션을 묶어서 반환 + return { + filterCategories, + filteredFloorPlans, + selectedFloorPlan, + selectedDetailViews, + recentFloorPlan, + handleCardClick, + handleConfirmFloorPlan, + handleConfirmRecentFloorPlan, + + // 필터 그리드에서 사용하는 상태/액션 + grid: { + appliedFilters: store.appliedFilters, + onFilterChipClick: store.openFilterSheet, + onFilterChipClear: store.clearAppliedFilter, + }, + + // FilterSheet props + filterSheet: { + open: store.isFilterSheetOpen, + onClose: store.closeFilterSheet, + pendingFilters: store.pendingFilters, + onFilterChange: store.setPendingFilter, + onApply: () => { + store.applyFilters(); + store.closeFilterSheet(); + }, + onReset: store.resetFilters, + }, + + // FloorPlanSheet (도면 상세) props + floorPlanSheet: { + open: store.isFloorPlanSheetOpen, + onClose: store.closeFloorPlanSheet, + }, + + // FloorPlanSheet (최근 공간) props + recentSheet: { + open: store.isRecentSheetOpen, + onClose: store.closeRecentSheet, + }, + }; +}; diff --git a/src/pages/imageSetup/v2/hooks/useFloorPlanSheet.ts b/src/pages/imageSetup/v2/hooks/useFloorPlanSheet.ts new file mode 100644 index 00000000..fabe95e2 --- /dev/null +++ b/src/pages/imageSetup/v2/hooks/useFloorPlanSheet.ts @@ -0,0 +1,29 @@ +// 도면 바텀시트 전용 로직 +// 도면 상세 바텀시트 안에서의 뷰 전환 + 좌우반전 로직 담당 + +import { useMemo } from 'react'; + +import { useFloorPlanStore } from '../stores/useFloorPlanStore'; + +import type { FloorPlanDetailView } from '../types/floorPlan'; + +export const useFloorPlanSheet = (detailViews: FloorPlanDetailView[]) => { + const { selectedViewIndex, setViewIndex, isMirror, toggleMirror } = + useFloorPlanStore(); + + const isMultiView = detailViews.length > 1; + + const currentView = useMemo( + () => detailViews[selectedViewIndex] ?? detailViews[0] ?? null, + [detailViews, selectedViewIndex] + ); + + return { + currentView, // 현재 보고 있는 뷰 (FloorPlanDetailView) + selectedViewIndex, // 현재 뷰 인덱스 + setViewIndex, // 뷰 인덱스 직접 설정 (Swiper 연동) + isMultiView, // 뷰 2개 이상 → prev/next 버튼 표시 + isMirror, // 좌우반전 상태 + toggleMirror, // 좌우반전 토글 + }; +}; diff --git a/src/pages/imageSetup/v2/steps/floorPlanSelect/FilterSheet.css.ts b/src/pages/imageSetup/v2/steps/floorPlanSelect/FilterSheet.css.ts new file mode 100644 index 00000000..36bce209 --- /dev/null +++ b/src/pages/imageSetup/v2/steps/floorPlanSelect/FilterSheet.css.ts @@ -0,0 +1,36 @@ +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 content = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['600'], + paddingBottom: unitVars.unit.gapPadding['300'], +}); + +export const title = style({ + margin: 0, + color: colorVars.color.text.primary, + ...fontVars.font.title_sb_16, +}); + +export const section = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['300'], +}); + +export const sectionTitle = style({ + margin: 0, + color: colorVars.color.text.primary, + ...fontVars.font.title_m_16, +}); + +export const chipGroup = style({ + display: 'flex', + flexWrap: 'wrap', + gap: unitVars.unit.gapPadding['200'], +}); diff --git a/src/pages/imageSetup/v2/steps/floorPlanSelect/FilterSheet.tsx b/src/pages/imageSetup/v2/steps/floorPlanSelect/FilterSheet.tsx new file mode 100644 index 00000000..9f67f5bb --- /dev/null +++ b/src/pages/imageSetup/v2/steps/floorPlanSelect/FilterSheet.tsx @@ -0,0 +1,83 @@ +import CloseBottomSheet from '@components/v2/bottomSheet/CloseBottomSheet'; +import ActionButton from '@components/v2/button/actionButton/ActionButton'; +import Chip from '@components/v2/chip/Chip'; + +import * as styles from './FilterSheet.css'; + +import type { FilterCategory, FloorPlanFilters } from '../../types/floorPlan'; + +interface FilterSheetProps { + open: boolean; + onClose: () => void; + filterCategories: FilterCategory[]; + pendingFilters: FloorPlanFilters; + onFilterChange: (key: keyof FloorPlanFilters, value: string) => void; + onApply: () => void; + onReset: () => void; +} + +const FilterSheet = ({ + open, + onClose, + filterCategories, + pendingFilters, + onFilterChange, + onApply, + onReset, +}: FilterSheetProps) => { + return ( + 필터

} + titleAlign="left" + contentSlot={ + // content: 버튼 제외 필터칩 영역 +
+ {filterCategories.map((category) => { + const currentValues = pendingFilters[category.id]; + + return ( +
+

{category.label}

+
+ {category.options.map((option) => ( + onFilterChange(category.id, option.id)} + > + {option.label} + + ))} +
+
+ ); + })} +
+ } + primaryButton={ + + 필터 적용하기 + + } + secondaryButton={ + + 초기화 + + } + /> + ); +}; + +export default FilterSheet; diff --git a/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectGrid.css.ts b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectGrid.css.ts new file mode 100644 index 00000000..b520ed75 --- /dev/null +++ b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectGrid.css.ts @@ -0,0 +1,98 @@ +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 container = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + minHeight: 0, + overflow: 'hidden', +}); + +export const chipBar = style({ + display: 'flex', + flexShrink: 0, + gap: unitVars.unit.gapPadding['200'], + padding: `${unitVars.unit.gapPadding['200']} ${unitVars.unit.gapPadding['500']}`, + overflowX: 'auto', + overflowY: 'hidden', + scrollbarWidth: 'none', + whiteSpace: 'nowrap', + msOverflowStyle: 'none', + selectors: { + '&::-webkit-scrollbar': { + display: 'none', + }, + }, +}); + +export const gridScroll = style({ + flex: 1, + padding: `${unitVars.unit.gapPadding['300']} ${unitVars.unit.gapPadding['500']}`, + overflow: 'auto', +}); + +export const divider = style({ + margin: `${unitVars.unit.gapPadding['800']} calc(${unitVars.unit.gapPadding['500']} * -1)`, + backgroundColor: colorVars.color.border.secondary, + width: `calc(100% + ${unitVars.unit.gapPadding['500']} * 2)`, + height: '0.8rem', +}); + +export const grid = style({ + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + justifyItems: 'center', + gap: unitVars.unit.gapPadding['200'], +}); + +export const chipIcon = style({ + flexShrink: 0, + width: '1.2rem', + height: '1.2rem', +}); + +// --- filtered-none 빈 결과 상태 (isExact: false) --- + +export const emptyContainer = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: unitVars.unit.gapPadding['200'], + padding: `${unitVars.unit.gapPadding['200']} ${unitVars.unit.gapPadding['100']}`, +}); + +export const emptyTitle = style({ + margin: 0, + textAlign: 'center', + color: colorVars.color.text.secondary, + ...fontVars.font.title_m_16, +}); + +export const emptyDescription = style({ + margin: 0, + textAlign: 'center', + whiteSpace: 'pre-line', + color: colorVars.color.text.tertiary, + ...fontVars.font.body_r_13, +}); + +// --- "선택한 필터와 유사한 공간" 섹션 --- + +export const similarSection = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['300'], + padding: `${unitVars.unit.gapPadding['400']} ${unitVars.unit.gapPadding['400']} ${unitVars.unit.gapPadding['600']}`, +}); + +export const similarTitle = style({ + margin: 0, + padding: `0 ${unitVars.unit.gapPadding['100']}`, + ...fontVars.font.title_sb_16, + color: colorVars.color.text.primary, +}); diff --git a/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectGrid.tsx b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectGrid.tsx new file mode 100644 index 00000000..9b6e5899 --- /dev/null +++ b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectGrid.tsx @@ -0,0 +1,128 @@ +import emptyImage from '@assets/v2/images/ImgEmpty.png'; + +import Chip from '@components/v2/chip/Chip'; +import Icon from '@components/v2/icon/Icon'; +import RoomTypeCard from '@components/v2/roomTypeCard/RoomTypeCard'; + +import * as styles from './FloorPlanSelectGrid.css'; + +import type { + FilterCategory, + FloorPlanData, + FloorPlanFilters, +} from '../../types/floorPlan'; + +interface FloorPlanSelectGridProps { + filterCategories: FilterCategory[]; + floorPlans: FloorPlanData[]; + appliedFilters: FloorPlanFilters; + onCardClick: (floorPlanId: number) => void; + onFilterChipClick: () => void; + onFilterChipClear: (key: keyof FloorPlanFilters) => void; +} + +const getChipLabel = (category: FilterCategory, filterValues: string[]) => { + if (filterValues.length === 0) return category.label; + + const selectedOptions = filterValues + .map((filterValue) => category.options.find((o) => o.id === filterValue)) + .filter( + (option): option is NonNullable => option !== undefined + ); + + if (selectedOptions.length === 0) return category.label; + if (selectedOptions.length === 1) return selectedOptions[0].label; + + return `${selectedOptions[0].label} 외 ${selectedOptions.length - 1}개`; +}; + +const FloorPlanSelectGrid = ({ + filterCategories, + floorPlans, + appliedFilters, + onCardClick, + onFilterChipClick, + onFilterChipClear, +}: FloorPlanSelectGridProps) => { + return ( +
+ {/* 필터칩 영역 */} +
+ {filterCategories.map((category) => { + const filterValues = appliedFilters[category.id]; + const isFiltered = filterValues.length > 0; + + return ( + + ) : ( + + ) + } + suffixAriaLabel={ + isFiltered ? `${category.label} 필터 초기화` : undefined + } + onClick={onFilterChipClick} + onSuffixClick={ + isFiltered ? () => onFilterChipClear(category.id) : undefined + } + > + {getChipLabel(category, filterValues)} + + ); + })} +
+ + {/* 카드 그리드 영역 */} +
+ {floorPlans.length === 0 ? ( + <> + {/* 상단 이미지 + 공간없음 텍스트 */} + 필터 결과 없음 +
+

+ 선택한 필터에 맞는 공간이 없어요. +

+

+ { + '하우미는 순차적으로 공간 유형을 확장하고 있어요.\n비슷한 공간을 선택해 이미지를 생성해보세요!' + } +

+
+ + {/* Divider */} +
+ + {/* TODO: API isExact: false일 때 유사 공간 카드 렌더 */} +
+

선택한 필터와 유사한 공간

+
+ {/* 유사 공간 카드 — API 연동 시 데이터 바인딩 */} +
+
+ + ) : ( +
+ {floorPlans.map((plan) => ( + onCardClick(plan.id)} + /> + ))} +
+ )} +
+
+ ); +}; + +export default FloorPlanSelectGrid; diff --git a/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectStep.css.ts b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectStep.css.ts new file mode 100644 index 00000000..eb41efa8 --- /dev/null +++ b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectStep.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', +}); diff --git a/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectStep.tsx b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectStep.tsx new file mode 100644 index 00000000..eccc106d --- /dev/null +++ b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSelectStep.tsx @@ -0,0 +1,83 @@ +import PageLayout from '@components/pageLayout/PageLayout'; + +import FilterSheet from './FilterSheet'; +import FloorPlanSelectGrid from './FloorPlanSelectGrid'; +import FloorPlanSheet from './FloorPlanSheet'; +import { useFloorPlanSelect } from '../../hooks/useFloorPlanSelect'; + +import type { + CompletedFloorPlan, + ImageSetupSteps, +} from '../../../types/funnel/steps'; + +interface FloorPlanSelectStepProps { + context: ImageSetupSteps['FloorPlan']; + onNext: (data: CompletedFloorPlan) => void; +} + +const FloorPlanSelectStep = ({ context, onNext }: FloorPlanSelectStepProps) => { + const { + filterCategories, + filteredFloorPlans, + selectedFloorPlan, + selectedDetailViews, + recentFloorPlan, + handleCardClick, + handleConfirmFloorPlan, + handleConfirmRecentFloorPlan, + grid, + filterSheet, + floorPlanSheet, + recentSheet, + } = useFloorPlanSelect(context, onNext); + + return ( + + + + + + {/* TODO: overlay-kit에 상태관리 위임, use-funnel에 등록 */} + + + {recentFloorPlan && ( + + )} + + ); +}; + +export default FloorPlanSelectStep; diff --git a/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSheet.css.ts b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSheet.css.ts new file mode 100644 index 00000000..a2de90f1 --- /dev/null +++ b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSheet.css.ts @@ -0,0 +1,85 @@ +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 content = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: unitVars.unit.gapPadding['300'], + paddingBottom: unitVars.unit.gapPadding['300'], +}); + +export const mirrorWrapper = style({ + transition: 'transform 200ms ease', + width: '100%', +}); + +export const mirrored = style({ + transform: 'scaleX(-1)', +}); + +export const viewLabel = style({ + margin: 0, + color: colorVars.color.text.tertiary, + ...fontVars.font.body_r_14, +}); + +export const titleRow = style({ + display: 'inline-flex', + alignItems: 'center', + gap: unitVars.unit.gapPadding['100'], +}); + +export const titleIcon = style({ + flexShrink: 0, + width: '1.6rem', + height: '1.6rem', +}); + +export const titleMain = style({ + whiteSpace: 'nowrap', + color: colorVars.color.text.primary, + ...fontVars.font.title_m_16, +}); + +export const titleMeta = style({ + whiteSpace: 'nowrap', + color: colorVars.color.text.tertiary, + ...fontVars.font.title_r_15, +}); + +export const swiperContainer = style({ + aspectRatio: '1 / 1', + position: 'relative', + borderRadius: unitVars.unit.radius['600'], + width: '100%', + overflow: 'hidden', +}); + +export const slideImage = style({ + objectFit: 'cover', + width: '100%', + height: '100%', +}); + +const navButtonBase = style({ + position: 'absolute', + zIndex: 1, + top: 0, + bottom: 0, + display: 'flex', + alignItems: 'center', +}); + +export const navButtonPrev = style([ + navButtonBase, + { left: unitVars.unit.gapPadding['200'] }, +]); + +export const navButtonNext = style([ + navButtonBase, + { right: unitVars.unit.gapPadding['200'] }, +]); diff --git a/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSheet.tsx b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSheet.tsx new file mode 100644 index 00000000..be6bd3d6 --- /dev/null +++ b/src/pages/imageSetup/v2/steps/floorPlanSelect/FloorPlanSheet.tsx @@ -0,0 +1,135 @@ +import { useRef } from 'react'; + +import clsx from 'clsx'; +import { Navigation } from 'swiper/modules'; +import { Swiper, SwiperSlide } from 'swiper/react'; + +import 'swiper/css'; + +import CloseBottomSheet from '@components/v2/bottomSheet/CloseBottomSheet'; +import ActionButton from '@components/v2/button/actionButton/ActionButton'; +import IconButton from '@components/v2/button/IconButton'; +import Icon from '@components/v2/icon/Icon'; + +import * as styles from './FloorPlanSheet.css'; +import { useFloorPlanSheet } from '../../hooks/useFloorPlanSheet'; + +import type { FloorPlanDetailView } from '../../types/floorPlan'; +import type { Swiper as SwiperType } from 'swiper'; + +interface FloorPlanSheetProps { + open: boolean; + onClose: () => void; + floorPlanName: string; + detailViews: FloorPlanDetailView[]; + onConfirm: () => void; +} + +const FloorPlanSheet = ({ + open, + onClose, + floorPlanName, + detailViews, + onConfirm, +}: FloorPlanSheetProps) => { + const swiperRef = useRef(null); + const { + currentView, + isMultiView, + isMirror, + toggleMirror, + selectedViewIndex, + setViewIndex, + } = useFloorPlanSheet(detailViews); + + if (!currentView) return null; + + return ( + + + {floorPlanName} + · + {currentView.equilibrium} + + } + contentSlot={ +
+
+
+ { + swiperRef.current = swiper; + }} + // swiper.realIndex로 Swiper가 index를 알려주면 + // setViewIndex로 store 업데이트 -> currentView가 바뀌어 도면 뷰 라벨 갱신 + onSlideChange={(swiper) => { + setViewIndex(swiper.realIndex); + }} + > + {detailViews.map((view, index) => ( + + {`${floorPlanName} + + ))} + + {isMultiView && ( + <> + swiperRef.current?.slidePrev()} + /> + swiperRef.current?.slideNext()} + /> + + )} +
+
+

{currentView.view}

+
+ } + primaryButton={ + + 공간 선택하기 + + } + secondaryButton={ + + 좌우반전 + + } + /> + ); +}; + +export default FloorPlanSheet; diff --git a/src/pages/imageSetup/v2/stores/useFloorPlanStore.ts b/src/pages/imageSetup/v2/stores/useFloorPlanStore.ts new file mode 100644 index 00000000..f7bc4486 --- /dev/null +++ b/src/pages/imageSetup/v2/stores/useFloorPlanStore.ts @@ -0,0 +1,135 @@ +/** + * 도면 선택 페이지의 모든 상태를 한 곳에서 관리 + * 1. 도면 선택 상태 + * - selectedFloorPlanId: 어떤 도면 카드를 선택했는지 + * - isMirror: 좌우반전 여부 + * 2. 필터 상태 (API query param 이름과 통일: residenceType, layoutType, areaSize) + * - appliedFilters: 실제 적용된 필터 (도면 카드 그리드에 반영됨) + * - pendingFilters: FilterSheet에서 선택했지만 아직 적용하지 않은 값 저장용 임시 필터 + * 3. 시트 열림/닫힘 상태 + * - isFilterSheetOpen: 필터 sheet 상태 + * - isFloorPlanSheetOpen: 도면 sheet 상태 + * - isRecentSheetOpen: 최근 선택 도면 sheet 상태 + */ + +// zustand 사용: 4개 컴포넌트(도면 그리드, 필터 3개)가 동일 상태를 읽고 써야함 + prop drilling 방지 +import { create } from 'zustand'; + +import { DEFAULT_FILTERS, type FloorPlanFilters } from '../types/floorPlan'; + +// 도면 선택 상태 초기값 — selectFloorPlan, clearFloorPlan, closeFloorPlanSheet, reset에서 반복 사용 +const INITIAL_SELECTION = { + selectedFloorPlanId: null as number | null, + selectedViewIndex: 0, + isMirror: false, +}; + +interface FloorPlanStoreState { + selectedFloorPlanId: number | null; + selectedViewIndex: number; + isMirror: boolean; + + appliedFilters: FloorPlanFilters; + pendingFilters: FloorPlanFilters; + + isFilterSheetOpen: boolean; + isFloorPlanSheetOpen: boolean; + isRecentSheetOpen: boolean; + + // 도면 카드 클릭 시 호출, viewIndex/mirror 초기화 + selectFloorPlan: (id: number) => void; + // 도면 카드 선택 해제 + clearFloorPlan: () => void; + setViewIndex: (idx: number) => void; + toggleMirror: () => void; + // FilterSheet 안에서 칩 클릭 시 다중 선택 토글 (그리드에 실제 반영 X) + setPendingFilter: (key: keyof FloorPlanFilters, value: string) => void; + clearAppliedFilter: (key: keyof FloorPlanFilters) => void; + // '필터 적용하기' 클릭 시 pendingFilter를 appliedFilter로 복사 + applyFilters: () => void; + resetFilters: () => void; + // 필터 sheet 열 때 pendingFilter를 appliedFilter로 동기화 + openFilterSheet: () => void; + closeFilterSheet: () => void; + openFloorPlanSheet: () => void; + // 도면선택 sheet 닫을 때 선택 상태도 함께 초기화 + closeFloorPlanSheet: () => void; + // 최근 선택한 도면이 있을 경우 RecentSheet open + openRecentSheet: () => void; + closeRecentSheet: () => void; + // 페이지 이탈 시 모든 상태 초기화 + reset: () => void; +} + +export const useFloorPlanStore = create((set) => ({ + selectedFloorPlanId: null, + selectedViewIndex: 0, + isMirror: false, + + appliedFilters: { ...DEFAULT_FILTERS }, + pendingFilters: { ...DEFAULT_FILTERS }, + + isFilterSheetOpen: false, + isFloorPlanSheetOpen: false, + isRecentSheetOpen: false, + + selectFloorPlan: (id) => + set({ ...INITIAL_SELECTION, selectedFloorPlanId: id }), + clearFloorPlan: () => set(INITIAL_SELECTION), + // 도면이 여러 장일 경우 선택된 도면 view index 세팅 + // TODO: 바텀시트를 퍼널 스텝으로 등록하면, 뒤로가기로 돌아왔을 때 이전에 보고 있던 뷰 인덱스를 복원해야 함 + setViewIndex: (idx) => set({ selectedViewIndex: idx }), + toggleMirror: () => set((state) => ({ isMirror: !state.isMirror })), + + // FilterSheet의 Filter가 바뀔 때 pending필터값 저장 + setPendingFilter: (key, value) => + set((state) => ({ + pendingFilters: { + ...state.pendingFilters, + [key]: + value === 'ALL' + ? [] + : state.pendingFilters[key].includes(value) + ? state.pendingFilters[key].filter( + (selectedValue) => selectedValue !== value + ) + : [...state.pendingFilters[key], value], + }, + })), + // FilterChip의 X버튼 클릭 시 해당 카테고리의 필터 clear + clearAppliedFilter: (key) => + set((state) => ({ + appliedFilters: { + ...state.appliedFilters, + [key]: [], + }, + })), + applyFilters: () => + set((state) => ({ appliedFilters: { ...state.pendingFilters } })), + resetFilters: () => + set({ + pendingFilters: { ...DEFAULT_FILTERS }, + }), + + openFilterSheet: () => + set((state) => ({ + isFilterSheetOpen: true, + pendingFilters: { ...state.appliedFilters }, + })), + closeFilterSheet: () => set({ isFilterSheetOpen: false }), + openFloorPlanSheet: () => set({ isFloorPlanSheetOpen: true }), + closeFloorPlanSheet: () => + set({ ...INITIAL_SELECTION, isFloorPlanSheetOpen: false }), + openRecentSheet: () => set({ isRecentSheetOpen: true }), + closeRecentSheet: () => set({ isRecentSheetOpen: false }), + + reset: () => + set({ + ...INITIAL_SELECTION, + appliedFilters: { ...DEFAULT_FILTERS }, + pendingFilters: { ...DEFAULT_FILTERS }, + isFilterSheetOpen: false, + isFloorPlanSheetOpen: false, + isRecentSheetOpen: false, + }), +})); diff --git a/src/pages/imageSetup/v2/types/floorPlan.ts b/src/pages/imageSetup/v2/types/floorPlan.ts new file mode 100644 index 00000000..b30d4906 --- /dev/null +++ b/src/pages/imageSetup/v2/types/floorPlan.ts @@ -0,0 +1,80 @@ +// --- 도면 목록 API 응답 타입 --- + +// 도면 카드 하나의 데이터 (목록 조회) +export interface FloorPlanData { + id: number; + name: string; + imageUrl: string; + isLatest: boolean; + residenceType: string; + layoutType: string; + areaSize: string; +} + +// API 응답 래퍼 +export interface FloorPlanListResponse { + isExact: boolean; + floorPlans: FloorPlanData[]; +} + +// --- 도면 상세 API 응답 타입 --- + +// 도면 상세 뷰 하나의 데이터 (상세 조회 시 배열로 반환) +export interface FloorPlanDetailView { + id: number; + name: string; + imageUrl: string; + equilibrium: string; // Enum — 평형 (값 미정) + view: string; // Enum — 뷰 (값 미정) +} + +// API 응답 래퍼 +export interface FloorPlanDetailResponse { + floorPlan: FloorPlanDetailView[]; +} + +// --- 최근 생성 도면 API 응답 타입 --- + +// 최근 생성에 사용된 도면 데이터 (FloorPlanDetailView와 동일 구조) +export type RecentFloorPlanData = FloorPlanDetailView; + +// API 응답 래퍼 +export interface RecentFloorPlanResponse { + hasRecentImage: boolean; + floorPlan: RecentFloorPlanData | null; +} + +// --- 필터 관련 타입 --- + +// 각 FilterCategory의 하위 선택지 +export interface FilterOption { + id: string; + label: string; +} + +// ex: 주거 형태, 구조, 평형 +export interface FilterCategory { + /** + * FloorPlanFilters의 key('residenceType' | 'layoutType' | 'areaSize')로 제한. + * appliedFilters[category.id]처럼 필터 객체 키로 직접 사용되므로, + * 임의의 문자열이 키로 들어오는 것을 방지하고 + * 사용처에서 `as keyof FloorPlanFilters` 단언 없이 타입 안전하게 접근하기 위함. + */ + id: keyof FloorPlanFilters; + label: string; + options: FilterOption[]; +} + +// 현재 적용된 필터 상태 (API query param 이름과 통일) +export interface FloorPlanFilters { + residenceType: string[]; + layoutType: string[]; + areaSize: string[]; +} + +// 필터 초기값 +export const DEFAULT_FILTERS: FloorPlanFilters = { + residenceType: [], + layoutType: [], + areaSize: [], +}; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 8912bee7..a8da4895 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -68,6 +68,16 @@ const publicRoutes = [ return { Component: PrivacyPolicyPage }; }, }, + // TODO: 확인 완료 후 삭제 + { + path: '/test/floor-plan', + lazy: async () => { + const { default: FloorPlanSelectTest } = await import( + '@pages/imageSetup/v2/FloorPlanSelectTest' + ); + return { Component: FloorPlanSelectTest }; + }, + }, ]; // 보호된 라우트 그룹 (인증 필요) diff --git a/src/shared/assets/v2/images/ImgEmpty.png b/src/shared/assets/v2/images/ImgEmpty.png new file mode 100644 index 00000000..10032e18 Binary files /dev/null and b/src/shared/assets/v2/images/ImgEmpty.png differ diff --git a/src/shared/assets/v2/svg/Close.svg b/src/shared/assets/v2/svg/Close.svg index 505c6dd0..8e3b14f4 100644 --- a/src/shared/assets/v2/svg/Close.svg +++ b/src/shared/assets/v2/svg/Close.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/src/shared/components/pageLayout/PageLayout.css.ts b/src/shared/components/pageLayout/PageLayout.css.ts index 14298118..7fbe2790 100644 --- a/src/shared/components/pageLayout/PageLayout.css.ts +++ b/src/shared/components/pageLayout/PageLayout.css.ts @@ -4,10 +4,12 @@ export const layout = style({ display: 'flex', flex: 1, flexDirection: 'column', + minHeight: 0, }); export const content = style({ display: 'flex', flex: 1, flexDirection: 'column', + minHeight: 0, }); diff --git a/src/shared/components/pageLayout/PageLayout.tsx b/src/shared/components/pageLayout/PageLayout.tsx index 130f267a..77cb8a1c 100644 --- a/src/shared/components/pageLayout/PageLayout.tsx +++ b/src/shared/components/pageLayout/PageLayout.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; -import LogoNavBar from '@components/navBar/LogoNavBar'; -import TitleNavBar from '@components/navBar/TitleNavBar'; +import LogoNavBar from '@components/v2/navBar/LogoNavBar'; +import TitleNavBar from '@components/v2/navBar/TitleNavBar'; import * as styles from './PageLayout.css'; @@ -9,14 +9,16 @@ type HeaderConfig = | { type: 'title'; title: string; - showBackButton?: boolean; + backLabel?: string; onBackClick?: () => void; - showLoginButton?: boolean; - showSettingButton?: boolean; } | { type: 'logo'; - buttonType?: 'login' | 'profile' | null; + page?: 'landing' | 'home'; + showGenerateButton?: boolean; + authSlot?: 'none' | 'login' | 'profile'; + onGenerateClick?: () => void; + onLoginClick?: () => void; onProfileClick?: () => void; } | { type: 'none' }; @@ -34,16 +36,18 @@ const PageLayout = ({ header, children, className }: PageLayoutProps) => { return ( ); case 'logo': return ( ); @@ -53,7 +57,7 @@ const PageLayout = ({ header, children, className }: PageLayoutProps) => { }; return ( -
+
{renderHeader()}
{children}
diff --git a/src/shared/components/v2/bottomSheet/BottomSheetBase.css.ts b/src/shared/components/v2/bottomSheet/BottomSheetBase.css.ts index 925ae87f..5ca272ce 100644 --- a/src/shared/components/v2/bottomSheet/BottomSheetBase.css.ts +++ b/src/shared/components/v2/bottomSheet/BottomSheetBase.css.ts @@ -1,4 +1,5 @@ import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; import { zIndex } from '@styles/tokens/zIndex'; import { colorVars } from '@styles/tokensV2/color.css'; @@ -30,6 +31,7 @@ export const overlay = style([ top: 0, bottom: 0, backgroundColor: colorVars.color.fill.dim, + pointerEvents: 'auto', }, ]); @@ -64,7 +66,7 @@ export const panel = style({ borderTopLeftRadius: unitVars.unit.radius['700'], borderTopRightRadius: unitVars.unit.radius['700'], backgroundColor: colorVars.color.bg.primary, - padding: `${unitVars.unit.gapPadding['200']} ${unitVars.unit.gapPadding['600']} ${unitVars.unit.gapPadding['600']}`, + padding: `${unitVars.unit.gapPadding['200']} ${unitVars.unit.gapPadding['000']} ${unitVars.unit.gapPadding['600']}`, width: '100%', maxHeight: 'calc(100dvh - 10.4rem)', overflow: 'hidden', @@ -103,14 +105,28 @@ export const closeHeader = style({ height: '4.8rem', }); -// close 타입 제목을 헤더 중앙에 고정하는 슬롯 -export const titleSlot = style({ - position: 'absolute', - inset: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - pointerEvents: 'none', +// close 타입 제목을 헤더 왼쪽(FilterSheet)/중앙(FloorPlanSheet)에 고정하는 슬롯 +export const titleSlot = recipe({ + base: { + display: 'flex', + alignItems: 'center', + minWidth: 0, + }, + variants: { + align: { + center: { + position: 'absolute', + inset: 0, + justifyContent: 'center', + pointerEvents: 'none', + }, + left: { + flex: 1, + justifyContent: 'flex-start', + paddingLeft: unitVars.unit.gapPadding['400'], + }, + }, + }, }); // 우측 상단 닫기 액션 버튼 @@ -131,19 +147,13 @@ export const closeButton = style({ }, }); -// 닫기 버튼 내부 X 아이콘의 고정 크기 -export const closeIcon = style({ - flexShrink: 0, - width: '2.4rem', - height: '2.4rem', -}); - // 본문 콘텐츠와 하단 버튼 감싸는 column 래퍼 export const body = style({ display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: unitVars.unit.gapPadding['500'], + padding: `${unitVars.unit.gapPadding['000']} ${unitVars.unit.gapPadding['600']}`, width: '100%', height: '100%', minHeight: 0, diff --git a/src/shared/components/v2/bottomSheet/BottomSheetBase.tsx b/src/shared/components/v2/bottomSheet/BottomSheetBase.tsx index 8d32c633..0b235b3d 100644 --- a/src/shared/components/v2/bottomSheet/BottomSheetBase.tsx +++ b/src/shared/components/v2/bottomSheet/BottomSheetBase.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { Drawer } from 'vaul'; -import IcnX from '@assets/v2/svg/IcnX.svg?react'; +import IconButton from '@components/v2/button/IconButton'; import * as styles from './BottomSheetBase.css'; @@ -11,6 +11,7 @@ interface BottomSheetBaseProps { open: boolean; headerType: 'dragHandle' | 'close'; titleSlot?: ReactNode; + titleAlign?: 'left' | 'center'; contentSlot: ReactNode; primaryButton: ReactNode; secondaryButton?: ReactNode; @@ -27,6 +28,7 @@ const BottomSheetBase = ({ open, headerType, titleSlot, + titleAlign = 'center', contentSlot, primaryButton, secondaryButton, @@ -63,7 +65,8 @@ const BottomSheetBase = ({ {open && (
- 커스텀 dim overlay 적용 */} + ) : (
-
{titleSlot}
-
+ -
)}
diff --git a/src/shared/components/v2/bottomSheet/CloseBottomSheet.tsx b/src/shared/components/v2/bottomSheet/CloseBottomSheet.tsx index f7af9453..4d05af12 100644 --- a/src/shared/components/v2/bottomSheet/CloseBottomSheet.tsx +++ b/src/shared/components/v2/bottomSheet/CloseBottomSheet.tsx @@ -10,6 +10,7 @@ interface CloseBottomSheetProps { primaryButton: ReactNode; secondaryButton?: ReactNode; height?: string; + titleAlign?: 'left' | 'center'; } const CloseBottomSheet = ({ @@ -20,12 +21,14 @@ const CloseBottomSheet = ({ primaryButton, secondaryButton, height, + titleAlign = 'center', }: CloseBottomSheetProps) => { return ( - - + ); }; diff --git a/src/shared/components/v2/chip/Chip.css.ts b/src/shared/components/v2/chip/Chip.css.ts index 642685cb..6f1d396e 100644 --- a/src/shared/components/v2/chip/Chip.css.ts +++ b/src/shared/components/v2/chip/Chip.css.ts @@ -10,6 +10,7 @@ export const chip = recipe({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: unitVars.unit.gapPadding['050'], transition: 'transform 100ms ease, background-color 100ms ease, color 100ms ease', borderWidth: '0.1rem', @@ -39,13 +40,6 @@ export const chip = recipe({ }, }); -export const content = style({ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: unitVars.unit.gapPadding['050'], -}); - export const label = recipe({ base: { display: 'inline-flex', @@ -53,15 +47,16 @@ export const label = recipe({ justifyContent: 'center', paddingLeft: unitVars.unit.gapPadding['300'], whiteSpace: 'nowrap', - ...fontVars.font.body_r_13, }, variants: { selected: { false: { color: colorVars.color.text.tertiary, + ...fontVars.font.body_r_13, }, true: { color: colorVars.color.text.inverse, + ...fontVars.font.body_m_13, }, }, hasSuffix: { @@ -85,8 +80,19 @@ export const suffix = style({ paddingLeft: unitVars.unit.gapPadding['000'], }); -export const suffixIcon = style({ - flexShrink: 0, - width: '1.2rem', - height: '1.2rem', +export const suffixButton = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'transform 100ms ease', + paddingTop: unitVars.unit.gapPadding['200'], + paddingRight: unitVars.unit.gapPadding['300'], + paddingBottom: unitVars.unit.gapPadding['200'], + paddingLeft: unitVars.unit.gapPadding['000'], + color: 'inherit', + selectors: { + '&:active': { + transform: 'scale(0.9)', + }, + }, }); diff --git a/src/shared/components/v2/chip/Chip.tsx b/src/shared/components/v2/chip/Chip.tsx index 98e333f9..fe23b5d5 100644 --- a/src/shared/components/v2/chip/Chip.tsx +++ b/src/shared/components/v2/chip/Chip.tsx @@ -6,34 +6,51 @@ interface ChipProps extends Omit, 'children'> { children: ReactNode; selected?: boolean; suffixIcon?: ReactNode; + suffixAriaLabel?: string; + onSuffixClick?: () => void; } const Chip = ({ children, selected = false, suffixIcon, + suffixAriaLabel, + onSuffixClick, type = 'button', + className, + onClick, ...props }: ChipProps) => { const hasSuffix = suffixIcon !== undefined; + const chipClassName = `${styles.chip({ selected })}${className ? ` ${className}` : ''}`; return ( ); }; diff --git a/src/shared/components/v2/navBar/TitleNavBar.css.ts b/src/shared/components/v2/navBar/TitleNavBar.css.ts index 7b1dca4d..5fa8bcce 100644 --- a/src/shared/components/v2/navBar/TitleNavBar.css.ts +++ b/src/shared/components/v2/navBar/TitleNavBar.css.ts @@ -31,10 +31,6 @@ export const backButton = style({ transition: 'transform 120ms ease', border: 0, background: 'transparent', - paddingTop: unitVars.unit.gapPadding['000'], - paddingRight: unitVars.unit.gapPadding['100'], - paddingBottom: unitVars.unit.gapPadding['000'], - paddingLeft: unitVars.unit.gapPadding['000'], color: colorVars.color.text.tertiary, ...fontVars.font.title_r_15, selectors: { @@ -44,10 +40,8 @@ export const backButton = style({ }, }); -export const backIcon = style({ - flexShrink: 0, - width: '2rem', - height: '2rem', +export const label = style({ + padding: `${unitVars.unit.gapPadding['000']} ${unitVars.unit.gapPadding['100']}`, }); export const title = style({ diff --git a/src/shared/components/v2/navBar/TitleNavBar.tsx b/src/shared/components/v2/navBar/TitleNavBar.tsx index ff0396a0..a07d7902 100644 --- a/src/shared/components/v2/navBar/TitleNavBar.tsx +++ b/src/shared/components/v2/navBar/TitleNavBar.tsx @@ -1,4 +1,4 @@ -import IcnArrowLeftM from '@assets/v2/svg/IcnArrowLeftM.svg?react'; +import Icon from '@components/v2/icon/Icon'; import * as styles from './TitleNavBar.css'; @@ -26,8 +26,8 @@ const TitleNavBar = ({ className={styles.backButton} onClick={onBackClick} > -

{title}

diff --git a/src/shared/components/v2/roomTypeCard/RoomTypeCard.css.ts b/src/shared/components/v2/roomTypeCard/RoomTypeCard.css.ts index 57d3e205..df9c42f6 100644 --- a/src/shared/components/v2/roomTypeCard/RoomTypeCard.css.ts +++ b/src/shared/components/v2/roomTypeCard/RoomTypeCard.css.ts @@ -25,6 +25,7 @@ export const optionCard = recipe({ variants: { kind: { default: { + flexDirection: 'column', backgroundColor: colorVars.color.fill.strong, }, more: { @@ -35,20 +36,23 @@ export const optionCard = recipe({ }, size: { s: { + aspectRatio: '1 / 1', padding: unitVars.unit.gapPadding['300'], - width: '16rem', - height: '16rem', + width: '100%', + minWidth: '16rem', }, m: { + aspectRatio: '1 / 1', padding: unitVars.unit.gapPadding['300'], - width: '16.4rem', - height: '16.4rem', + width: '100%', + minWidth: '16.4rem', }, }, }, }); export const previewCard = style({ + aspectRatio: '1 / 1', position: 'relative', display: 'flex', flexShrink: 0, @@ -57,8 +61,8 @@ export const previewCard = style({ borderRadius: unitVars.unit.radius['600'], backgroundColor: colorVars.color.fill.tertiary, padding: unitVars.unit.gapPadding['100'], - width: '33.9rem', - height: '33.9rem', + width: '100%', + minWidth: '32.7rem', overflow: 'hidden', }); @@ -99,23 +103,6 @@ export const optionInfoRow = recipe({ }, }); -export const optionTitleIcon = recipe({ - base: { - flexShrink: 0, - width: '1.6rem', - height: '1.6rem', - }, - variants: { - size: { - s: {}, - m: { - paddingTop: unitVars.unit.gapPadding['050'], - paddingBottom: unitVars.unit.gapPadding['050'], - }, - }, - }, -}); - export const optionTitle = recipe({ base: { margin: 0, @@ -205,11 +192,3 @@ export const previewNavButton = style({ }, }, }); - -export const previewNavIcon = style({ - flexShrink: 0, - borderRadius: unitVars.unit.radius.full, - backgroundColor: colorVars.color.fill.inverse, - width: '2rem', - height: '2rem', -}); diff --git a/src/shared/components/v2/roomTypeCard/RoomTypeCard.tsx b/src/shared/components/v2/roomTypeCard/RoomTypeCard.tsx index 6ad60749..30aee6eb 100644 --- a/src/shared/components/v2/roomTypeCard/RoomTypeCard.tsx +++ b/src/shared/components/v2/roomTypeCard/RoomTypeCard.tsx @@ -6,7 +6,6 @@ import clsx from 'clsx'; import fallbackImage from '@assets/v2/images/CardRoomTypeFallback.svg'; import * as styles from './RoomTypeCard.css'; -import IconButton from '../button/IconButton'; import Icon from '../icon/Icon'; type ButtonProps = Omit< @@ -131,22 +130,26 @@ const RoomTypePreviewCard = ({ draggable={false} onError={() => setImageSrc(fallbackImage)} /> - - + {onPrevClick && ( + + )} + {onNextClick && ( + + )}
); };