diff --git a/backend/src/main/java/autoever2/cartag/repository/ColorRepository.java b/backend/src/main/java/autoever2/cartag/repository/ColorRepository.java index 5be2cda..fd1fc0f 100644 --- a/backend/src/main/java/autoever2/cartag/repository/ColorRepository.java +++ b/backend/src/main/java/autoever2/cartag/repository/ColorRepository.java @@ -44,7 +44,8 @@ public List findOuterColorCarByCarId(int carId) { } public Optional findOuterColorImagesByColorId(int colorId){ - String sql = "select color_car_image from ColorCarMapper where color_id = :colorId"; + String sql = "select color_car_image from ColorCarMapper cm inner join Color as c " + + "on cm.color_id = c.color_id where c.color_id = :colorId and c.is_outer_color = 1"; try { SqlParameterSource param = new MapSqlParameterSource() .addValue("colorId", colorId); diff --git a/backend/src/main/java/autoever2/cartag/service/ModelService.java b/backend/src/main/java/autoever2/cartag/service/ModelService.java index 5c6734c..11ced5e 100644 --- a/backend/src/main/java/autoever2/cartag/service/ModelService.java +++ b/backend/src/main/java/autoever2/cartag/service/ModelService.java @@ -20,6 +20,9 @@ public class ModelService { public List getModelTypeData(int carId) { List modelData = modelRepository.findAllModelTypeData(carId); + if (modelData.isEmpty()) { + throw new EmptyDataException(ErrorCode.RESOURCE_NOT_FOUND); + } Long carBoughtCount = carRepository.findCarBoughtCountByCarId(carId).orElse(0L); List powerTrainData = modelData.stream().filter(modelShortMappedDto -> modelShortMappedDto.getModelTypeId() == 1).collect(Collectors.toList()); diff --git a/backend/src/test/java/autoever2/cartag/controller/TrimControllerTest.java b/backend/src/test/java/autoever2/cartag/controller/TrimControllerTest.java index 69b166e..073c71d 100644 --- a/backend/src/test/java/autoever2/cartag/controller/TrimControllerTest.java +++ b/backend/src/test/java/autoever2/cartag/controller/TrimControllerTest.java @@ -111,7 +111,6 @@ void getTrimList() throws Exception { //when ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/cars/types").param("cartype", String.valueOf(carType))); - System.out.println("jsonPath(\"$[3].options[0]\") = " + jsonPath("$[3].options[0]")); //then resultActions.andExpect(status().isOk()) .andExpect(jsonPath("$[0].trim").value("Le Blanc")) diff --git a/backend/src/test/java/autoever2/cartag/integration/ColorTest.java b/backend/src/test/java/autoever2/cartag/integration/ColorTest.java new file mode 100644 index 0000000..4170cff --- /dev/null +++ b/backend/src/test/java/autoever2/cartag/integration/ColorTest.java @@ -0,0 +1,61 @@ +package autoever2.cartag.integration; + +import autoever2.cartag.controller.ColorController; +import autoever2.cartag.domain.color.InnerColorPercentDto; +import autoever2.cartag.domain.color.OuterColorPercentDto; +import autoever2.cartag.exception.EmptyDataException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +@Sql(scripts = {"classpath:/insert/insertColor-h2.sql"}) +public class ColorTest { + + @Autowired + ColorController controller; + + @Test + @DisplayName("선택한 car_id에 따라 외장 색상 리스트를 반환합니다.") + void getOuterColorList() { + List outerColors = controller.carOuterColorInfo(1); + assertEquals("천연 퀄팅(블랙)", outerColors.get(0).getColorName()); + assertEquals("image_2", outerColors.get(1).getColorImage()); + assertThrows(EmptyDataException.class, () -> { + controller.carOuterColorInfo(2); + }); + } + + @Test + @DisplayName("선택한 car_id에 따라 내장 색상 리스트를 반환합니다.") + void getInnerColorList() { + List innerColors = controller.carInnerColorInfo(1); + assertEquals("퍼플 그레이 펄", innerColors.get(0).getColorName()); + assertEquals("image_3", innerColors.get(0).getColorImage()); + assertThrows(EmptyDataException.class, () -> { + controller.carInnerColorInfo(2); + }); + } + + @Test + @DisplayName("선택 차량의 외장색상 이미지 리스트를 반환합니다") + void getOuterColorImageList() { + List images = controller.carOuterColorImageInfo(1); + assertEquals(60, images.size()); + assertEquals("red_image_30.jpg", images.get(29)); + assertThrows(EmptyDataException.class, () -> { + controller.carOuterColorImageInfo(3); + }); + } +} diff --git a/backend/src/test/java/autoever2/cartag/integration/ModelTest.java b/backend/src/test/java/autoever2/cartag/integration/ModelTest.java new file mode 100644 index 0000000..9eccd3e --- /dev/null +++ b/backend/src/test/java/autoever2/cartag/integration/ModelTest.java @@ -0,0 +1,68 @@ +package autoever2.cartag.integration; + +import autoever2.cartag.controller.ModelController; +import autoever2.cartag.domain.model.ModelDetailMappedDto; +import autoever2.cartag.domain.model.ModelEfficiencyDataDto; +import autoever2.cartag.domain.model.ModelShortDataDto; +import autoever2.cartag.domain.model.PowerTrainDataDto; +import autoever2.cartag.exception.EmptyDataException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Sql({"classpath:insert/insert-model-h2.sql"}) +public class ModelTest { + + @Autowired + ModelController controller; + + @Test + @DisplayName("차량 모델 페이지에서 하단의 리스트(파워트레인 등)를 반환하는 api 테스트") + void getModelList() { + List trimModels = controller.getTrimModelType(1); + assertEquals("디젤2.2", trimModels.get(0).getModelName()); + assertEquals(0, trimModels.get(1).getModelPrice()); + assertEquals("구동방식", trimModels.get(2).getModelTypeName()); + assertThrows(EmptyDataException.class, () -> { + controller.getTrimModelType(9); + }); + + PowerTrainDataDto hmgData = trimModels.get(0).getHmgData(); + assertEquals("45.0/1750~2750", hmgData.getMaxKgfm()); + assertEquals(1.0, hmgData.getRatioKgfm()); + } + + @Test + @DisplayName("모델명과 설명, 이미지 반환하는 api 테스트") + void getModelDescriptionAndImage() { + ModelDetailMappedDto modelDetail = controller.getModelDetail(1); + assertEquals("디젤2.2", modelDetail.getModelName()); + assertEquals("/model/diesel2-2.jpg", modelDetail.getModelImage()); + assertEquals("파워트레인", modelDetail.getModelTypeName()); + assertEquals("높은 토크로 파워풀한 드라이빙이 가능하며, 차급대비 연비 효율이 우수합니다", modelDetail.getOptionDescription()); + } + + @Test + @DisplayName("파워트레인과 구동방식의 조합으로 나온 효츌 HMG값 반환") + void getHmgData() { + assertThrows(EmptyDataException.class, () -> { + controller.getPowerTrainData(1, 1); + }); + + ModelEfficiencyDataDto powerTrainData = controller.getPowerTrainData(1, 3); + assertEquals("2,199cc", powerTrainData.getDisplacement()); + assertEquals("12.16km/s", powerTrainData.getAverageFuel()); + } +} diff --git a/backend/src/test/java/autoever2/cartag/integration/OptionTest.java b/backend/src/test/java/autoever2/cartag/integration/OptionTest.java new file mode 100644 index 0000000..a11fd1b --- /dev/null +++ b/backend/src/test/java/autoever2/cartag/integration/OptionTest.java @@ -0,0 +1,80 @@ +package autoever2.cartag.integration; + +import autoever2.cartag.controller.OptionController; +import autoever2.cartag.domain.option.DefaultOptionDto; +import autoever2.cartag.domain.option.OptionDetailDto; +import autoever2.cartag.domain.option.OptionHmgDataVo; +import autoever2.cartag.domain.option.SubOptionDto; +import autoever2.cartag.exception.EmptyDataException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +@Sql({"classpath:insert/insert-suboption-h2.sql"}) +public class OptionTest { + @Autowired + OptionController controller; + + @Test + @DisplayName("추가 옵션 데이터와 선택 비율(%) 및 HMG 데이터 존재 여부 List 제공 테스트") + void getOptionDataAndHmgDataList() { + List subOptionList = controller.getSubOptionList(1); + assertEquals(6, subOptionList.size()); + assertEquals("2열 통풍 시트", subOptionList.get(0).getOptionName()); + assertEquals("/images/options/sub/2seats.jpg", subOptionList.get(0).getOptionImage()); + assertEquals(100000, subOptionList.get(0).getOptionPrice()); + assertEquals("상세품목", subOptionList.get(0).getOptionCategoryName()); + } + + @Test + @DisplayName("추가옵션 데이터 상세정보 및 이미지, HMG가 존재한다면(비어있다면 비어있는 부분을 Null) 태스트") + void getOptionDetail() { + OptionDetailDto subOptionDetail = controller.getSubOptionDetail(1, 1); + assertEquals("2열 통풍 시트", subOptionDetail.getOptionName()); + assertEquals("시동이 걸린 상태에서 해당 좌석의 통풍 스위치를 누르면 표시등이 켜지면서 해당 좌석에 바람이 나오는 편의장치입니다.", subOptionDetail.getOptionDescription()); + + OptionDetailDto subOptionDto = controller.getSubOptionDetail(1, 69); + assertEquals("컴포트2", subOptionDto.getOptionName()); + + List subOptionList = subOptionDto.getSubOptionList(); + assertEquals(2, subOptionList.size()); + } + + @Test + @DisplayName("기본옵션 데이터 상세정보 및 이미지, HMG가 존재한다면(비어있다면 비어있는 부분을 Null) 보냄 테스트") + void getDataAndImage() { + OptionDetailDto defaultOptionDetail = controller.getDefaultOptionDetail(1, 1); + assertEquals("상세품목", defaultOptionDetail.getCategoryName()); + assertEquals("2열 통풍 시트", defaultOptionDetail.getOptionName()); + assertEquals("시동이 걸린 상태에서 해당 좌석의 통풍 스위치를 누르면 표시등이 켜지면서 해당 좌석에 바람이 나오는 편의장치입니다.", defaultOptionDetail.getOptionDescription()); + assertFalse(defaultOptionDetail.isPackage()); + assertThrows(EmptyDataException.class, () -> { + controller.getDefaultOptionDetail(2, 1); + }); + + OptionHmgDataVo hmgData = defaultOptionDetail.getHmgData(); + assertEquals(38.0, hmgData.getOptionUsedCount()); + } + + @Test + @DisplayName("기본 옵션 데이터와 및 HMG 데이터 존재 여부 List 제공 테스트") + void getOptionAndData(){ + List defaultOptionList = controller.getDefaultOptionList(1); + assertEquals(3, defaultOptionList.size()); + + DefaultOptionDto defaultOptionDto = defaultOptionList.get(1); + assertEquals("적외선 무릎 워머", defaultOptionDto.getOptionName()); + assertEquals("악세사리", defaultOptionDto.getOptionCategoryName()); + } +} diff --git a/backend/src/test/java/autoever2/cartag/integration/TrimTest.java b/backend/src/test/java/autoever2/cartag/integration/TrimTest.java new file mode 100644 index 0000000..92ce53c --- /dev/null +++ b/backend/src/test/java/autoever2/cartag/integration/TrimTest.java @@ -0,0 +1,39 @@ +package autoever2.cartag.integration; + +import autoever2.cartag.controller.TrimController; +import autoever2.cartag.domain.car.CarDto; +import autoever2.cartag.exception.EmptyDataException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +@Sql(scripts = {"classpath:/insert/insertCar-h2.sql"}) +public class TrimTest { + @Autowired + TrimController controller; + + @Test + @DisplayName("cartype에 종속적인 차량 리스트 반환 api를 테스트 합니다.") + void testCarController() { + List cars = controller.carTrimInfo(1); + assertEquals("Le Blanc", cars.get(0).getTrim()); + assertEquals(40000000, cars.get(1).getCarDefaultPrice()); + assertEquals("image_1", cars.get(2).getOuterImage()); + assertEquals("image_2", cars.get(3).getInnerImage()); + assertThrows(EmptyDataException.class, () -> { + controller.carTrimInfo(2); + }); + } +} diff --git a/backend/src/test/java/autoever2/cartag/service/CarServiceTest.java b/backend/src/test/java/autoever2/cartag/service/CarServiceTest.java index 5354940..745335e 100644 --- a/backend/src/test/java/autoever2/cartag/service/CarServiceTest.java +++ b/backend/src/test/java/autoever2/cartag/service/CarServiceTest.java @@ -7,7 +7,6 @@ import autoever2.cartag.exception.ErrorCode; import autoever2.cartag.repository.CarRepository; import autoever2.cartag.repository.OptionRepository; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,7 +18,7 @@ import java.util.ArrayList; import java.util.List; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.when; diff --git a/backend/src/test/java/autoever2/cartag/service/ColorServiceTest.java b/backend/src/test/java/autoever2/cartag/service/ColorServiceTest.java index 14766a2..d885688 100644 --- a/backend/src/test/java/autoever2/cartag/service/ColorServiceTest.java +++ b/backend/src/test/java/autoever2/cartag/service/ColorServiceTest.java @@ -16,7 +16,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import javax.swing.text.html.Option; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -150,6 +149,8 @@ void getModelTypeData() { assertEquals(60, imageFiles.size()); assertEquals(2, result_inner.get(4).getColorBoughtPercent()); assertEquals("어비스 블랙펄", result_outer.get(0).getColorName()); + assertEquals(100000L, result_inner.get(0).getColorPrice()); + assertEquals(2, result_inner.get(1).getColorBoughtPercent()); assertThatThrownBy(() -> service.changeImageToImages(2)).isInstanceOf(EmptyDataException.class); assertThatThrownBy(() -> service.findInnerColorByCarId(2)).isInstanceOf(EmptyDataException.class); assertThatThrownBy(() -> service.findOuterColorByCarId(2)).isInstanceOf(EmptyDataException.class); diff --git a/backend/src/test/java/autoever2/cartag/service/ModelServiceTest.java b/backend/src/test/java/autoever2/cartag/service/ModelServiceTest.java index 55a1ccf..558c267 100644 --- a/backend/src/test/java/autoever2/cartag/service/ModelServiceTest.java +++ b/backend/src/test/java/autoever2/cartag/service/ModelServiceTest.java @@ -1,13 +1,15 @@ package autoever2.cartag.service; -import autoever2.cartag.domain.model.*; +import autoever2.cartag.domain.model.ModelDetailMappedDto; +import autoever2.cartag.domain.model.ModelEfficiencyDataDto; +import autoever2.cartag.domain.model.ModelShortDataDto; +import autoever2.cartag.domain.model.ModelShortMappedDto; import autoever2.cartag.exception.EmptyDataException; import autoever2.cartag.repository.CarRepository; import autoever2.cartag.repository.ModelRepository; import org.assertj.core.api.SoftAssertions; import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -20,7 +22,6 @@ import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; @ExtendWith({MockitoExtension.class, SoftAssertionsExtension.class}) diff --git a/frontend/src/components/cards/ModelTypeCard.tsx b/frontend/src/components/cards/ModelTypeCard.tsx index e34fbcb..8063d63 100644 --- a/frontend/src/components/cards/ModelTypeCard.tsx +++ b/frontend/src/components/cards/ModelTypeCard.tsx @@ -23,7 +23,7 @@ export default function ModelTypeCard({ {desc} {title} - +{price} 원 + +{price.toLocaleString()} 원 ); diff --git a/frontend/src/components/cards/OptionCard.tsx b/frontend/src/components/cards/OptionCard.tsx index 97cff55..f112924 100644 --- a/frontend/src/components/cards/OptionCard.tsx +++ b/frontend/src/components/cards/OptionCard.tsx @@ -32,7 +32,7 @@ export default function OptionCard({ 기본포함 ) : ( - +{price} 원 + +{price.toLocaleString()} 원 ); @@ -98,7 +98,13 @@ const OptionCardInfo = styled.div` const OptionTitle = styled.div` ${HeadingEn4} + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; `; + const OptionPrice = styled.div` ${HeadingKrMedium7} display: flex; diff --git a/frontend/src/components/cards/OuterColorCard.tsx b/frontend/src/components/cards/OuterColorCard.tsx index 32ee6cd..80e1497 100644 --- a/frontend/src/components/cards/OuterColorCard.tsx +++ b/frontend/src/components/cards/OuterColorCard.tsx @@ -35,7 +35,7 @@ export default function OuterColorCard({ {name} - + {price}원 + + {price.toLocaleString()} 원 diff --git a/frontend/src/components/common/banner/Banner.tsx b/frontend/src/components/common/banner/Banner.tsx index ba6f664..4968c73 100644 --- a/frontend/src/components/common/banner/Banner.tsx +++ b/frontend/src/components/common/banner/Banner.tsx @@ -46,6 +46,8 @@ const SubTitle = styled.p` `; const Title = styled.p` + position: relative; + z-index: 3; color: ${({ theme }) => theme.color.primaryColor700}; ${HeadingKrBold1} `; diff --git a/frontend/src/components/common/hmgTag/HmgTag.tsx b/frontend/src/components/common/hmgTag/HmgTag.tsx index 39077ba..ae3f2f1 100644 --- a/frontend/src/components/common/hmgTag/HmgTag.tsx +++ b/frontend/src/components/common/hmgTag/HmgTag.tsx @@ -7,7 +7,6 @@ export interface IHmgTag { } export default function HmgTag({ size = 'medium' }: IHmgTag) { - // Todo. ">" 를 svg 아이콘으로 바꾸기 return HMG Data {size === 'large' ? '>' : null}; } const Wrapper = styled.div<{ $size: sizeType }>` diff --git a/frontend/src/components/tabs/OptionTab.tsx b/frontend/src/components/tabs/OptionTab.tsx index 6ee877d..06888ea 100644 --- a/frontend/src/components/tabs/OptionTab.tsx +++ b/frontend/src/components/tabs/OptionTab.tsx @@ -1,5 +1,5 @@ import { Dispatch, HTMLAttributes, SetStateAction, useEffect, useRef, useState } from 'react'; -import { BodyKrMedium3, BodyKrRegular3 } from '../../styles/typefaces'; +import { BodyKrMedium3, BodyKrRegular3, BodyKrRegular4 } from '../../styles/typefaces'; import styled, { css, useTheme } from 'styled-components'; import { ArrowLeft, ArrowRight } from '../common/icons/Icons'; import { NUM_IN_A_PAGE } from '../../utils/constants'; @@ -92,18 +92,21 @@ export default function OptionTab({ options, setBannerInfo }: ISubOptionTab) { {chunkedOptions.map((optionGroup: ISubOptionList[], groupIndex) => ( - - {optionGroup.map((option: ISubOptionList, index: number) => ( - handleOptionClick(index)} - $isselected={page === groupIndex && index === selectedIdx} - > -
{option.optionName}
- {displayUnderline(groupIndex, index)} -
- ))} -
+ <> + + {optionGroup.map((option: ISubOptionList, index: number) => ( + + handleOptionClick(index)} + $isselected={page === groupIndex && index === selectedIdx} + > +
{option.optionName}
+ {displayUnderline(groupIndex, index)} +
+
+ ))} +
+ ))}
@@ -117,13 +120,23 @@ export default function OptionTab({ options, setBannerInfo }: ISubOptionTab) { ); } - +const HoverCaption = styled.div` + display: none; + white-space: nowrap; + right: 0; + position: absolute; + padding: 4px 12px; + text-align: center; + border-radius: 10px; + background-color: ${({ theme }) => theme.color.gray900}; + color: ${({ theme }) => theme.color.white}; + ${BodyKrRegular4} +`; const TabWrapper = styled.div` width: 488px; display: flex; align-items: center; justify-content: space-between; - height: 40px; `; const BtnWrapper = styled.button` @@ -131,30 +144,39 @@ const BtnWrapper = styled.button` `; const Tab = styled.div<{ $offset: number }>` display: flex; - - transition: transform 1s ease; + transition: transform 0.4s ease; transform: translateX(${({ $offset }) => $offset}px); `; const TabWrapperInner = styled.div` overflow: hidden; width: 408px; + height: 100%; `; const TabDivision = styled.ul` display: flex; - justify-content: space-between; width: 408px; padding: 0 16px; `; - +const TabButtonWrapper = styled.div` + display: flex; + align-items: end; + position: relative; + height: 100%; + &:hover { + ${HoverCaption} { + display: block; + } + } +`; const TabButton = styled.div<{ $isselected: boolean }>` display: flex; - overflow: hidden; align-items: center; flex-direction: column; gap: 4px; width: 78px; margin: 0 8px; height: 28px; + cursor: pointer; ${({ theme, $isselected }) => { if ($isselected) { diff --git a/frontend/src/containers/OptionPage/OptionBannerContainer.tsx b/frontend/src/containers/OptionPage/OptionBannerContainer.tsx index 6e00dc4..3c973b5 100644 --- a/frontend/src/containers/OptionPage/OptionBannerContainer.tsx +++ b/frontend/src/containers/OptionPage/OptionBannerContainer.tsx @@ -9,7 +9,6 @@ import CenterWrapper from '../../components/layout/CenterWrapper'; import Banner from '../../components/common/banner/Banner'; import HmgTag from '../../components/common/hmgTag/HmgTag'; import OptionTab from '../../components/tabs/OptionTab'; -import { MAX_TEXT_CNT } from '../../utils/constants'; import { useEffect, useState } from 'react'; import { IMG_URL } from '../../utils/apis'; @@ -61,10 +60,6 @@ export default function OptionBannerContainer({ setWinY(window.scrollY); }; - const isOverMaxLine = (desc: string) => { - const text = desc.length > MAX_TEXT_CNT ? desc.substring(0, MAX_TEXT_CNT) + '...' : desc; - return text; - }; useEffect(() => { window.addEventListener('scroll', handleScroll); return () => { @@ -96,20 +91,23 @@ export default function OptionBannerContainer({ setBannerInfo={setBannerInfo} /> )} - - {isOverMaxLine(bannerInfo.descriptionText)} - {bannerInfo.descriptionText.length > MAX_TEXT_CNT && 더보기} - + {bannerInfo.descriptionText && ( + + {bannerInfo.descriptionText} + {bannerInfo.descriptionText} + + )} + {optionDetail.hmgData && ( - {optionDetail.hmgData.overHalf && ( + {optionDetail.hmgData.overHalf !== null && ( {optionDetail.hmgData.overHalf - ? '이 트림을 구매한 사람 중 절반 이상이 선택한 옵션이에요.' - : '이 트림을 구매한 사람이 이 옵션을 이만큼 선택했어요.'} + ? '구매자의 절반 이상이 선택했어요.' + : '구매자가 이 옵션을 이만큼 선택했어요.'} {Number(optionDetail.hmgData.optionBoughtCount).toLocaleString()}개 @@ -147,7 +145,31 @@ export default function OptionBannerContainer({ ); } +const HoverCaption = styled.div` + display: none; + white-space: pre-wrap; + position: absolute; + padding: 4px 12px; + border-radius: 10px; + top: 120%; + color: ${({ theme }) => theme.color.gray50}; + opacity: 90%; + background-color: ${({ theme }) => theme.color.gray900}; + ${BodyKrRegular4} + &:after { + content: ''; + position: absolute; + left: 10%; + bottom: 100%; + width: 0; + height: 0; + margin-left: -10px; + border: solid transparent; + border-bottom-color: ${({ theme }) => theme.color.gray900}; + border-width: 3px; + } +`; const Wrapper = styled.div<{ $isBannerVisible: boolean }>` z-index: 5; position: sticky; @@ -172,17 +194,27 @@ const Container = styled(CenterWrapper)` height: 100%; `; -const AdditionalText = styled.p` +const Description = styled.div` + position: relative; + + &:hover { + ${HoverCaption} { + display: block; + } + } +`; + +const AdditionalText = styled.div` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: pre-wrap; word-break: keep-all; width: 456px; color: ${({ theme }) => theme.color.gray800}; ${BodyKrRegular4} - span { - padding-left: 10px; - text-decoration: underline; - ${BodyKrMedium4} - cursor:pointer; - } `; const InfoWrapper = styled.div` @@ -220,20 +252,13 @@ const DataList = styled.ul` width: 448px; margin-top: 16px; align-items: center; + gap: 24px; `; const Data = styled.li` width: 100%; - height: 67px; display: flex; flex-direction: column; - &:first-child { - padding-right: 24px; - } - - &:last-child { - padding-left: 24px; - } `; const DataTitle = styled.div` diff --git a/frontend/src/containers/OptionPage/OptionSelectContainer/DefaultOptionContainer.tsx b/frontend/src/containers/OptionPage/OptionSelectContainer/DefaultOptionContainer.tsx index f640322..059c4a5 100644 --- a/frontend/src/containers/OptionPage/OptionSelectContainer/DefaultOptionContainer.tsx +++ b/frontend/src/containers/OptionPage/OptionSelectContainer/DefaultOptionContainer.tsx @@ -43,11 +43,6 @@ export default function DefaultOptionContainer() { currentCategory === '전체' ? defaultOption : groupedData[currentCategory]; const displayData = filteredByCategory.map((option, idx) => ( - {option.hasHmgData && ( - - - - )} { handleCardClick(option.optionId); @@ -59,6 +54,11 @@ export default function DefaultOptionContainer() { imgPath={option.optionImage} hashTag={option.hashtagName} /> + {option.hasHmgData && ( + + + + )} )); return ( diff --git a/frontend/src/containers/OptionPage/OptionSelectContainer/SubOptionContainer.tsx b/frontend/src/containers/OptionPage/OptionSelectContainer/SubOptionContainer.tsx index 793077f..2e56d85 100644 --- a/frontend/src/containers/OptionPage/OptionSelectContainer/SubOptionContainer.tsx +++ b/frontend/src/containers/OptionPage/OptionSelectContainer/SubOptionContainer.tsx @@ -51,11 +51,6 @@ export default function SubOptionContainer() { const filteredByCategory = currentCategory === '전체' ? subOption : groupedData[currentCategory]; const displayData = filteredByCategory.map((option, idx) => ( - {option.hasHmgData && ( - - - - )} handleCardClick(option.subOptionId)} type="sub" @@ -67,6 +62,11 @@ export default function SubOptionContainer() { hashTag={option.hashtagName} handleSelectOption={() => handleSelectOption(option.subOptionId)} /> + {option.hasHmgData && ( + + + + )} )); @@ -111,7 +111,6 @@ const OptionSection = styled.div` `; const OptionWrapper = styled.div` display: flex; - flex-wrap: wrap; gap: 16px; `; diff --git a/frontend/src/containers/OuterColorPage/OuterColorBannerContainer.tsx b/frontend/src/containers/OuterColorPage/OuterColorBannerContainer.tsx index 0e879c8..5ab83b9 100644 --- a/frontend/src/containers/OuterColorPage/OuterColorBannerContainer.tsx +++ b/frontend/src/containers/OuterColorPage/OuterColorBannerContainer.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { MouseEventHandler, useCallback, useContext, useEffect, useReducer } from 'react'; import { styled } from 'styled-components'; import Banner from '../../components/common/banner/Banner'; import CenterWrapper from '../../components/layout/CenterWrapper'; @@ -7,100 +7,104 @@ import { useFetch } from '../../hooks/useFetch'; import { IMG_URL, OUTER_IMG_API } from '../../utils/apis'; import Loading from '../../components/loading/Loading'; import { OuterColorContext } from '../../context/OuterColorProvider'; - -interface IImgLoadingList { - [key: number]: boolean; -} +import car360Reducer from '../../reducer/car360Reducer'; export default function OuterColorBannerContainer() { - const { data: outerColorData, seletedColorId } = useContext(OuterColorContext); + const { seletedColorId } = useContext(OuterColorContext); const { data: car360ImgUrls, loading } = useFetch( `${OUTER_IMG_API}?colorid=${seletedColorId}` ); - // Todo. useReducer or Object로 관리하기 - const [imgIdx, setImgIdx] = useState(0); - const [isDragging, setIsDragging] = useState(false); - const [startX, setStartX] = useState(0); - const [startIdx, setStartIdx] = useState(0); - const [imgLoadingList, setImgLoadingList] = useState({}); + const [imgState, setImgState] = useReducer(car360Reducer, { + visibleIdx: 0, + isDragging: false, + startX: 0, + startIdx: 0, + imgLoading: true, + }); const imgLen = car360ImgUrls ? car360ImgUrls.length : 0; - const prevCar360ImgUrls = useRef([]); - const handleMousedown: MouseEventHandler = ({ pageX }) => { - setIsDragging(true); - setStartX(pageX); - setStartIdx(imgIdx); - }; - const handleMousemove: MouseEventHandler = ({ pageX, currentTarget }) => { - if (!isDragging) return; - const { offsetWidth } = currentTarget; - const moveX = startX - pageX; - const percent = moveX / offsetWidth; - const moveIdx = Math.round(imgLen * percent); - let resultIdx = startIdx + moveIdx; - if (resultIdx < 0) { - resultIdx += imgLen; - } - resultIdx %= imgLen; - setImgIdx(resultIdx); - }; - const handleMouseup: MouseEventHandler = ({ pageX }) => { - setIsDragging(false); - setStartX(pageX); - }; + const handleMousedown: MouseEventHandler = useCallback( + ({ pageX }) => { + setImgState({ type: 'SET_IS_DRAGGING', value: true }); + setImgState({ type: 'SET_START_X', value: pageX }); + setImgState({ type: 'SET_START_IDX', value: imgState.visibleIdx }); + console.log(imgState.visibleIdx); + }, + [imgState] + ); + const handleMousemove: MouseEventHandler = useCallback( + ({ pageX, currentTarget }) => { + if (!imgState.isDragging) return; + const { offsetWidth } = currentTarget; + const moveX = imgState.startX - pageX; + const percent = moveX / offsetWidth; + const moveIdx = Math.round(imgLen * percent); + let resultIdx = imgState.startIdx + moveIdx; + if (resultIdx < 0) { + resultIdx += imgLen; + } + resultIdx %= imgLen; + setImgState({ type: 'SET_VISIBLE_IDX', value: resultIdx }); + }, + [setImgState, imgState, imgLen] + ); + + const handleMouseup: MouseEventHandler = useCallback( + ({ pageX }) => { + setImgState({ type: 'SET_IS_DRAGGING', value: false }); + setImgState({ type: 'SET_START_X', value: pageX }); + }, + [setImgState] + ); - const initImgLoadingState = useCallback(() => { - if (!outerColorData) return; - const dataLen = outerColorData.length; - const initLoadingList: IImgLoadingList = {}; - for (let i = 3; i < dataLen + 3; i++) { - initLoadingList[i] = true; + const isLoaded = useCallback((urls: string[]) => { + for (const url of urls) { + if (!localStorage.getItem(url)) { + return false; + } } - setImgLoadingList(initLoadingList); - }, [outerColorData, setImgLoadingList]); + return true; + }, []); const downloadAndSaveImages = useCallback( - async (car360ImgUrls: string[]) => { - const isChangeImgUrls = prevCar360ImgUrls.current === car360ImgUrls; - if (isChangeImgUrls) return; - const imgBlob = await Promise.all( + async (car360ImgUrls: string[], abortController: AbortController) => { + if (isLoaded(car360ImgUrls)) { + setImgState({ type: 'SET_IMG_LOADING', value: false }); + return; + } + setImgState({ type: 'SET_IMG_LOADING', value: true }); + + await Promise.all( car360ImgUrls.map(async (url, idx) => { const isImageExist = localStorage.getItem(url) !== null; if (isImageExist) return; - const fetchUrl = `${IMG_URL}${url}`; - const res = await fetch(fetchUrl); + const res = await fetch(IMG_URL + url, { + signal: abortController.signal, + }); const imgBlob = await res.blob(); - const imageUrl = car360ImgUrls[idx]; - localStorage.setItem(imageUrl, URL.createObjectURL(imgBlob)); + const localStorageKey = car360ImgUrls[idx]; + localStorage.setItem(localStorageKey, URL.createObjectURL(imgBlob)); return imgBlob; }) ); - - if (imgBlob) { - setImgLoadingList((cur) => { - return { ...cur, [seletedColorId]: false }; - }); - } + setImgState({ type: 'SET_IMG_LOADING', value: false }); }, - [setImgLoadingList, seletedColorId] + [isLoaded] ); - useEffect(initImgLoadingState, [initImgLoadingState]); - useEffect(() => { if (!car360ImgUrls || loading) return; - downloadAndSaveImages(car360ImgUrls); + const abortController = new AbortController(); + downloadAndSaveImages(car360ImgUrls, abortController); + return () => { + abortController.abort(); + }; }, [downloadAndSaveImages, loading, car360ImgUrls]); - useEffect(() => { - if (!car360ImgUrls) return; - prevCar360ImgUrls.current = car360ImgUrls; - }, [car360ImgUrls]); - const car360Components = car360ImgUrls?.map((url, idx) => { const imgSrc = localStorage.getItem(url); if (!imgSrc) return; - return ; + return ; }); return ( @@ -111,7 +115,7 @@ export default function OuterColorBannerContainer() { 360° - {imgLoadingList[seletedColorId] ? : car360Components} + {imgState.imgLoading ? : car360Components} diff --git a/frontend/src/containers/TrimPage/TrimBannerContainer.tsx b/frontend/src/containers/TrimPage/TrimBannerContainer.tsx index b596f3f..a09474c 100644 --- a/frontend/src/containers/TrimPage/TrimBannerContainer.tsx +++ b/frontend/src/containers/TrimPage/TrimBannerContainer.tsx @@ -87,8 +87,7 @@ export default function TrimBannerContainer() { {option.optionName} - {option.optionUsedCount} - 15,000km 당 + {option.optionUsedCount}회15,000km 당 )); diff --git a/frontend/src/reducer/car360Reducer.ts b/frontend/src/reducer/car360Reducer.ts new file mode 100644 index 0000000..1fa53ba --- /dev/null +++ b/frontend/src/reducer/car360Reducer.ts @@ -0,0 +1,31 @@ +interface ICar360State { + visibleIdx: number; + isDragging: boolean; + startX: number; + startIdx: number; + imgLoading: boolean; +} + +type actionType = + | { type: 'SET_VISIBLE_IDX'; value: number } + | { type: 'SET_IS_DRAGGING'; value: boolean } + | { type: 'SET_START_X'; value: number } + | { type: 'SET_START_IDX'; value: number } + | { type: 'SET_IMG_LOADING'; value: boolean }; + +export default function car360Reducer(state: ICar360State, action: actionType): ICar360State { + switch (action.type) { + case 'SET_IS_DRAGGING': + return { ...state, isDragging: action.value }; + case 'SET_START_IDX': + return { ...state, startIdx: action.value }; + case 'SET_START_X': + return { ...state, startX: action.value }; + case 'SET_VISIBLE_IDX': + return { ...state, visibleIdx: action.value }; + case 'SET_IMG_LOADING': + return { ...state, imgLoading: action.value }; + default: + return state; + } +} diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 67c37c1..6e874a4 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -1,6 +1,5 @@ export const MAX_PAGE = 3; export const NUM_IN_A_PAGE = 4; -export const MAX_TEXT_CNT = 98; export const PATH = { home: '/', @@ -30,7 +29,6 @@ Object.freeze({ MAX_PAGE, CAR_TYPE, NUM_IN_A_PAGE, - MAX_TEXT_CNT, HYUNDAI_URL, PATH, MESSAGE,