diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index a6318cebc..f9a4142a0 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -110,6 +110,7 @@ const HomePage = () => { { value: 'product', label: '상품' }, ]} activeTab={activeMenuTab} + sticky={activeMenuTab === 'explore'} onTabChange={setActiveMenuTab} /> {activeMenuTab === 'explore' && } diff --git a/src/pages/home/apis/queries/useProductMainQuery.test.ts b/src/pages/home/apis/queries/useProductMainQuery.test.ts new file mode 100644 index 000000000..a1148db9d --- /dev/null +++ b/src/pages/home/apis/queries/useProductMainQuery.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { + getMockProductMainResponse, + toSearchSectionProducts, +} from './useProductMainQuery'; + +describe('useProductMainQuery mock', () => { + // 검색/필터 파라미터가 응답 데이터와 appliedFilters에 함께 반영되는지 검증 + it('types, colors, price, keyword를 조합해 필터링하고 appliedFilters를 반환', () => { + const response = getMockProductMainResponse({ + keyword: '수납장', + types: [3], + minPrice: 500000, + maxPrice: 2500000, + colors: [1], + size: 20, + }); + + expect(response.code).toBe(200); + expect(response.msg).toBe('응답 성공'); + expect(response.data.products.length).toBeGreaterThan(0); + expect( + response.data.products.every( + (product) => product.categoryName === '수납/장식장' + ) + ).toBe(true); + expect(response.data.meta.appliedFilters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + category: 'type', + id: '3', + label: '수납/장식장', + }), + expect.objectContaining({ + category: 'color', + id: '1', + label: '화이트', + value: '#FFFFFF', + }), + ]) + ); + }); + + // 커서 기반 페이지네이션에서 다음 페이지가 이전 페이지보다 작은 id 구간으로 내려오는지 검증 + it('cursor, size 기준으로 페이지네이션 메타를 계산한다', () => { + const firstPage = getMockProductMainResponse({ size: 3 }); + expect(firstPage.data.products).toHaveLength(3); + expect(firstPage.data.meta.hasNext).toBe(true); + expect(firstPage.data.meta.nextCursor).not.toBeNull(); + + const secondPage = getMockProductMainResponse({ + size: 3, + cursor: firstPage.data.meta.nextCursor ?? undefined, + }); + expect(secondPage.data.products).toHaveLength(3); + expect(secondPage.data.products[0].id).toBeLessThan( + firstPage.data.products[firstPage.data.products.length - 1].id + ); + }); + + // UI(ProductCard 리스트)에서 바로 사용할 수 있는 뷰 모델로 변환되는지 검증 + it('SearchSection에서 바로 쓸 수 있는 카드 모델로 매핑', () => { + const response = getMockProductMainResponse({ size: 1 }); + const cards = toSearchSectionProducts(response); + + expect(cards).toHaveLength(1); + expect(cards[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + title: expect.any(String), + originalPrice: expect.any(Number), + discountPrice: expect.any(Number), + linkUrl: expect.any(String), + }) + ); + }); +}); diff --git a/src/pages/home/apis/queries/useProductMainQuery.ts b/src/pages/home/apis/queries/useProductMainQuery.ts new file mode 100644 index 000000000..e95ebf551 --- /dev/null +++ b/src/pages/home/apis/queries/useProductMainQuery.ts @@ -0,0 +1,5 @@ +export { + getMockProductMainResponse, + toSearchSectionProducts, + type SearchSectionProductCardItem, +} from '../../mocks/productMainApiMock'; diff --git a/src/pages/home/components/product/IntroSection/IntroSection.css.ts b/src/pages/home/components/product/IntroSection/IntroSection.css.ts index 3e39f6f89..f3584ab85 100644 --- a/src/pages/home/components/product/IntroSection/IntroSection.css.ts +++ b/src/pages/home/components/product/IntroSection/IntroSection.css.ts @@ -1,8 +1,24 @@ import { style } from '@vanilla-extract/css'; +import { unitVars } from '@styles/tokensV2/unit.css'; + +/** 비율 박스 — introBanner가 inset으로 프레임에 맞게 채움 */ export const section = style({ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', + boxSizing: 'border-box', + aspectRatio: '37.5 / 22', + position: 'relative', + width: '100%', + minWidth: unitVars.unit.dimension.wMin, + maxWidth: unitVars.unit.dimension.wMax, + overflow: 'hidden', +}); + +export const introBanner = style({ + position: 'absolute', + inset: 0, + display: 'block', + objectFit: 'cover', + objectPosition: 'center', width: '100%', + height: '100%', }); diff --git a/src/pages/home/components/product/IntroSection/IntroSection.tsx b/src/pages/home/components/product/IntroSection/IntroSection.tsx index 2e00fef7f..3cae8673a 100644 --- a/src/pages/home/components/product/IntroSection/IntroSection.tsx +++ b/src/pages/home/components/product/IntroSection/IntroSection.tsx @@ -1,7 +1,43 @@ +import { useEffect, useState } from 'react'; + +import bannerFallback from '@assets/v2/images/bannerFallback.svg'; + import * as styles from './IntroSection.css'; -const IntroSection = () => { - return
IntroSection
; +export type IntroSectionProps = { + bannerUrl?: string | null; + alt?: string; +}; + +const resolveInitialSrc = (url: string | null | undefined) => + url ? url : bannerFallback; + +const IntroSection = ({ bannerUrl, alt = '상품 배너' }: IntroSectionProps) => { + const initialImageSrc = resolveInitialSrc(bannerUrl); + + const [imageSrc, setImageSrc] = useState(initialImageSrc); + + const handleImageError = () => { + setImageSrc(bannerFallback); + }; + + useEffect(() => { + setImageSrc(resolveInitialSrc(bannerUrl)); + }, [bannerUrl]); + + return ( +
+ {alt} +
+ ); }; export default IntroSection; diff --git a/src/pages/home/components/product/ProductFilterSheet/ProductFilterSheet.css.ts b/src/pages/home/components/product/ProductFilterSheet/ProductFilterSheet.css.ts new file mode 100644 index 000000000..247a8091f --- /dev/null +++ b/src/pages/home/components/product/ProductFilterSheet/ProductFilterSheet.css.ts @@ -0,0 +1,45 @@ +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 root = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['200'], +}); + +export const section = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['300'], +}); + +export const sectionTitle = style({ + textAlign: 'left', + color: colorVars.color.text.secondary, + ...fontVars.font.title_m_15, +}); + +export const chipGroup = style({ + display: 'flex', + flexWrap: 'wrap', + gap: unitVars.unit.gapPadding['100'], +}); + +export const colorChipInner = style({ + display: 'inline-flex', + alignItems: 'center', + gap: unitVars.unit.gapPadding['100'], +}); + +export const colorDot = style({ + flexShrink: 0, + borderWidth: '0.1rem', + borderStyle: 'solid', + borderRadius: unitVars.unit.radius.full, + borderColor: colorVars.color.border.secondary, + width: '1.2rem', + height: '1.2rem', +}); diff --git a/src/pages/home/components/product/ProductFilterSheet/ProductFilterSheet.tsx b/src/pages/home/components/product/ProductFilterSheet/ProductFilterSheet.tsx new file mode 100644 index 000000000..6695f7ca5 --- /dev/null +++ b/src/pages/home/components/product/ProductFilterSheet/ProductFilterSheet.tsx @@ -0,0 +1,246 @@ +import { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; + +import Chip from '@components/v2/chip/Chip'; + +import * as styles from './ProductFilterSheet.css'; + +const ALL = 'ALL'; +const PRODUCT_FILTERS_MOCK_RESPONSE = { + code: 200, + msg: '응답 성공', + data: { + furnitureTypes: [ + { id: 1, nameKr: '침대/프레임', nameEng: 'BED' }, + { id: 2, nameKr: '업무용 책상', nameEng: 'DESK' }, + { id: 3, nameKr: '식탁', nameEng: 'DINING' }, + { id: 4, nameKr: '좌식 테이블', nameEng: 'FLOOR_TABLE' }, + { id: 5, nameKr: '옷장', nameEng: 'WARDROBE' }, + { id: 6, nameKr: '수납/장식장', nameEng: 'STORAGE' }, + { id: 7, nameKr: '소파', nameEng: 'SOFA' }, + { id: 8, nameKr: '의자/스툴', nameEng: 'CHAIR' }, + { id: 9, nameKr: '화장대/협탁', nameEng: 'VANITY' }, + { id: 10, nameKr: '조명', nameEng: 'LIGHT' }, + { id: 11, nameKr: '그 외', nameEng: 'OTHER' }, + ], + priceRanges: [ + { id: 'P1', label: '5만원 이하', min: 0, max: 50000 }, + { id: 'P2', label: '5-10만원', min: 50000, max: 100000 }, + { id: 'P3', label: '10만원대', min: 100000, max: 199999 }, + { id: 'P4', label: '20만원대', min: 200000, max: 299999 }, + { id: 'P5', label: '30만원대', min: 300000, max: 399999 }, + { id: 'P6', label: '40만원대', min: 400000, max: 499999 }, + { id: 'P7', label: '50만원 이상', min: 500000, max: null }, + ], + colors: [ + { id: 1, label: '블랙', value: '#000000' }, + { id: 2, label: '화이트', value: '#FFFFFF' }, + { id: 3, label: '그레이', value: '#8E959E' }, + { id: 4, label: '베이지', value: '#D4C4B0' }, + { id: 5, label: '실버', value: '#C8CDD2' }, + { id: 6, label: '골드', value: '#D4AF37' }, + { id: 7, label: '브라운', value: '#5C4033' }, + { id: 8, label: '레드', value: '#E53935' }, + { id: 9, label: '오렌지', value: '#FB8C00' }, + { id: 10, label: '옐로우', value: '#FDD835' }, + { id: 11, label: '그린', value: '#43A047' }, + { id: 12, label: '블루', value: '#1E88E5' }, + { id: 13, label: '네이비', value: '#1A237E' }, + { id: 14, label: '바이올렛', value: '#7E57C2' }, + { id: 15, label: '핑크', value: '#EC407A' }, + ], + }, +} as const; + +const FURNITURE_OPTIONS: { id: string; label: string }[] = [ + { id: ALL, label: '전체' }, + ...PRODUCT_FILTERS_MOCK_RESPONSE.data.furnitureTypes.map((type) => ({ + id: String(type.id), + label: type.nameKr, + })), +]; + +const PRICE_OPTIONS: { id: string; label: string }[] = [ + { id: ALL, label: '전체' }, + ...PRODUCT_FILTERS_MOCK_RESPONSE.data.priceRanges.map((range) => ({ + id: range.id, + label: range.label, + })), +]; + +const COLOR_OPTIONS: { id: string; label: string; value?: string }[] = [ + { id: ALL, label: '전체' }, + ...PRODUCT_FILTERS_MOCK_RESPONSE.data.colors.map((color) => ({ + id: String(color.id), + label: color.label, + value: color.value, + })), +]; + +const INITIAL_SELECTION: string[] = [ALL]; + +// 섹션 내 다중 선택 - ALL만 있으면 ‘전체’, 그 외는 선택된 id 목록 +function toggleSectionSelection(current: string[], id: string): string[] { + if (id === ALL) { + return [ALL]; + } + const withoutAll = current.filter((x) => x !== ALL); + const has = withoutAll.includes(id); + const next = has ? withoutAll.filter((x) => x !== id) : [...withoutAll, id]; + return next.length === 0 ? [ALL] : next; +} + +export interface ProductFilterValues { + furnitureTypeIds: string[]; + priceRangeIds: string[]; + colorIds: string[]; +} + +export interface ProductFilterSheetRef { + reset: () => void; + getValues: () => ProductFilterValues; + setValues: (values: ProductFilterValues) => void; +} + +const ProductFilterSheet = forwardRef( + function ProductFilterSheet(_props, ref) { + const [furnitureTypeIds, setFurnitureTypeIds] = + useState(INITIAL_SELECTION); + const [priceRangeIds, setPriceRangeIds] = + useState(INITIAL_SELECTION); + const [colorIds, setColorIds] = useState(INITIAL_SELECTION); + + const reset = useCallback(() => { + setFurnitureTypeIds([...INITIAL_SELECTION]); + setPriceRangeIds([...INITIAL_SELECTION]); + setColorIds([...INITIAL_SELECTION]); + }, []); + + const getValues = useCallback((): ProductFilterValues => { + return { + furnitureTypeIds: [...furnitureTypeIds], + priceRangeIds: [...priceRangeIds], + colorIds: [...colorIds], + }; + }, [furnitureTypeIds, priceRangeIds, colorIds]); + + const setValues = useCallback((values: ProductFilterValues) => { + setFurnitureTypeIds( + values.furnitureTypeIds.length > 0 + ? [...values.furnitureTypeIds] + : [...INITIAL_SELECTION] + ); + setPriceRangeIds( + values.priceRangeIds.length > 0 + ? [...values.priceRangeIds] + : [...INITIAL_SELECTION] + ); + setColorIds( + values.colorIds.length > 0 + ? [...values.colorIds] + : [...INITIAL_SELECTION] + ); + }, []); + + useImperativeHandle( + ref, + () => ({ + reset, + getValues, + setValues, + }), + [reset, getValues, setValues] + ); + + const handleFurnitureChipClick = useCallback((id: string) => { + setFurnitureTypeIds((prev) => toggleSectionSelection(prev, id)); + }, []); + + const handlePriceChipClick = useCallback((id: string) => { + setPriceRangeIds((prev) => toggleSectionSelection(prev, id)); + }, []); + + const handleColorChipClick = useCallback((id: string) => { + setColorIds((prev) => toggleSectionSelection(prev, id)); + }, []); + + return ( +
+
+

+ 카테고리 +

+
+ {FURNITURE_OPTIONS.map(({ id, label }) => ( + handleFurnitureChipClick(id)} + > + {label} + + ))} +
+
+ +
+

+ 가격대 +

+
+ {PRICE_OPTIONS.map(({ id, label }) => ( + handlePriceChipClick(id)} + > + {label} + + ))} +
+
+ +
+

+ 색상 +

+
+ {COLOR_OPTIONS.map(({ id, label, value }) => ( + handleColorChipClick(id)} + > + {value ? ( + + + {label} + + ) : ( + label + )} + + ))} +
+
+
+ ); + } +); + +ProductFilterSheet.displayName = 'ProductFilterSheet'; + +export default ProductFilterSheet; diff --git a/src/pages/home/components/product/ProductTab.css.ts b/src/pages/home/components/product/ProductTab.css.ts index 6ff6411ef..9ab9dea48 100644 --- a/src/pages/home/components/product/ProductTab.css.ts +++ b/src/pages/home/components/product/ProductTab.css.ts @@ -1,8 +1,21 @@ 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({ + boxSizing: 'border-box', display: 'flex', flexDirection: 'column', - alignItems: 'center', + alignItems: 'stretch', + alignSelf: 'stretch', width: '100%', + minWidth: unitVars.unit.dimension.wMin, + maxWidth: unitVars.unit.dimension.wMax, +}); + +export const filterSheetTitle = style({ + color: colorVars.color.text.primary, + ...fontVars.font.title_m_16, }); diff --git a/src/pages/home/components/product/ProductTab.tsx b/src/pages/home/components/product/ProductTab.tsx index c937a027b..784818f00 100644 --- a/src/pages/home/components/product/ProductTab.tsx +++ b/src/pages/home/components/product/ProductTab.tsx @@ -1,12 +1,96 @@ +import CloseBottomSheet from '@shared/components/v2/bottomSheet/CloseBottomSheet'; +import DragHandleBottomSheet from '@shared/components/v2/bottomSheet/DragHandleBottomSheet'; +import ActionButton from '@shared/components/v2/button/actionButton/ActionButton'; + import IntroSection from './IntroSection/IntroSection'; +import ProductFilterSheet from './ProductFilterSheet/ProductFilterSheet'; import * as styles from './ProductTab.css'; import SearchSection from './SearchSection/SearchSection'; +import SelectedProductSheet from './SelectedProductSheet/SelectedProductSheet'; +import { + MAX_SELECTED_PRODUCTS, + useProductTabState, +} from '../../hooks/useProductTabState'; const ProductTab = () => { + const { + sheetExpanded, + setSheetExpanded, + filterSheetOpen, + chipSelected, + appliedFilterChips, + selectedProducts, + productFilterSheetRef, + handleFilterChipClick, + handleRemoveAppliedChip, + handleSelectProduct, + handleRemoveSelectedProduct, + handleDecorateWithProductsClick, + handleFilterSheetClose, + handleFilterApply, + handleFilterResetClick, + } = useProductTabState(); + return (
- + product.id)} + onSelectProduct={handleSelectProduct} + /> + + + } + primaryButton={ + + 이 상품들로 우리 집 꾸미기 + + } + /> + + 필터

} + contentSlot={} + secondaryButton={ + + 초기화 + + } + primaryButton={ + + 필터 적용하기 + + } + />
); }; diff --git a/src/pages/home/components/product/SearchSection/SearchSection.css.ts b/src/pages/home/components/product/SearchSection/SearchSection.css.ts index 3e39f6f89..20b0e6385 100644 --- a/src/pages/home/components/product/SearchSection/SearchSection.css.ts +++ b/src/pages/home/components/product/SearchSection/SearchSection.css.ts @@ -1,8 +1,90 @@ import { style } from '@vanilla-extract/css'; +import { zIndex } from '@styles/tokens/zIndex'; +import { colorVars } from '@styles/tokensV2/color.css'; +import { unitVars } from '@styles/tokensV2/unit.css'; + export const section = style({ + boxSizing: 'border-box', display: 'flex', flexDirection: 'column', + alignItems: 'stretch', + paddingBottom: '22rem', // 하단 바 높이만큼 패딩 + width: '100%', +}); + +export const searchHeader = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + padding: `${unitVars.unit.gapPadding['100']} ${unitVars.unit.gapPadding['000']}`, + width: '100%', +}); + +export const stickyHeader = style({ + position: 'fixed', + zIndex: zIndex.navigation, + top: 0, + left: 0, + background: colorVars.color.bg.primary, + width: '100%', +}); + +export const stickySearchBarWrap = style({ + transform: 'translateY(-0.8rem)', + transition: 'max-height 360ms ease, opacity 360ms ease, transform 360ms ease', + opacity: 0, + maxHeight: 0, + overflow: 'hidden', +}); + +export const stickySearchBarWrapVisible = style({ + transform: 'translateY(0)', + opacity: 1, + maxHeight: '6.4rem', +}); + +export const searchBarContainer = style({ + boxSizing: 'border-box', + padding: `${unitVars.unit.gapPadding['200']} ${unitVars.unit.gapPadding['500']}`, + width: '100%', +}); + +export const filterList = style({ + boxSizing: 'border-box', + position: 'relative', + display: 'flex', + alignItems: 'center', + width: '100%', + overflow: 'hidden', +}); + +export const filterScroll = style({ + boxSizing: 'border-box', + display: 'flex', alignItems: 'center', + gap: unitVars.unit.gapPadding['100'], + padding: `${unitVars.unit.gapPadding['200']} ${unitVars.unit.gapPadding['500']}`, + width: '100%', + minWidth: 0, + maxWidth: '100%', + overflowX: 'auto', + overflowY: 'hidden', + overscrollBehaviorX: 'contain', + scrollbarWidth: 'none', + whiteSpace: 'nowrap', + msOverflowStyle: 'none', + selectors: { + '&::-webkit-scrollbar': { + display: 'none', + }, + }, +}); + +export const productList = style({ + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gap: unitVars.unit.gapPadding['200'], + padding: `${unitVars.unit.gapPadding['200']} ${unitVars.unit.gapPadding['500']}`, width: '100%', }); diff --git a/src/pages/home/components/product/SearchSection/SearchSection.tsx b/src/pages/home/components/product/SearchSection/SearchSection.tsx index 06fcc14e3..a6650812d 100644 --- a/src/pages/home/components/product/SearchSection/SearchSection.tsx +++ b/src/pages/home/components/product/SearchSection/SearchSection.tsx @@ -1,7 +1,214 @@ +import { useCallback, useMemo, useRef } from 'react'; + +import ProductCard from '@shared/components/v2/productCard/ProductCard'; +import SearchBar from '@shared/components/v2/textField/SearchBar'; + +import Chip from '@components/v2/chip/Chip'; + +import { + getMockProductMainResponse, + toSearchSectionProducts, +} from '@/pages/home/apis/queries/useProductMainQuery'; +import { useProductStickyHeader } from '@/pages/home/hooks/useProductStickyHeader'; +import Icon from '@/shared/components/v2/icon/Icon'; + import * as styles from './SearchSection.css'; -const SearchSection = () => { - return
SearchSection
; +export type ProductFilterChipCategory = 'furniture' | 'price' | 'color'; + +export interface AppliedFilterChip { + category: ProductFilterChipCategory; + id: string; + label: string; + applied: boolean; +} + +export interface SelectedProduct { + id: string; + title: string; + brand: string; + imageUrl?: string; + originalPrice: number; + discountPrice: number; + discountRate: number; +} + +interface SearchSectionProps { + chipSelected: Record; + onFilterChipClick: (category: ProductFilterChipCategory) => void; + appliedFilterChips: AppliedFilterChip[]; + onAppliedFilterChipRemove: ( + category: ProductFilterChipCategory, + id: string + ) => void; + selectedProductIds: string[]; + onSelectProduct: (product: SelectedProduct) => void; +} + +const SearchSection = ({ + chipSelected, + onFilterChipClick, + appliedFilterChips, + onAppliedFilterChipRemove, + selectedProductIds, + onSelectProduct, +}: SearchSectionProps) => { + const searchBarRef = useRef(null); + const filterListRef = useRef(null); + const { isFilterSticky, showStickySearchBar } = useProductStickyHeader({ + searchBarRef, + filterListRef, + }); + + const mockProducts = toSearchSectionProducts( + getMockProductMainResponse({ + size: 20, + }) + ); + + const handleFilterChipCategoryClick = useCallback( + (category: ProductFilterChipCategory) => { + onFilterChipClick(category); + }, + [onFilterChipClick] + ); + + const handleAppliedFilterChipRemoveClick = useCallback( + (category: ProductFilterChipCategory, id: string) => { + onAppliedFilterChipRemove(category, id); + }, + [onAppliedFilterChipRemove] + ); + + const handleMockSaveToggle = useCallback(() => {}, []); + + const handleSelectMockProduct = useCallback( + (product: SelectedProduct) => { + onSelectProduct(product); + }, + [onSelectProduct] + ); + + const filterChips = useMemo( + () => + appliedFilterChips.map(({ category, id, label, applied }) => + applied ? ( + handleFilterChipCategoryClick(category)} + suffixIcon={} + suffixAriaLabel={`${label} 필터 해제`} + onSuffixClick={() => + handleAppliedFilterChipRemoveClick(category, id) + } + > + {label} + + ) : ( + handleFilterChipCategoryClick(category)} + suffixIcon={} + > + {label} + + ) + ), + [ + appliedFilterChips, + chipSelected, + handleAppliedFilterChipRemoveClick, + handleFilterChipCategoryClick, + ] + ); + + return ( +
+ {isFilterSticky ? ( +
+
+
+ +
+
+
+
{filterChips}
+
+
+ ) : null} +
+
+ +
+
+
{filterChips}
+
+
+
+ {mockProducts.map( + ({ + id, + title, + brand, + imageUrl, + discountRate, + originalPrice, + discountPrice, + colorHexes, + saveCount, + linkUrl, + }) => { + const isSelected = selectedProductIds.includes(id); + return ( + + handleSelectMockProduct({ + id, + title, + brand, + imageUrl, + originalPrice, + discountPrice, + discountRate, + }), + }} + /> + ); + } + )} +
+
+ ); }; export default SearchSection; diff --git a/src/pages/home/components/product/SelectedProductSheet/SelectedProductSheet.css.ts b/src/pages/home/components/product/SelectedProductSheet/SelectedProductSheet.css.ts new file mode 100644 index 000000000..152ee6933 --- /dev/null +++ b/src/pages/home/components/product/SelectedProductSheet/SelectedProductSheet.css.ts @@ -0,0 +1,241 @@ +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 container = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['300'], + width: '100%', +}); + +export const headerRow = style({ + display: 'flex', + alignItems: 'center', + gap: unitVars.unit.gapPadding['100'], + padding: `${unitVars.unit.gapPadding['000']} ${unitVars.unit.gapPadding['200']}`, +}); + +export const title = style({ + color: colorVars.color.text.primary, + ...fontVars.font.title_sb_18, +}); + +export const count = style({ + color: colorVars.color.text.tertiary, + ...fontVars.font.body_r_14, +}); + +export const selectedCount = style({ + color: colorVars.color.text.primary, + ...fontVars.font.title_sb_14, +}); + +export const compactRow = style({ + display: 'grid', + gridTemplateColumns: 'repeat(6, 1fr)', + gap: unitVars.unit.gapPadding['050'], + padding: `0 ${unitVars.unit.gapPadding['050']}`, + width: '100%', +}); + +export const compactSlot = style({ + aspectRatio: '1 / 1', + border: `1px solid ${colorVars.color.border.tertiary}`, + borderRadius: unitVars.unit.radius['300'], + backgroundColor: colorVars.color.bg.primary, +}); + +export const compactSlotFilled = style([ + compactSlot, + { + overflow: 'hidden', + }, +]); + +export const compactSlotContainer = style({ + aspectRatio: '1 / 1', + position: 'relative', + width: '100%', +}); + +export const compactImageWrap = style({ + position: 'relative', + width: '100%', + height: '100%', +}); + +export const compactImage = style({ + display: 'block', + objectFit: 'cover', + width: '100%', + height: '100%', +}); + +export const compactImageFallback = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + color: colorVars.color.text.disabled, +}); + +export const expandedGrid = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: unitVars.unit.gapPadding['200'], + padding: `0 ${unitVars.unit.gapPadding['100']}`, + width: '100%', +}); + +export const addCard = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['200'], + width: '100%', +}); + +export const addImageWrap = style({ + aspectRatio: '1 / 1', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: `1px solid ${colorVars.color.border.tertiary}`, + borderRadius: unitVars.unit.radius['300'], + backgroundColor: colorVars.color.bg.primary, + width: '100%', +}); + +export const addCardSquare = style({ + aspectRatio: '1 / 1', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: `1px solid ${colorVars.color.border.tertiary}`, + borderRadius: unitVars.unit.radius['300'], + backgroundColor: colorVars.color.bg.primary, + width: '100%', +}); + +export const addCardContent = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: unitVars.unit.gapPadding['100'], +}); + +export const addLabel = style({ + margin: 0, + textAlign: 'left', + ...fontVars.font.caption_r_12, + color: colorVars.color.text.disabled, +}); + +export const selectedCard = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', +}); + +export const selectedCardContainer = style({ + position: 'relative', + width: '100%', + minWidth: 0, +}); + +export const selectedImage = style({ + display: 'block', + objectFit: 'cover', + width: '100%', + height: '100%', +}); + +export const selectedImageWrap = style({ + aspectRatio: '1 / 1', + position: 'relative', + outline: `1px solid ${colorVars.color.border.tertiary}`, + borderRadius: unitVars.unit.radius['300'], + backgroundColor: colorVars.color.bg.primary, + width: '100%', + overflow: 'hidden', +}); + +export const selectedImageFallback = style({ + display: 'flex', + flex: 1, + alignItems: 'center', + justifyContent: 'center', + width: '100%', + color: colorVars.color.text.disabled, +}); + +export const closeButton = recipe({ + base: { + position: 'absolute', + zIndex: 2, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: unitVars.unit.radius.full, + padding: 0, + }, + variants: { + layout: { + expanded: { + top: '-0.4rem', + right: '-0.4rem', + }, + compact: { + top: '-0.25rem', + right: '-0.25rem', + }, + }, + }, +}); + +export const selectedInfoSection = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['050'], + padding: `calc(${unitVars.unit.gapPadding['200']} + ${unitVars.unit.gapPadding['050']}) ${unitVars.unit.gapPadding['050']}`, + width: '100%', + minWidth: 0, + minHeight: '7.2rem', +}); + +export const selectedTitle = style({ + overflow: 'hidden', + wordBreak: 'break-all', + color: colorVars.color.text.primary, + ...fontVars.font.caption_r_12, +}); + +export const addInfoPlaceholder = style({ + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'flex-start', + padding: `0 ${unitVars.unit.gapPadding['100']} ${unitVars.unit.gapPadding['100']}`, + width: '100%', + minHeight: '7.2rem', +}); + +export const selectedPriceRow = style({ + display: 'flex', + alignItems: 'center', + gap: '0.1rem', +}); + +export const selectedDiscountRate = style({ + ...fontVars.font.body_m_13, + color: colorVars.color.text.brand, +}); + +export const selectedPrice = style({ + ...fontVars.font.body_m_13, + color: colorVars.color.text.primary, +}); diff --git a/src/pages/home/components/product/SelectedProductSheet/SelectedProductSheet.tsx b/src/pages/home/components/product/SelectedProductSheet/SelectedProductSheet.tsx new file mode 100644 index 000000000..90461df49 --- /dev/null +++ b/src/pages/home/components/product/SelectedProductSheet/SelectedProductSheet.tsx @@ -0,0 +1,156 @@ +import { useCallback } from 'react'; + +import IconButton from '@shared/components/v2/button/IconButton'; +import Icon from '@shared/components/v2/icon/Icon'; + +import * as styles from './SelectedProductSheet.css'; + +interface SelectedProductItem { + id: string; + title: string; + imageUrl?: string; + originalPrice: number; + discountPrice: number; + discountRate: number; +} + +interface SelectedProductSheetProps { + expanded: boolean; + selectedProducts: SelectedProductItem[]; + onRemoveProduct: (productId: string) => void; + maxCount?: number; +} + +const SelectedProductSheet = ({ + expanded, + selectedProducts, + onRemoveProduct, + maxCount = 6, +}: SelectedProductSheetProps) => { + const selectedCount = selectedProducts.length; + const hasSelectedProduct = selectedCount > 0; + const emptyCount = Math.max(maxCount - selectedCount, 0); + const visibleProducts = selectedProducts.slice(0, maxCount); + const formatPrice = (price: number) => price.toLocaleString('ko-KR'); + + const handleRemoveProductClick = useCallback( + (productId: string) => { + onRemoveProduct(productId); + }, + [onRemoveProduct] + ); + + return ( +
+
+ +

선택한 상품

+ + ({selectedCount}/ + {maxCount}) + +
+ + {expanded ? ( +
+ {visibleProducts.map((product) => ( +
+
+
+ {product.imageUrl ? ( + {product.title} + ) : ( +
+ +
+ )} +
+
+

{product.title}

+
+ {product.discountRate > 0 && ( + + {product.discountRate}% + + )} + + {formatPrice(product.discountPrice)} + +
+
+
+ handleRemoveProductClick(product.id)} + /> +
+ ))} + {Array.from({ length: emptyCount }).map((_, index) => ( +
+ {hasSelectedProduct ? ( + <> +
+ + + +
+
+

상품 추가하기

+
+ + ) : ( +
+
+ +

상품 추가하기

+
+
+ )} +
+ ))} +
+ ) : ( +
+ {visibleProducts.map((product) => ( +
+
+
+ {product.imageUrl ? ( + {product.title} + ) : ( +
+ +
+ )} +
+
+ handleRemoveProductClick(product.id)} + /> +
+ ))} + {Array.from({ length: emptyCount }).map((_, index) => ( +
+ ))} +
+ )} +
+ ); +}; + +export default SelectedProductSheet; diff --git a/src/pages/home/hooks/useProductStickyHeader.ts b/src/pages/home/hooks/useProductStickyHeader.ts new file mode 100644 index 000000000..b817394c5 --- /dev/null +++ b/src/pages/home/hooks/useProductStickyHeader.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseProductStickyHeaderParams { + searchBarRef: React.RefObject; + filterListRef: React.RefObject; +} + +export const useProductStickyHeader = ({ + searchBarRef, + filterListRef, +}: UseProductStickyHeaderParams) => { + const lastScrollYRef = useRef(0); + const isFilterStickyRef = useRef(false); + const [isFilterSticky, setIsFilterSticky] = useState(false); + const [showStickySearchBar, setShowStickySearchBar] = useState(false); + + useEffect(() => { + // handleScroll: 스크롤 위치/방향에 따라 sticky 상태와 검색바 노출 상태 갱신 + const handleScroll = () => { + if (!searchBarRef.current || !filterListRef.current) return; + + const currentY = window.scrollY || window.pageYOffset; + const isScrollUp = currentY < lastScrollYRef.current; + lastScrollYRef.current = currentY; + + const filterTop = filterListRef.current.getBoundingClientRect().top; + const searchBarTop = searchBarRef.current.getBoundingClientRect().top; + + // 필터칩 행 상단이 viewport 상단에 닿으면 sticky 시작 + if (!isFilterStickyRef.current && filterTop <= 0) { + isFilterStickyRef.current = true; + setIsFilterSticky(true); + } + + if (isFilterStickyRef.current) { + // 스크롤 업 중 원래 검색바 상단이 다시 화면 안으로 들어오면 sticky 해제 + if (isScrollUp && searchBarTop >= 0) { + isFilterStickyRef.current = false; + setIsFilterSticky(false); + setShowStickySearchBar(false); + return; + } + // sticky 상태에서는 스크롤 업일 때만 검색바 노출 + setShowStickySearchBar(isScrollUp); + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + // 최초 마운트 시 현재 스크롤 위치를 반영해 상태 동기화 + handleScroll(); + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [filterListRef, searchBarRef]); + + return { + isFilterSticky, + showStickySearchBar, + }; +}; diff --git a/src/pages/home/hooks/useProductTabState.ts b/src/pages/home/hooks/useProductTabState.ts new file mode 100644 index 000000000..3f910f813 --- /dev/null +++ b/src/pages/home/hooks/useProductTabState.ts @@ -0,0 +1,349 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +import type { + ProductFilterValues, + ProductFilterSheetRef, +} from '@pages/home/components/product/ProductFilterSheet/ProductFilterSheet'; +import type { + AppliedFilterChip, + ProductFilterChipCategory, + SelectedProduct, +} from '@pages/home/components/product/SearchSection/SearchSection'; + +import { useToast } from '@components/toast/useToast'; + +const INITIAL_CHIP_SELECTED: Record = { + furniture: false, + price: false, + color: false, +}; + +const ALL = 'ALL'; + +const FURNITURE_OPTION_ORDER: string[] = [ + ALL, + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', +]; + +const PRICE_OPTION_ORDER: string[] = [ + ALL, + 'P1', + 'P2', + 'P3', + 'P4', + 'P5', + 'P6', + 'P7', +]; + +const COLOR_OPTION_ORDER: string[] = [ + ALL, + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', +]; + +const FURNITURE_LABELS: Record = { + '1': '침대/프레임', + '2': '업무용 책상', + '3': '식탁', + '4': '좌식 테이블', + '5': '옷장', + '6': '수납/장식장', + '7': '소파', + '8': '의자/스툴', + '9': '화장대/협탁', + '10': '조명', + '11': '그 외', +}; + +const PRICE_LABELS: Record = { + P1: '5만원 이하', + P2: '5-10만원', + P3: '10만원대', + P4: '20만원대', + P5: '30만원대', + P6: '40만원대', + P7: '50만원 이상', +}; + +const COLOR_LABELS: Record = { + '1': '블랙', + '2': '화이트', + '3': '그레이', + '4': '베이지', + '5': '실버', + '6': '골드', + '7': '브라운', + '8': '레드', + '9': '오렌지', + '10': '옐로우', + '11': '그린', + '12': '블루', + '13': '네이비', + '14': '바이올렛', + '15': '핑크', +}; + +const INITIAL_FILTER_VALUES: ProductFilterValues = { + furnitureTypeIds: [ALL], + priceRangeIds: [ALL], + colorIds: [ALL], +}; + +// 바텀시트에 담을 수 있는 최대 선택 상품 수 +export const MAX_SELECTED_PRODUCTS = 6; + +// 선택값 배열에서 "대표 라벨 + 외 N개" 형태의 필터 라벨 생성 +const buildSummaryLabel = ( + ids: string[], + labels: Record, + orderedOptionIds: string[] +): string | null => { + const selected = ids.filter((id) => id !== ALL); + if (selected.length === 0) return null; + + const selectedSet = new Set(selected); + let firstId: string | undefined; + for (const id of orderedOptionIds) { + if (id !== ALL && selectedSet.has(id)) { + firstId = id; + break; + } + } + if (firstId === undefined) { + firstId = selected[0]; + } + + const first = labels[firstId] ?? firstId; + return selected.length === 1 ? first : `${first} 외 ${selected.length - 1}개`; +}; + +// 현재 선택 상태를 SearchSection 상단 칩(카테고리/가격대/색상) 데이터로 변환 +const buildAppliedFilterChips = ( + values: ProductFilterValues +): AppliedFilterChip[] => { + const furnitureLabel = buildSummaryLabel( + values.furnitureTypeIds, + FURNITURE_LABELS, + FURNITURE_OPTION_ORDER + ); + const priceLabel = buildSummaryLabel( + values.priceRangeIds, + PRICE_LABELS, + PRICE_OPTION_ORDER + ); + const colorLabel = buildSummaryLabel( + values.colorIds, + COLOR_LABELS, + COLOR_OPTION_ORDER + ); + + return [ + { + category: 'furniture', + id: furnitureLabel ? 'furniture-summary' : 'furniture-placeholder', + label: furnitureLabel ?? '카테고리', + applied: furnitureLabel !== null, + }, + { + category: 'price', + id: priceLabel ? 'price-summary' : 'price-placeholder', + label: priceLabel ?? '가격대', + applied: priceLabel !== null, + }, + { + category: 'color', + id: colorLabel ? 'color-summary' : 'color-placeholder', + label: colorLabel ?? '색상', + applied: colorLabel !== null, + }, + ]; +}; + +export const useProductTabState = () => { + const [sheetExpanded, setSheetExpanded] = useState(false); + const [filterSheetOpen, setFilterSheetOpen] = useState(false); + const [chipSelected, setChipSelected] = useState< + Record + >(INITIAL_CHIP_SELECTED); + const [appliedFilterValues, setAppliedFilterValues] = + useState(INITIAL_FILTER_VALUES); + const [appliedFilterChips, setAppliedFilterChips] = useState< + AppliedFilterChip[] + >(() => buildAppliedFilterChips(INITIAL_FILTER_VALUES)); + const [selectedProducts, setSelectedProducts] = useState( + [] + ); + const productFilterSheetRef = useRef(null); + const { notify } = useToast(); + + const syncFilterSheetValues = useCallback(() => { + productFilterSheetRef.current?.setValues({ + furnitureTypeIds: [...appliedFilterValues.furnitureTypeIds], + priceRangeIds: [...appliedFilterValues.priceRangeIds], + colorIds: [...appliedFilterValues.colorIds], + }); + }, [appliedFilterValues]); + + // 필터 시트가 다시 열릴 때 마지막 적용값으로 내부 선택 상태 복원 + useLayoutEffect(() => { + if (!filterSheetOpen) return; + syncFilterSheetValues(); + }, [filterSheetOpen, syncFilterSheetValues]); + + // handleFilterChipClick: 상단 필터 칩 클릭 시 활성 카테고리 전환 및 필터 시트 열림/닫힘 처리 + const handleFilterChipClick = useCallback( + (category: ProductFilterChipCategory) => { + setChipSelected((prev) => { + // 이미 선택된 칩을 다시 누르면 시트를 닫고 선택 상태 초기화 + if (prev[category]) { + queueMicrotask(() => { + setFilterSheetOpen(false); + }); + return { ...INITIAL_CHIP_SELECTED }; + } + + queueMicrotask(() => { + setFilterSheetOpen(true); + queueMicrotask(() => { + syncFilterSheetValues(); + }); + }); + // 하나의 카테고리 칩만 활성 상태로 유지 + return { + furniture: category === 'furniture', + price: category === 'price', + color: category === 'color', + }; + }); + }, + [syncFilterSheetValues] + ); + + // handleFilterSheetClose: 필터 시트를 닫고 칩 활성 상태를 초기화 + const handleFilterSheetClose = useCallback(() => { + setFilterSheetOpen(false); + setChipSelected({ ...INITIAL_CHIP_SELECTED }); + }, []); + + // handleFilterApply: 시트의 현재 선택값을 적용값으로 확정하고 상단 요약 칩 갱신 + const handleFilterApply = useCallback(() => { + const values = productFilterSheetRef.current?.getValues(); + if (values) { + const nextValues: ProductFilterValues = { + furnitureTypeIds: [...values.furnitureTypeIds], + priceRangeIds: [...values.priceRangeIds], + colorIds: [...values.colorIds], + }; + + setAppliedFilterValues(nextValues); + setAppliedFilterChips(buildAppliedFilterChips(nextValues)); + } + handleFilterSheetClose(); + }, [handleFilterSheetClose]); + + // handleRemoveAppliedChip: 상단 적용 칩 제거 시 해당 카테고리를 ALL 상태로 복원 + const handleRemoveAppliedChip = useCallback( + (category: ProductFilterChipCategory, _id: string) => { + const normalizedValues: ProductFilterValues = { + furnitureTypeIds: + category === 'furniture' + ? [ALL] + : [...appliedFilterValues.furnitureTypeIds], + priceRangeIds: + category === 'price' ? [ALL] : [...appliedFilterValues.priceRangeIds], + colorIds: + category === 'color' ? [ALL] : [...appliedFilterValues.colorIds], + }; + + setAppliedFilterValues(normalizedValues); + setAppliedFilterChips(buildAppliedFilterChips(normalizedValues)); + if (filterSheetOpen) { + // 시트가 열린 상태에서만 즉시 동기화하고, 닫힌 상태는 오픈 시 effect에 위임 + productFilterSheetRef.current?.setValues(normalizedValues); + } + }, + [appliedFilterValues, filterSheetOpen] + ); + + // handleSelectProduct: 상품 선택 추가(중복 방지, 최대 개수 제한, 초과 시 토스트) + const handleSelectProduct = useCallback( + (product: SelectedProduct) => { + let attemptedOverMax = false; + setSelectedProducts((prev) => { + if (prev.some((item) => item.id === product.id)) return prev; + if (prev.length >= MAX_SELECTED_PRODUCTS) { + attemptedOverMax = true; + return prev; + } + return [...prev, product]; + }); + if (attemptedOverMax) { + notify({ + text: `상품은 최대 ${MAX_SELECTED_PRODUCTS}개까지만 선택할 수 있어요`, + }); + } + }, + [notify] + ); + + // handleRemoveSelectedProduct: 선택된 상품 목록에서 특정 상품 제거 + const handleRemoveSelectedProduct = useCallback((productId: string) => { + setSelectedProducts((prev) => + prev.filter((product) => product.id !== productId) + ); + }, []); + + // handleDecorateWithProductsClick: 하단 CTA 클릭 시 최소 선택 개수(1개) 검증 + const handleDecorateWithProductsClick = useCallback(() => { + if (selectedProducts.length === 0) { + notify({ text: '상품을 1개 이상 선택해주세요' }); + } + }, [notify, selectedProducts.length]); + + // 필터 시트 내부 선택값만 초기화(적용값은 유지) + const handleFilterResetClick = useCallback(() => { + productFilterSheetRef.current?.reset(); + }, []); + + return { + sheetExpanded, + setSheetExpanded, + filterSheetOpen, + chipSelected, + appliedFilterChips, + selectedProducts, + productFilterSheetRef, + handleFilterChipClick, + handleRemoveAppliedChip, + handleSelectProduct, + handleRemoveSelectedProduct, + handleDecorateWithProductsClick, + handleFilterSheetClose, + handleFilterApply, + handleFilterResetClick, + }; +}; diff --git a/src/pages/home/mocks/productMainApiMock.ts b/src/pages/home/mocks/productMainApiMock.ts new file mode 100644 index 000000000..3c302d6df --- /dev/null +++ b/src/pages/home/mocks/productMainApiMock.ts @@ -0,0 +1,475 @@ +/** + * 상품 탭 메인 API 쿼리 파라미터와 동일한 형태 + * - 추후 실제 API 연동 시 이 타입을 그대로 요청 파라미터 타입으로 재사용할 수 있다. + */ +interface ProductMainQueryParams { + keyword?: string; + types?: number[]; + minPrice?: number; + maxPrice?: number; + colors?: number[]; + cursor?: number; + size?: number; +} + +/** + * 서버 응답의 meta.appliedFilters 아이템 + * category가 color일 때만 value(hex code)를 포함한다. + */ +interface AppliedFilter { + category: 'type' | 'price' | 'color'; + id: string; + label: string; + value?: string; +} + +/** + * 서버의 data.products 단일 아이템 스키마 + * 실제 API 문서 필드명을 유지해 매핑 비용을 줄인다. + */ +interface ProductMainItem { + id: number; + productId: number; + categoryName: string; + source: string; + brand: string | null; + name: string; + imageUrl: string; + originalPrice: number; + discountRate: number; + finalPrice: number; + mallName: string | null; + linkUrl: string; + colorHexes: string[]; +} + +/** + * 상품 탭 조회/검색 공용 응답 스키마 + * message 대신 msg를 사용하는 현재 백엔드 응답 규격을 맞춘다. + */ +interface ProductMainResponse { + code: number; + msg: string; + data: { + products: ProductMainItem[]; + meta: { + nextCursor: number | null; + hasNext: boolean; + appliedFilters: AppliedFilter[]; + }; + }; +} + +/** + * 목데이터 내부 전용 필드 + * - typeId/colorIds는 서버 응답에는 없고 필터링 계산을 위해서만 사용한다. + */ +interface InternalProduct extends ProductMainItem { + typeId: number; + colorIds: number[]; +} + +export interface SearchSectionProductCardItem { + id: string; + title: string; + brand: string; + imageUrl: string; + discountRate: number; + originalPrice: number; + discountPrice: number; + colorHexes: string[]; + saveCount: number; + linkUrl: string; +} + +// 필터 칩 노출용 typeId -> 라벨 매핑 +const TYPE_LABELS: Record = { + 1: '침대/프레임', + 2: '의자/스툴', + 3: '수납/장식장', + 4: '소파', + 5: '조명', +}; + +// 색상 필터 및 카드 컬러칩 표시에 공통으로 사용하는 메타 +const COLOR_META: Record = { + 1: { label: '화이트', hex: '#FFFFFF' }, + 2: { label: '그레이', hex: '#8E959E' }, + 3: { label: '블랙', hex: '#1B1E22' }, + 4: { label: '베이지', hex: '#D4C4B0' }, + 5: { label: '브라운', hex: '#5C4033' }, +}; + +/** + * 목 상품 원본 풀 + * - id는 내림차순 정렬 기준이므로 큰 값이 최신으로 간주된다. + * - typeId/colorIds는 필터 계산용 내부 속성이다. + * - imageUrl은 기존 SearchSection에서 사용하던 목 이미지들을 재사용한다. + */ +const PRODUCTS: Omit[] = [ + { + id: 630, + productId: 370, + categoryName: '수납/장식장', + source: 'soozip', + brand: '자니즈', + name: '심플리 수납장 16color', + imageUrl: + 'https://i.pinimg.com/736x/a9/62/30/a9623026cd4d93af383b4c5f59d5a86a.jpg', + originalPrice: 2090000, + discountRate: 0, + finalPrice: 2090000, + mallName: 'SOOZIP', + linkUrl: + 'https://soozip.co.kr/product/%EC%8B%AC%ED%94%8C%EB%A6%AC-%EC%88%98%EB%82%A9%EC%9E%A5-16color/370/category/75/display/1/', + typeId: 3, + colorIds: [1, 4, 5], + }, + { + id: 629, + productId: 3003, + categoryName: '침대/프레임', + source: 'naver', + brand: '모던하우스', + name: '내추럴 원목 퀸 침대 프레임', + imageUrl: + 'https://i.pinimg.com/1200x/65/cf/44/65cf44b71f3d3092b68fb034ad24fb90.jpg', + originalPrice: 120000, + discountRate: 20, + finalPrice: 96000, + mallName: '오늘의집', + linkUrl: 'https://ohou.se/productions/3003', + typeId: 1, + colorIds: [1, 5], + }, + { + id: 628, + productId: 4210, + categoryName: '침대/프레임', + source: 'naver', + brand: '이케아', + name: 'MALM 높은 침대프레임 블랙브라운', + imageUrl: + 'https://i.pinimg.com/1200x/85/ac/e7/85ace7a04cbb367063e97cba14839bd4.jpg', + originalPrice: 249000, + discountRate: 10, + finalPrice: 224100, + mallName: 'IKEA 공식몰', + linkUrl: 'https://www.ikea.com/kr/ko/p/4210', + typeId: 1, + colorIds: [3, 5], + }, + { + id: 627, + productId: 5210, + categoryName: '의자/스툴', + source: 'soozip', + brand: 'TABLE LAB', + name: '모던 체어', + imageUrl: + 'https://i.pinimg.com/1200x/a5/d9/b7/a5d9b7fcd0dd6f8bf645194ac96e1f5b.jpg', + originalPrice: 340000, + discountRate: 15, + finalPrice: 289000, + mallName: 'SOOZIP', + linkUrl: 'https://soozip.co.kr/product/chair/5210', + typeId: 2, + colorIds: [4, 5], + }, + { + id: 626, + productId: 9301, + categoryName: '소파', + source: 'naver', + brand: 'LIVING STUDIO', + name: '코지 패브릭 2인 소파', + imageUrl: + 'https://i.pinimg.com/1200x/39/a3/5e/39a35eb09726363b73c7972ac91b61e7.jpg', + originalPrice: 624000, + discountRate: 20, + finalPrice: 499000, + mallName: '오늘의집', + linkUrl: 'https://ohou.se/productions/9301', + typeId: 4, + colorIds: [1, 4], + }, + { + id: 625, + productId: 1112, + categoryName: '조명', + source: 'naver', + brand: 'LIGHTER', + name: '무드 스탠드 조명', + imageUrl: + 'https://i.pinimg.com/1200x/02/88/5a/02885ae1b6ccc1ae06521fbd3982892b.jpg', + originalPrice: 94000, + discountRate: 5, + finalPrice: 89000, + mallName: '네이버쇼핑', + linkUrl: 'https://shopping.naver.com/products/1112', + typeId: 5, + colorIds: [3, 4], + }, + { + id: 624, + productId: 7771, + categoryName: '조명', + source: 'naver', + brand: 'LIGHTER', + name: '골드 메탈 펜던트 조명', + imageUrl: + 'https://i.pinimg.com/736x/d9/4b/93/d94b93371e360e14a8e693749c4408a8.jpg', + originalPrice: 127000, + discountRate: 22, + finalPrice: 99000, + mallName: '네이버쇼핑', + linkUrl: 'https://shopping.naver.com/products/7771', + typeId: 5, + colorIds: [2, 3], + }, + { + id: 623, + productId: 7772, + categoryName: '수납/장식장', + source: 'soozip', + brand: 'RUGROOM', + name: '우드 수납 라탄 바스켓 세트', + imageUrl: + 'https://i.pinimg.com/736x/36/bb/a7/36bba7025c4907223e8bd47bf25acd8d.jpg', + originalPrice: 99000, + discountRate: 40, + finalPrice: 59000, + mallName: 'SOOZIP', + linkUrl: 'https://soozip.co.kr/product/storage/7772', + typeId: 3, + colorIds: [4, 2], + }, + { + id: 622, + productId: 7773, + categoryName: '수납/장식장', + source: 'naver', + brand: 'WALLSET', + name: '월 선반 세트', + imageUrl: + 'https://i.pinimg.com/736x/29/13/51/291351b55807526727e53a4a3d0453a2.jpg', + originalPrice: 47000, + discountRate: 18, + finalPrice: 39000, + mallName: '네이버쇼핑', + linkUrl: 'https://shopping.naver.com/products/7773', + typeId: 3, + colorIds: [1, 5], + }, + { + id: 621, + productId: 7774, + categoryName: '침대/프레임', + source: 'naver', + brand: 'BEDDING LAB', + name: '코튼 침구 세트', + imageUrl: + 'https://i.pinimg.com/1200x/cb/7a/1d/cb7a1d203af17e14752165d50244a20e.jpg', + originalPrice: 90000, + discountRate: 12, + finalPrice: 79000, + mallName: '오늘의집', + linkUrl: 'https://ohou.se/productions/7774', + typeId: 1, + colorIds: [1, 4], + }, + { + id: 620, + productId: 7775, + categoryName: '수납/장식장', + source: 'soozip', + brand: 'BASKETRY', + name: '핸드메이드 라탄 바스켓', + imageUrl: + 'https://i.pinimg.com/736x/d5/ed/d2/d5edd2d6b6a7955f91e41b9433d9852d.jpg', + originalPrice: 68000, + discountRate: 28, + finalPrice: 49000, + mallName: 'SOOZIP', + linkUrl: 'https://soozip.co.kr/product/storage/7775', + typeId: 3, + colorIds: [4, 5], + }, + { + id: 619, + productId: 7776, + categoryName: '의자/스툴', + source: 'naver', + brand: 'MODERN CASA', + name: '미니멀 오피스 체어', + imageUrl: + 'https://i.pinimg.com/736x/da/2c/ae/da2cae9be8e292baa2dc1243f2f84775.jpg', + originalPrice: 245000, + discountRate: 35, + finalPrice: 159000, + mallName: '네이버쇼핑', + linkUrl: 'https://shopping.naver.com/products/7776', + typeId: 2, + colorIds: [2, 3], + }, +]; + +const DEFAULT_SIZE = 20; + +/** + * 가격 필터 메타 생성기 + * - min/max 모두 있으면 "X원 - Y원" + * - min만 있으면 "X원 이상" + * - 미지정이면 appliedFilters에 price를 추가하지 않음 + */ +const buildPriceFilter = ( + minPrice: number | undefined, + maxPrice: number | undefined +): AppliedFilter[] => { + const hasMin = typeof minPrice === 'number' && minPrice > 0; + const hasMax = typeof maxPrice === 'number' && Number.isFinite(maxPrice); + if (!hasMin && !hasMax) return []; + + const normalizedMin = hasMin ? minPrice : 0; + const normalizedMax = hasMax ? maxPrice : null; + const label = + normalizedMax === null + ? `${normalizedMin.toLocaleString()}원 이상` + : `${normalizedMin.toLocaleString()}원 - ${normalizedMax.toLocaleString()}원`; + const id = + normalizedMax === null + ? `P_MIN_${normalizedMin}` + : `P_${normalizedMin}_${normalizedMax}`; + + return [{ category: 'price', id, label }]; +}; + +// ProductCard 컬러칩 렌더링용 hex 배열 변환 +const getColorHexes = (colorIds: number[]): string[] => + colorIds + .map((colorId) => COLOR_META[colorId]?.hex) + .filter((hex): hex is string => typeof hex === 'string'); + +export const getMockProductMainResponse = ( + params: ProductMainQueryParams = {} +): ProductMainResponse => { + const keyword = params.keyword?.trim().toLowerCase() ?? ''; + const types = params.types ?? []; + const colors = params.colors ?? []; + const size = params.size ?? DEFAULT_SIZE; + const minPrice = params.minPrice; + const maxPrice = params.maxPrice; + + /** + * 실제 API와 유사하게 다중 조건을 AND로 누적 적용 + * - keyword: 상품명/브랜드 부분 일치 + * - types: typeId 포함 + * - price: finalPrice 기준 범위 + * - colors: 하나라도 겹치면 통과(OR) + */ + const filtered = PRODUCTS.filter((product) => { + if (keyword.length > 0) { + const searchable = `${product.name} ${product.brand ?? ''}`.toLowerCase(); + if (!searchable.includes(keyword)) return false; + } + if (types.length > 0 && !types.includes(product.typeId)) return false; + if ( + typeof minPrice === 'number' && + Number.isFinite(minPrice) && + product.finalPrice < minPrice + ) { + return false; + } + if ( + typeof maxPrice === 'number' && + Number.isFinite(maxPrice) && + product.finalPrice > maxPrice + ) { + return false; + } + if ( + colors.length > 0 && + !product.colorIds.some((colorId) => colors.includes(colorId)) + ) { + return false; + } + return true; + }).sort((a, b) => b.id - a.id); + + const cursor = params.cursor; + const cursorFiltered = + typeof cursor === 'number' + ? filtered.filter((product) => product.id < cursor) + : filtered; + + /** + * 커서 페이지네이션 규칙 + * - cursor가 있으면 "id < cursor" 구간만 조회 + * - hasNext는 현재 커서 구간에서 size 초과 여부로 판단 + * - nextCursor는 현재 페이지 마지막 상품의 id + */ + const page = cursorFiltered.slice(0, size); + const hasNext = cursorFiltered.length > size; + const nextCursor = hasNext ? (page[page.length - 1]?.id ?? null) : null; + + const typeFilters: AppliedFilter[] = types + .map((typeId) => ({ + category: 'type' as const, + id: String(typeId), + label: TYPE_LABELS[typeId] ?? `타입 ${typeId}`, + })) + .filter((filter) => filter.label.length > 0); + + const priceFilter = buildPriceFilter(minPrice, maxPrice); + + const colorFilters: AppliedFilter[] = colors.flatMap((colorId) => { + const color = COLOR_META[colorId]; + if (!color) return []; + return [ + { + category: 'color' as const, + id: String(colorId), + label: color.label, + value: color.hex, + }, + ]; + }); + + return { + code: 200, + msg: '응답 성공', + data: { + products: page.map(({ typeId: _typeId, colorIds, ...rest }) => ({ + ...rest, + colorHexes: getColorHexes(colorIds), + })), + meta: { + nextCursor, + hasNext, + appliedFilters: [...typeFilters, ...priceFilter, ...colorFilters], + }, + }, + }; +}; + +export const toSearchSectionProducts = ( + response: ProductMainResponse +): SearchSectionProductCardItem[] => + /** + * API 응답 스키마 -> SearchSection 카드 뷰 모델 매퍼 + * - id는 selectedProductIds 비교를 위해 string으로 통일 + * - finalPrice -> discountPrice로 전달 + * - colorHexes는 내부 원본(PRODUCTS)에서 역조회해 채운다 + */ + response.data.products.map((product) => ({ + id: String(product.id), + title: product.name, + brand: product.brand ?? '', + imageUrl: product.imageUrl, + discountRate: product.discountRate, + originalPrice: product.originalPrice, + discountPrice: product.finalPrice, + colorHexes: product.colorHexes, + saveCount: 0, + linkUrl: product.linkUrl, + })); diff --git a/src/shared/assets/v2/images/bannerFallback.svg b/src/shared/assets/v2/images/bannerFallback.svg new file mode 100644 index 000000000..eb55d142f --- /dev/null +++ b/src/shared/assets/v2/images/bannerFallback.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/shared/components/v2/bottomSheet/BottomSheetBase.css.ts b/src/shared/components/v2/bottomSheet/BottomSheetBase.css.ts index 5ba94a066..20e0ff5ae 100644 --- a/src/shared/components/v2/bottomSheet/BottomSheetBase.css.ts +++ b/src/shared/components/v2/bottomSheet/BottomSheetBase.css.ts @@ -165,6 +165,13 @@ export const contentSlot = style({ width: '100%', minHeight: 0, overflow: 'auto', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + selectors: { + '&::-webkit-scrollbar': { + display: 'none', + }, + }, }); // 버튼 래퍼 row diff --git a/src/shared/components/v2/bottomSheet/DragHandleBottomSheet.tsx b/src/shared/components/v2/bottomSheet/DragHandleBottomSheet.tsx index 90fb11b74..723ef0961 100644 --- a/src/shared/components/v2/bottomSheet/DragHandleBottomSheet.tsx +++ b/src/shared/components/v2/bottomSheet/DragHandleBottomSheet.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import BottomSheetBase from './BottomSheetBase'; import * as styles from './BottomSheetBase.css'; @@ -9,6 +9,7 @@ interface DragHandleBottomSheetProps { contentSlot: ReactNode; primaryButton: ReactNode; secondaryButton?: ReactNode; + onExpandedChange?: (expanded: boolean) => void; /** 최소 높이 (rem 문자열). 있으면 Persistent(최소높이 존재) 모드, 없으면 Dismissible 모드 */ collapsedHeight?: string; /** Dismissible 모드에서 바텀시트가 사라질 때 부모에게 알리는 콜백 (부모가 open을 false로 바꿈) */ @@ -33,6 +34,7 @@ const DragHandleBottomSheet = ({ contentSlot, primaryButton, secondaryButton, + onExpandedChange, collapsedHeight, onDismiss, }: DragHandleBottomSheetProps) => { @@ -48,6 +50,10 @@ const DragHandleBottomSheet = ({ const collapsedPxRef = useRef(0); const expandedPxRef = useRef(0); + useEffect(() => { + onExpandedChange?.(expanded); + }, [expanded, onExpandedChange]); + const handlePointerDown = useCallback( (e: React.PointerEvent) => { const panel = panelRef.current; diff --git a/src/shared/components/v2/button/actionButton/ActionButton.tsx b/src/shared/components/v2/button/actionButton/ActionButton.tsx index 732fedf9a..ff9d31c65 100644 --- a/src/shared/components/v2/button/actionButton/ActionButton.tsx +++ b/src/shared/components/v2/button/actionButton/ActionButton.tsx @@ -32,6 +32,7 @@ export interface ActionButtonProps leftIcon?: IconName; rightIcon?: IconName; fullWidth?: boolean; + visualDisabled?: boolean; } const ActionButton = ({ @@ -44,10 +45,12 @@ const ActionButton = ({ fullWidth = false, type = 'button', disabled, + visualDisabled = false, className, ...props }: ActionButtonProps) => { - const isDisabled = disabled === true; + const isDomDisabled = disabled === true; + const isVisuallyDisabled = isDomDisabled || visualDisabled; const iconSize = BUTTON_SIZE_TO_ICON_SIZE[size]; return ( @@ -59,11 +62,12 @@ const ActionButton = ({ color, size, fullWidth, - ...(isDisabled ? { disabled: true } : {}), + ...(isVisuallyDisabled ? { disabled: true } : {}), }), className )} - disabled={isDisabled} + disabled={isDomDisabled} + aria-disabled={isDomDisabled || undefined} {...props} > {leftIcon != null ? : null} diff --git a/src/shared/components/v2/menuTab/MenuTab.css.ts b/src/shared/components/v2/menuTab/MenuTab.css.ts index 4722ddefa..5169692c6 100644 --- a/src/shared/components/v2/menuTab/MenuTab.css.ts +++ b/src/shared/components/v2/menuTab/MenuTab.css.ts @@ -6,18 +6,32 @@ import { colorVars } from '@styles/tokensV2/color.css'; import { fontVars } from '@styles/tokensV2/font.css'; import { unitVars } from '@styles/tokensV2/unit.css'; -export const menuTabBar = style({ - position: 'sticky', - zIndex: zIndex.sticky, - top: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-start', - borderBottom: `0.2rem solid ${colorVars.color.border.tertiary}`, - background: colorVars.color.fill.inverse, - padding: `${unitVars.unit.gapPadding['000']} ${unitVars.unit.gapPadding['500']}`, - width: '100%', - height: '4.2rem', +export const menuTabBar = recipe({ + base: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + borderBottom: `0.2rem solid ${colorVars.color.border.tertiary}`, + background: colorVars.color.fill.inverse, + padding: `${unitVars.unit.gapPadding['000']} ${unitVars.unit.gapPadding['500']}`, + width: '100%', + height: '4.2rem', + }, + variants: { + sticky: { + true: { + position: 'sticky', + zIndex: zIndex.sticky, + top: 0, + }, + false: { + position: 'static', + }, + }, + }, + defaultVariants: { + sticky: true, + }, }); export const tabButton = recipe({ diff --git a/src/shared/components/v2/menuTab/MenuTab.tsx b/src/shared/components/v2/menuTab/MenuTab.tsx index bc8d3eb0e..d36f9c453 100644 --- a/src/shared/components/v2/menuTab/MenuTab.tsx +++ b/src/shared/components/v2/menuTab/MenuTab.tsx @@ -9,6 +9,7 @@ interface MenuTabProps { tabs: MenuTabItem[]; activeTab: T; onTabChange: (tab: T) => void; + sticky?: boolean; } const MenuTab = ({ @@ -16,9 +17,10 @@ const MenuTab = ({ tabs, activeTab, onTabChange, + sticky = true, }: MenuTabProps) => { return ( -
+
{tabs.map(({ value, label }) => ( + ) : null} + +
+ ) : ( +
+ {sideIconName ? ( + + ) : null} + + {btnText} + +
+ ); + + return ( +
+
e.stopPropagation()} + > + {showCloseButton ? ( +
+ +
+ ) : null} +
+
+
{content}
+
+
+ {footer} +
+
+ ); +}; + +export default Popup; diff --git a/src/shared/components/v2/productCard/ProductCard.css.ts b/src/shared/components/v2/productCard/ProductCard.css.ts index ae3b195cc..4fbe22343 100644 --- a/src/shared/components/v2/productCard/ProductCard.css.ts +++ b/src/shared/components/v2/productCard/ProductCard.css.ts @@ -136,7 +136,7 @@ export const middleInfoSection = recipe({ cardType: { default: {}, shopping: { - height: '7.2rem', + minHeight: '9.6rem', }, }, }, @@ -159,7 +159,7 @@ export const brandText = style({ export const productText = style({ ...fontVars.font.body_r_14, display: '-webkit-box', - maxHeight: '4.1rem', + minHeight: 'auto', overflow: 'hidden', wordBreak: 'break-all', color: colorVars.color.text.primary, @@ -205,3 +205,97 @@ export const saveCountText = style({ ...fontVars.font.caption_r_11, color: colorVars.color.gray400, }); + +export const popupPreviewCard = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['400'], +}); + +export const popupPreviewImageWrap = style({ + aspectRatio: '1 / 1', + position: 'relative', + border: `1px solid ${colorVars.color.border.tertiary}`, + borderRadius: unitVars.unit.radius['300'], + width: '100%', + overflow: 'hidden', +}); + +export const popupPreviewImage = style({ + display: 'block', + objectFit: 'cover', + width: '100%', + height: '100%', +}); + +export const popupPreviewInfo = style({ + display: 'flex', + flexDirection: 'column', + gap: unitVars.unit.gapPadding['200'], + textAlign: 'left', +}); + +export const popupPreviewMetaRow = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', +}); + +export const popupPreviewLikeRow = style({ + display: 'flex', + flexShrink: 0, + alignItems: 'center', + gap: unitVars.unit.gapPadding['050'], +}); + +export const popupPreviewLikeCount = style({ + ...fontVars.font.caption_r_11, + color: colorVars.color.text.tertiary, +}); + +export const popupPreviewBrand = style({ + ...fontVars.font.caption_r_12, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + color: colorVars.color.text.tertiary, +}); + +export const popupPreviewTitle = style({ + ...fontVars.font.body_r_14, + display: '-webkit-box', + overflow: 'hidden', + wordBreak: 'break-all', + color: colorVars.color.text.primary, + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', +}); + +export const popupPreviewPriceSection = style({ + display: 'flex', + flexDirection: 'column', + paddingTop: unitVars.unit.gapPadding['100'], +}); + +export const popupPreviewOriginalPrice = style({ + ...fontVars.font.caption_r_11, + textDecoration: 'line-through', + color: colorVars.color.text.tertiary, +}); + +export const popupPreviewDiscountRow = style({ + display: 'flex', + alignItems: 'center', + gap: '0.1rem', +}); + +export const popupPreviewDiscountRate = style({ + ...fontVars.font.title_sb_15, + color: colorVars.color.text.brand, +}); + +export const popupPreviewDiscountPrice = style({ + ...fontVars.font.title_sb_15, + color: colorVars.color.text.primary, +}); diff --git a/src/shared/components/v2/productCard/ProductCard.tsx b/src/shared/components/v2/productCard/ProductCard.tsx index 6f68faa42..9367488a2 100644 --- a/src/shared/components/v2/productCard/ProductCard.tsx +++ b/src/shared/components/v2/productCard/ProductCard.tsx @@ -1,4 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +import { overlay } from 'overlay-kit'; import type { LinkInfo, @@ -20,6 +22,7 @@ import * as styles from './ProductCard.css'; import ActionButton from '../button/actionButton/ActionButton'; import IconButton from '../button/IconButton'; import Icon from '../icon/Icon'; +import Popup from '../popup/Popup'; type CardType = 'default' | 'shopping'; @@ -32,6 +35,11 @@ interface ProductCardProps { disabled?: boolean; onCardClick?: (area?: CardClickArea) => void; enableWholeCardLink?: boolean; + shoppingAction?: { + label?: string; + onClick: () => void; + disabled?: boolean; + }; } const ProductCard = ({ @@ -43,6 +51,7 @@ const ProductCard = ({ disabled = false, onCardClick, enableWholeCardLink = false, + shoppingAction, }: ProductCardProps) => { const isDefault = cardType === 'default'; const [isLoaded, setIsLoaded] = useState(false); @@ -62,6 +71,135 @@ const ProductCard = ({ linkHref, }); + const handleShoppingViewDetailClick = useCallback(() => { + overlay.open(({ unmount }) => ( + { + shoppingAction?.onClick(); + unmount(); + }} + showCloseButton + sideIconName={save.isSaved ? 'HeartFillGray' : 'HeartStrokeGray'} + content={ +
+
+ {product.title} + {linkHref ? ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + role="presentation" + > + { + if (link?.onClick) { + link.onClick(); + return; + } + window.open(linkHref, '_blank', 'noopener,noreferrer'); + }} + > + {link?.label || '사이트'} + +
+ ) : null} +
+
+ {visibleColors.length > 0 || + extraColorCount > 0 || + (typeof save.count === 'number' && + Number.isFinite(save.count)) ? ( +
+
+ {visibleColors.map((hex, index) => ( +
+ +
+ ))} + {extraColorCount > 0 ? ( + + +{extraColorCount} + + ) : null} +
+ {typeof save.count === 'number' && + Number.isFinite(save.count) ? ( +
+ + + {save.count.toLocaleString('ko-KR')} + +
+ ) : null} +
+ ) : null} + {!!product.brand && ( +

{product.brand}

+ )} +

{product.title}

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

+ {originalPriceText} +

+ )} +
+ {discountRateText && ( + + {discountRateText} + + )} + {discountPriceText && ( + + {discountPriceText} + + )} +
+
+ )} +
+
+ } + /> + )); + }, [ + discountPriceText, + discountRateText, + extraColorCount, + originalPriceText, + product.brand, + product.imageUrl, + product.title, + link, + linkHref, + save.count, + save.isSaved, + save.onToggle, + shoppingAction, + visibleColors, + ]); + return (
) : ( - + )}
@@ -201,8 +344,15 @@ const ProductCard = ({
) ) : ( - - 선택 + + {shoppingAction?.label ?? '선택'} )} diff --git a/src/shared/components/v2/textField/SearchBar.tsx b/src/shared/components/v2/textField/SearchBar.tsx index 49fe82019..d20d924a1 100644 --- a/src/shared/components/v2/textField/SearchBar.tsx +++ b/src/shared/components/v2/textField/SearchBar.tsx @@ -14,7 +14,7 @@ interface SearchBarProps const SearchBar = ({ value: controlledValue, onChange: onControlledChange, - placeholder = '가구 유형, 브랜드, 키워드로 상품을 검색하세요.', + placeholder = '카테고리, 브랜드, 키워드로 상품을 검색하세요.', id, ...props }: SearchBarProps) => {