diff --git a/backend/src/main/java/com/funeat/FuneatApplication.java b/backend/src/main/java/com/funeat/FuneatApplication.java index 5a71c081b..9fae64b95 100644 --- a/backend/src/main/java/com/funeat/FuneatApplication.java +++ b/backend/src/main/java/com/funeat/FuneatApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class FuneatApplication { - public static void main(String[] args) { - SpringApplication.run(FuneatApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(FuneatApplication.class, args); + } } diff --git a/backend/src/main/java/com/funeat/auth/presentation/AuthController.java b/backend/src/main/java/com/funeat/auth/presentation/AuthController.java index 3250f32db..db05a7f8b 100644 --- a/backend/src/main/java/com/funeat/auth/presentation/AuthController.java +++ b/backend/src/main/java/com/funeat/auth/presentation/AuthController.java @@ -29,7 +29,7 @@ public interface AuthController { description = "기존 회원이면 홈으로 이동, 신규 회원이면 마이페이지로 이동." ) @GetMapping - ResponseEntity loginAuthorizeUser(@RequestParam("code") String code, HttpServletRequest request); + ResponseEntity loginAuthorizeUser(@RequestParam("code") final String code, final HttpServletRequest request); @Operation(summary = "로그아웃", description = "로그아웃을 한다") @ApiResponse( @@ -37,6 +37,6 @@ public interface AuthController { description = "로그아웃 성공." ) @PostMapping - ResponseEntity logout(@AuthenticationPrincipal LoginInfo loginInfo, HttpServletRequest request, - HttpServletResponse response); + ResponseEntity logout(@AuthenticationPrincipal final LoginInfo loginInfo, final HttpServletRequest request, + final HttpServletResponse response); } diff --git a/backend/src/main/java/com/funeat/auth/util/KakaoPlatformUserProvider.java b/backend/src/main/java/com/funeat/auth/util/KakaoPlatformUserProvider.java index a338c9d4a..c1cc463ca 100644 --- a/backend/src/main/java/com/funeat/auth/util/KakaoPlatformUserProvider.java +++ b/backend/src/main/java/com/funeat/auth/util/KakaoPlatformUserProvider.java @@ -33,20 +33,20 @@ public class KakaoPlatformUserProvider implements PlatformUserProvider { private final RestTemplate restTemplate; private final ObjectMapper objectMapper; - private final String kakaoRestApiKey; - private final String redirectUri; - private final String kakaoAdminKey; + + @Value("${kakao.rest-api-key}") + private String kakaoRestApiKey; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + @Value("${kakao.admin-key}") + private String kakaoAdminKey; public KakaoPlatformUserProvider(final RestTemplateBuilder restTemplateBuilder, - final ObjectMapper objectMapper, - @Value("${kakao.rest-api-key}") final String kakaoRestApiKey, - @Value("${kakao.redirect-uri}") final String redirectUri, - @Value("${kakao.admin-key}") final String kakaoAdminKey) { + final ObjectMapper objectMapper) { this.restTemplate = restTemplateBuilder.build(); this.objectMapper = objectMapper; - this.kakaoRestApiKey = kakaoRestApiKey; - this.redirectUri = redirectUri; - this.kakaoAdminKey = kakaoAdminKey; } @Override @@ -118,10 +118,10 @@ private KakaoUserInfoDto convertJsonToKakaoUserDto(final String responseBody) { @Override public String getRedirectURI() { - final StringJoiner joiner = new StringJoiner("&"); - joiner.add("response_type=code"); - joiner.add("client_id=" + kakaoRestApiKey); - joiner.add("redirect_uri=" + redirectUri); + final StringJoiner joiner = new StringJoiner("&") + .add("response_type=code") + .add("client_id=" + kakaoRestApiKey) + .add("redirect_uri=" + redirectUri); return AUTHORIZATION_BASE_URL + OAUTH_URI + "?" + joiner; } diff --git a/backend/src/main/java/com/funeat/common/CustomPageableHandlerMethodArgumentResolver.java b/backend/src/main/java/com/funeat/common/CustomPageableHandlerMethodArgumentResolver.java index db3e9622e..acb481f97 100644 --- a/backend/src/main/java/com/funeat/common/CustomPageableHandlerMethodArgumentResolver.java +++ b/backend/src/main/java/com/funeat/common/CustomPageableHandlerMethodArgumentResolver.java @@ -14,8 +14,8 @@ public class CustomPageableHandlerMethodArgumentResolver extends PageableHandlerMethodArgumentResolver { @Override - public Pageable resolveArgument(MethodParameter methodParameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + public Pageable resolveArgument(final MethodParameter methodParameter, final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { final Pageable pageable = super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); final Sort lastPrioritySort = Sort.by("id").descending(); diff --git a/backend/src/main/java/com/funeat/common/WebConfig.java b/backend/src/main/java/com/funeat/common/WebConfig.java index e5a3c20f0..638dffe05 100644 --- a/backend/src/main/java/com/funeat/common/WebConfig.java +++ b/backend/src/main/java/com/funeat/common/WebConfig.java @@ -51,7 +51,7 @@ public void addFormatters(final FormatterRegistry registry) { } @Override - public void addArgumentResolvers(List resolvers) { + public void addArgumentResolvers(final List resolvers) { resolvers.add(customPageableHandlerMethodArgumentResolver); resolvers.add(authArgumentResolver); } diff --git a/backend/src/main/java/com/funeat/common/logging/Logging.java b/backend/src/main/java/com/funeat/common/logging/Logging.java new file mode 100644 index 000000000..475a43b4b --- /dev/null +++ b/backend/src/main/java/com/funeat/common/logging/Logging.java @@ -0,0 +1,11 @@ +package com.funeat.common.logging; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Logging { +} diff --git a/backend/src/main/java/com/funeat/common/logging/LoggingAspect.java b/backend/src/main/java/com/funeat/common/logging/LoggingAspect.java new file mode 100644 index 000000000..f5a96ce77 --- /dev/null +++ b/backend/src/main/java/com/funeat/common/logging/LoggingAspect.java @@ -0,0 +1,91 @@ +package com.funeat.common.logging; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.CodeSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +public class LoggingAspect { + + private static final List excludeNames = Arrays.asList("image", "images", "request"); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + @Pointcut("execution(public * com.funeat.*.presentation.*.*(..))") + private void allPresentation() { + } + + @Pointcut("@annotation(com.funeat.common.logging.Logging)") + private void logging() { + } + + @Before("allPresentation() && logging()") + public void requestLogging(final JoinPoint joinPoint) { + final HttpServletRequest request = getRequest(); + final Map args = getSpecificParameters(joinPoint); + + printRequestLog(request, args); + } + + private HttpServletRequest getRequest() { + final ServletRequestAttributes servletRequestAttributes + = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); + return servletRequestAttributes.getRequest(); + } + + private Map getSpecificParameters(final JoinPoint joinPoint) { + final CodeSignature codeSignature = (CodeSignature) joinPoint.getSignature(); + final String[] parameterNames = codeSignature.getParameterNames(); + final Object[] args = joinPoint.getArgs(); + + final Map params = new HashMap<>(); + for (int i = 0; i < parameterNames.length; i++) { + if (!excludeNames.contains(parameterNames[i])) { + params.put(parameterNames[i], args[i]); + } + } + + return params; + } + + private void printRequestLog(final HttpServletRequest request, final Object value) { + try { + log.info("[REQUEST {}] [PATH {}] {}", + request.getMethod(), request.getRequestURI(), objectMapper.writeValueAsString(value)); + } catch (final JsonProcessingException e) { + log.warn("[LOGGING ERROR] Request 로깅에 실패했습니다"); + } + } + + @AfterReturning(value = "allPresentation() && logging()", returning = "responseEntity") + public void requestLogging(final ResponseEntity responseEntity) { + printResponseLog(responseEntity); + } + + private void printResponseLog(final ResponseEntity responseEntity) { + try { + final String responseStatus = responseEntity.getStatusCode().toString(); + log.info("[RESPONSE {}] {}", responseStatus, objectMapper.writeValueAsString(responseEntity.getBody())); + } catch (final JsonProcessingException e) { + log.warn("[LOGGING ERROR] Response 로깅에 실패했습니다"); + } + } +} diff --git a/backend/src/main/java/com/funeat/common/s3/S3Uploader.java b/backend/src/main/java/com/funeat/common/s3/S3Uploader.java index 858bde518..493e98599 100644 --- a/backend/src/main/java/com/funeat/common/s3/S3Uploader.java +++ b/backend/src/main/java/com/funeat/common/s3/S3Uploader.java @@ -10,6 +10,7 @@ import com.funeat.common.exception.CommonException.NotAllowedFileExtensionException; import com.funeat.common.exception.CommonException.S3UploadFailException; import java.io.IOException; +import java.util.List; import java.util.UUID; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; @@ -20,8 +21,7 @@ @Profile("!test") public class S3Uploader implements ImageUploader { - public static final String JPEG = "image/jpeg"; - public static final String PNG = "image/png"; + private static final List INCLUDE_EXTENSIONS = List.of("image/jpeg", "image/png"); @Value("${cloud.aws.s3.bucket}") private String bucket; @@ -55,7 +55,7 @@ public String upload(final MultipartFile image) { private void validateExtension(final MultipartFile image) { final String contentType = image.getContentType(); - if (!contentType.equals(JPEG) && !contentType.equals(PNG)) { + if (!INCLUDE_EXTENSIONS.contains(contentType)){ throw new NotAllowedFileExtensionException(IMAGE_EXTENSION_ERROR_CODE, contentType); } } diff --git a/backend/src/main/java/com/funeat/exception/presentation/GlobalControllerAdvice.java b/backend/src/main/java/com/funeat/exception/presentation/GlobalControllerAdvice.java index 3d806575d..b6fed678c 100644 --- a/backend/src/main/java/com/funeat/exception/presentation/GlobalControllerAdvice.java +++ b/backend/src/main/java/com/funeat/exception/presentation/GlobalControllerAdvice.java @@ -24,6 +24,9 @@ @RestControllerAdvice public class GlobalControllerAdvice { + private static final String ERROR_MESSAGE_DELIMITER = ", "; + private static final String RESPONSE_DELIMITER = ". "; + private final Logger log = LoggerFactory.getLogger(this.getClass()); private final ObjectMapper objectMapper; @@ -50,9 +53,9 @@ public ResponseEntity handleParamValidationException(final MethodArgumentNotV .getAllErrors() .stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); + .collect(Collectors.joining(ERROR_MESSAGE_DELIMITER)); - final String responseErrorMessage = errorMessage + ". " + REQUEST_VALID_ERROR_CODE.getMessage(); + final String responseErrorMessage = errorMessage + RESPONSE_DELIMITER + REQUEST_VALID_ERROR_CODE.getMessage(); final ErrorCode errorCode = new ErrorCode<>(REQUEST_VALID_ERROR_CODE.getCode(), responseErrorMessage); @@ -66,7 +69,7 @@ private static String getMethodArgumentExceptionLogMessage(final MethodArgumentN .getFieldErrors() .stream() .map(FieldError::getField) - .collect(Collectors.joining(", ")); + .collect(Collectors.joining(ERROR_MESSAGE_DELIMITER)); return filedErrorMessages + " 요청 실패"; } diff --git a/backend/src/main/java/com/funeat/member/dto/MemberRecipeDto.java b/backend/src/main/java/com/funeat/member/dto/MemberRecipeDto.java index 927f853d5..4c01ce95d 100644 --- a/backend/src/main/java/com/funeat/member/dto/MemberRecipeDto.java +++ b/backend/src/main/java/com/funeat/member/dto/MemberRecipeDto.java @@ -1,12 +1,10 @@ package com.funeat.member.dto; import com.fasterxml.jackson.annotation.JsonCreator; -import com.funeat.product.domain.Product; import com.funeat.recipe.domain.Recipe; import com.funeat.recipe.domain.RecipeImage; import java.time.LocalDateTime; import java.util.List; -import java.util.stream.Collectors; public class MemberRecipeDto { diff --git a/backend/src/main/java/com/funeat/member/dto/MemberReviewDto.java b/backend/src/main/java/com/funeat/member/dto/MemberReviewDto.java index 2ced61eaa..082b4dd72 100644 --- a/backend/src/main/java/com/funeat/member/dto/MemberReviewDto.java +++ b/backend/src/main/java/com/funeat/member/dto/MemberReviewDto.java @@ -13,8 +13,8 @@ public class MemberReviewDto { private final Long favoriteCount; private MemberReviewDto(final Long reviewId, final Long productId, final String categoryType, - final String productName, final String content, - final Long rating, final Long favoriteCount) { + final String productName, final String content, + final Long rating, final Long favoriteCount) { this.reviewId = reviewId; this.productId = productId; this.categoryType = categoryType; diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java index 8a5539281..6e28cfac6 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java @@ -2,6 +2,7 @@ import com.funeat.auth.dto.LoginInfo; import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.common.logging.Logging; import com.funeat.member.application.MemberService; import com.funeat.member.dto.MemberProfileResponse; import com.funeat.member.dto.MemberRecipesResponse; @@ -38,20 +39,17 @@ public MemberApiController(final MemberService memberService, final ReviewServic @GetMapping public ResponseEntity getMemberProfile(@AuthenticationPrincipal final LoginInfo loginInfo) { - final Long memberId = loginInfo.getId(); - - final MemberProfileResponse response = memberService.getMemberProfile(memberId); + final MemberProfileResponse response = memberService.getMemberProfile(loginInfo.getId()); return ResponseEntity.ok(response); } + @Logging @PutMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) public ResponseEntity putMemberProfile(@AuthenticationPrincipal final LoginInfo loginInfo, @RequestPart(required = false) final MultipartFile image, @RequestPart @Valid final MemberRequest memberRequest) { - final Long memberId = loginInfo.getId(); - - memberService.modify(memberId, image, memberRequest); + memberService.modify(loginInfo.getId(), image, memberRequest); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberController.java b/backend/src/main/java/com/funeat/member/presentation/MemberController.java index 304a1a5e4..9a4ede8da 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberController.java @@ -26,7 +26,7 @@ public interface MemberController { description = "사용자 정보 조회 성공." ) @GetMapping - ResponseEntity getMemberProfile(@AuthenticationPrincipal LoginInfo loginInfo); + ResponseEntity getMemberProfile(@AuthenticationPrincipal final LoginInfo loginInfo); @Operation(summary = "사용자 정보 수정", description = "사용자 닉네임과 프로필 사진을 수정한다.") @ApiResponse( @@ -44,8 +44,8 @@ public ResponseEntity putMemberProfile(@AuthenticationPrincipal final Logi description = "사용자 리뷰 조회 성공." ) @GetMapping - ResponseEntity getMemberReview(@AuthenticationPrincipal LoginInfo loginInfo, - @PageableDefault Pageable pageable); + ResponseEntity getMemberReview(@AuthenticationPrincipal final LoginInfo loginInfo, + @PageableDefault final Pageable pageable); @Operation(summary = "사용자 꿀조합 조회", description = "사용자가 작성한 꿀조합을 조회한다.") @ApiResponse( @@ -53,6 +53,6 @@ ResponseEntity getMemberReview(@AuthenticationPrincipal L description = "사용자 꿀조합 조회 성공." ) @GetMapping - ResponseEntity getMemberRecipe(@AuthenticationPrincipal LoginInfo loginInfo, - @PageableDefault Pageable pageable); + ResponseEntity getMemberRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, + @PageableDefault final Pageable pageable); } diff --git a/backend/src/main/java/com/funeat/product/application/ProductService.java b/backend/src/main/java/com/funeat/product/application/ProductService.java index 940a71cda..0301db183 100644 --- a/backend/src/main/java/com/funeat/product/application/ProductService.java +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -32,6 +32,7 @@ import com.funeat.tag.domain.Tag; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -46,6 +47,7 @@ public class ProductService { private static final int THREE = 3; private static final int TOP = 0; public static final String REVIEW_COUNT = "reviewCount"; + private static final int RANKING_SIZE = 3; private final CategoryRepository categoryRepository; private final ProductRepository productRepository; @@ -82,7 +84,7 @@ public ProductsInCategoryResponse getAllProductsInCategory(final Long categoryId } private Page getAllProductsInCategory(final Pageable pageable, final Category category) { - if (pageable.getSort().getOrderFor(REVIEW_COUNT) != null) { + if (Objects.nonNull(pageable.getSort().getOrderFor(REVIEW_COUNT))) { final PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); return productRepository.findAllByCategoryOrderByReviewCountDesc(category, pageRequest); } @@ -106,7 +108,7 @@ public RankingProductsResponse getTop3Products() { final List rankingProductDtos = productsAndReviewCounts.stream() .sorted(rankingScoreComparator) - .limit(3) + .limit(RANKING_SIZE) .map(it -> RankingProductDto.toDto(it.getProduct())) .collect(Collectors.toList()); @@ -125,7 +127,8 @@ public SearchProductsResponse searchProducts(final String query, final Pageable } public SearchProductResultsResponse getSearchResults(final String query, final Pageable pageable) { - final Page products = productRepository.findAllWithReviewCountByNameContaining(query, pageable); + final Page products = productRepository.findAllWithReviewCountByNameContaining(query, + pageable); final PageDto pageDto = PageDto.toDto(products); final List resultDtos = products.stream() diff --git a/backend/src/main/java/com/funeat/product/domain/Category.java b/backend/src/main/java/com/funeat/product/domain/Category.java index 03d1d4f52..5d6c62a08 100644 --- a/backend/src/main/java/com/funeat/product/domain/Category.java +++ b/backend/src/main/java/com/funeat/product/domain/Category.java @@ -1,11 +1,6 @@ package com.funeat.product.domain; -import javax.persistence.Entity; -import javax.persistence.EnumType; -import javax.persistence.Enumerated; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; +import javax.persistence.*; @Entity public class Category { @@ -19,12 +14,15 @@ public class Category { @Enumerated(EnumType.STRING) private CategoryType type; + private String image; + protected Category() { } - public Category(final String name, final CategoryType type) { + public Category(final String name, final CategoryType type, final String image) { this.name = name; this.type = type; + this.image = image; } public Long getId() { @@ -38,4 +36,8 @@ public String getName() { public CategoryType getType() { return type; } + + public String getImage() { + return image; + } } diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index 58010fe21..e66aa59cf 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -3,7 +3,6 @@ import com.funeat.member.domain.bookmark.ProductBookmark; import com.funeat.review.domain.Review; import java.util.List; -import java.util.Objects; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; @@ -80,10 +79,6 @@ public void updateImage(final String topFavoriteImage) { this.image = topFavoriteImage; } - public boolean isNotEqualImage(final String anotherImage) { - return !Objects.equals(this.image, anotherImage); - } - public Long getId() { return id; } diff --git a/backend/src/main/java/com/funeat/product/dto/CategoryResponse.java b/backend/src/main/java/com/funeat/product/dto/CategoryResponse.java index ebf70e602..3ea9e59a5 100644 --- a/backend/src/main/java/com/funeat/product/dto/CategoryResponse.java +++ b/backend/src/main/java/com/funeat/product/dto/CategoryResponse.java @@ -6,14 +6,16 @@ public class CategoryResponse { private final Long id; private final String name; + private final String image; - public CategoryResponse(final Long id, final String name) { + public CategoryResponse(final Long id, final String name, final String image) { this.id = id; this.name = name; + this.image = image; } public static CategoryResponse toResponse(final Category category) { - return new CategoryResponse(category.getId(), category.getName()); + return new CategoryResponse(category.getId(), category.getName(), category.getImage()); } public Long getId() { @@ -23,4 +25,8 @@ public Long getId() { public String getName() { return name; } + + public String getImage() { + return image; + } } diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java index de3e3e313..ad4cdab0e 100644 --- a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -13,22 +13,23 @@ public interface ProductRepository extends JpaRepository { - @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " + - "FROM Product p " + - "LEFT JOIN p.reviews r " + - "WHERE p.category = :category " + - "GROUP BY p ", + @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " + + "FROM Product p " + + "LEFT JOIN p.reviews r " + + "WHERE p.category = :category " + + "GROUP BY p ", countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") Page findAllByCategory(@Param("category") final Category category, final Pageable pageable); - @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " + - "FROM Product p " + - "LEFT JOIN p.reviews r " + - "WHERE p.category = :category " + - "GROUP BY p " + - "ORDER BY COUNT(r) DESC, p.id DESC ", + @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " + + "FROM Product p " + + "LEFT JOIN p.reviews r " + + "WHERE p.category = :category " + + "GROUP BY p " + + "ORDER BY COUNT(r) DESC, p.id DESC ", countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") - Page findAllByCategoryOrderByReviewCountDesc(@Param("category") final Category category, final Pageable pageable); + Page findAllByCategoryOrderByReviewCountDesc(@Param("category") final Category category, + final Pageable pageable); @Query("SELECT new com.funeat.product.dto.ProductReviewCountDto(p, COUNT(r.id)) " + "FROM Product p " @@ -50,5 +51,6 @@ public interface ProductRepository extends JpaRepository { + "GROUP BY p.id " + "ORDER BY " + "(CASE WHEN p.name LIKE CONCAT(:name, '%') THEN 1 ELSE 2 END), p.id DESC") - Page findAllWithReviewCountByNameContaining(@Param("name") final String name, final Pageable pageable); + Page findAllWithReviewCountByNameContaining(@Param("name") final String name, + final Pageable pageable); } diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java index ef79d5e67..f71a1a706 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java @@ -29,7 +29,7 @@ public ProductApiController(final ProductService productService) { @GetMapping("/categories/{categoryId}/products") public ResponseEntity getAllProductsInCategory(@PathVariable final Long categoryId, - @PageableDefault Pageable pageable) { + @PageableDefault final Pageable pageable) { final ProductsInCategoryResponse response = productService.getAllProductsInCategory(categoryId, pageable); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductController.java b/backend/src/main/java/com/funeat/product/presentation/ProductController.java index 26b4c6da9..d7f9e653f 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductController.java @@ -26,7 +26,7 @@ public interface ProductController { ) @GetMapping ResponseEntity getAllProductsInCategory( - @PathVariable(name = "category_id") final Long categoryId, @PageableDefault Pageable pageable + @PathVariable(name = "category_id") final Long categoryId, @PageableDefault final Pageable pageable ); @Operation(summary = "해당 상품 상세 조회", description = "해당 상품 상세정보를 조회한다.") diff --git a/backend/src/main/java/com/funeat/recipe/dto/RankingRecipeDto.java b/backend/src/main/java/com/funeat/recipe/dto/RankingRecipeDto.java index 38d398e27..c6fdcfc7c 100644 --- a/backend/src/main/java/com/funeat/recipe/dto/RankingRecipeDto.java +++ b/backend/src/main/java/com/funeat/recipe/dto/RankingRecipeDto.java @@ -26,7 +26,8 @@ public static RankingRecipeDto toDto(final Recipe recipe, final List writeRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, @@ -61,6 +63,7 @@ public ResponseEntity getSortingRecipes(@PageableDefault return ResponseEntity.ok(response); } + @Logging @PatchMapping(value = "/api/recipes/{recipeId}") public ResponseEntity likeRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long recipeId, diff --git a/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java index f5e2eaf6b..013c559cd 100644 --- a/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java @@ -33,9 +33,9 @@ public interface RecipeController { description = "꿀조합 작성 성공." ) @PostMapping - ResponseEntity writeRecipe(@AuthenticationPrincipal LoginInfo loginInfo, - @RequestPart List images, - @RequestPart RecipeCreateRequest recipeRequest); + ResponseEntity writeRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, + @RequestPart final List images, + @RequestPart final RecipeCreateRequest recipeRequest); @Operation(summary = "꿀조합 상세 조회", description = "꿀조합의 상세 정보를 조회한다.") @ApiResponse( @@ -52,7 +52,7 @@ ResponseEntity getRecipeDetail(@AuthenticationPrincipal fi description = "꿀조합 목록 조회 성공." ) @GetMapping - ResponseEntity getSortingRecipes(@PageableDefault Pageable pageable); + ResponseEntity getSortingRecipes(@PageableDefault final Pageable pageable); @Operation(summary = "꿀조합 좋아요", description = "꿀조합에 좋아요 또는 취소를 한다.") @ApiResponse( @@ -62,7 +62,7 @@ ResponseEntity getRecipeDetail(@AuthenticationPrincipal fi @PatchMapping ResponseEntity likeRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long recipeId, - @RequestBody RecipeFavoriteRequest request); + @RequestBody final RecipeFavoriteRequest request); @Operation(summary = "꿀조합 랭킹 조회", description = "전체 꿀조합들 중에서 랭킹 TOP3를 조회한다.") @ApiResponse( diff --git a/backend/src/main/java/com/funeat/recipe/util/RecipeHandlerInterceptor.java b/backend/src/main/java/com/funeat/recipe/util/RecipeHandlerInterceptor.java index 8aec3a948..192f5714d 100644 --- a/backend/src/main/java/com/funeat/recipe/util/RecipeHandlerInterceptor.java +++ b/backend/src/main/java/com/funeat/recipe/util/RecipeHandlerInterceptor.java @@ -12,10 +12,12 @@ @Component public class RecipeHandlerInterceptor implements HandlerInterceptor { + private static final String GET = "GET"; + @Override public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) { - if ("GET".equals(request.getMethod())) { + if (GET.equals(request.getMethod())) { return true; } diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index 6f3b6d8ac..2cf840ed1 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -48,6 +48,7 @@ public class ReviewService { private static final int TOP = 0; private static final int ONE = 1; + private static final String EMPTY_URL = ""; private final ReviewRepository reviewRepository; private final TagRepository tagRepository; @@ -80,7 +81,7 @@ public void create(final Long productId, final Long memberId, final MultipartFil final String imageUrl = Optional.ofNullable(image) .map(imageUploader::upload) - .orElse(""); + .orElse(EMPTY_URL); final Review savedReview = reviewRepository.save( new Review(findMember, findProduct, imageUrl, reviewRequest.getRating(), reviewRequest.getContent(), reviewRequest.getRebuy())); diff --git a/backend/src/main/java/com/funeat/review/domain/Review.java b/backend/src/main/java/com/funeat/review/domain/Review.java index 807b384c7..3545371e3 100644 --- a/backend/src/main/java/com/funeat/review/domain/Review.java +++ b/backend/src/main/java/com/funeat/review/domain/Review.java @@ -88,10 +88,6 @@ public void minusFavoriteCount() { this.favoriteCount--; } - public boolean isEqualFavoriteCount(final Long anotherFavoriteCount) { - return Objects.equals(this.favoriteCount, anotherFavoriteCount); - } - public Long getId() { return id; } diff --git a/backend/src/main/java/com/funeat/review/domain/ReviewTag.java b/backend/src/main/java/com/funeat/review/domain/ReviewTag.java index ee4afd022..72b549743 100644 --- a/backend/src/main/java/com/funeat/review/domain/ReviewTag.java +++ b/backend/src/main/java/com/funeat/review/domain/ReviewTag.java @@ -1,7 +1,6 @@ package com.funeat.review.domain; import com.funeat.tag.domain.Tag; - import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; diff --git a/backend/src/main/java/com/funeat/review/dto/RankingReviewDto.java b/backend/src/main/java/com/funeat/review/dto/RankingReviewDto.java index f3f6dd62f..cecbe2cec 100644 --- a/backend/src/main/java/com/funeat/review/dto/RankingReviewDto.java +++ b/backend/src/main/java/com/funeat/review/dto/RankingReviewDto.java @@ -13,8 +13,8 @@ public class RankingReviewDto { private final Long favoriteCount; private RankingReviewDto(final Long reviewId, final Long productId, final String categoryType, - final String productName, final String content, - final Long rating, final Long favoriteCount) { + final String productName, final String content, + final Long rating, final Long favoriteCount) { this.reviewId = reviewId; this.productId = productId; this.categoryType = categoryType; diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java index 1787a29a1..00c7683b6 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -2,6 +2,7 @@ import com.funeat.auth.dto.LoginInfo; import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.common.logging.Logging; import com.funeat.review.application.ReviewService; import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; @@ -31,6 +32,7 @@ public ReviewApiController(final ReviewService reviewService) { this.reviewService = reviewService; } + @Logging @PostMapping(value = "/api/products/{productId}/reviews", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) public ResponseEntity writeReview(@PathVariable final Long productId, @@ -42,10 +44,11 @@ public ResponseEntity writeReview(@PathVariable final Long productId, return ResponseEntity.created(URI.create("/api/products/" + productId)).build(); } + @Logging @PatchMapping("/api/products/{productId}/reviews/{reviewId}") - public ResponseEntity toggleLikeReview(@PathVariable Long reviewId, - @AuthenticationPrincipal LoginInfo loginInfo, - @RequestBody @Valid ReviewFavoriteRequest request) { + public ResponseEntity toggleLikeReview(@PathVariable final Long reviewId, + @AuthenticationPrincipal final LoginInfo loginInfo, + @RequestBody @Valid final ReviewFavoriteRequest request) { reviewService.likeReview(reviewId, loginInfo.getId(), request); reviewService.updateProductImage(reviewId); @@ -53,9 +56,9 @@ public ResponseEntity toggleLikeReview(@PathVariable Long reviewId, } @GetMapping("/api/products/{productId}/reviews") - public ResponseEntity getSortingReviews(@AuthenticationPrincipal LoginInfo loginInfo, - @PathVariable Long productId, - @PageableDefault Pageable pageable) { + public ResponseEntity getSortingReviews(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long productId, + @PageableDefault final Pageable pageable) { final SortingReviewsResponse response = reviewService.sortingReviews(productId, pageable, loginInfo.getId()); return ResponseEntity.ok(response); diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java index 4fa373126..574e50fb4 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -29,8 +29,10 @@ public interface ReviewController { description = "리뷰 작성 성공." ) @PostMapping - ResponseEntity writeReview(@PathVariable Long productId, @AuthenticationPrincipal LoginInfo loginInfo, - @RequestPart MultipartFile image, @RequestPart ReviewCreateRequest reviewRequest); + ResponseEntity writeReview(@PathVariable final Long productId, + @AuthenticationPrincipal final LoginInfo loginInfo, + @RequestPart final MultipartFile image, + @RequestPart final ReviewCreateRequest reviewRequest); @Operation(summary = "리뷰 좋아요", description = "리뷰에 좋아요 또는 취소를 한다.") @ApiResponse( @@ -38,8 +40,9 @@ ResponseEntity writeReview(@PathVariable Long productId, @AuthenticationPr description = "리뷰 좋아요(취소) 성공." ) @PatchMapping - ResponseEntity toggleLikeReview(@PathVariable Long reviewId, @AuthenticationPrincipal LoginInfo loginInfo, - @RequestBody ReviewFavoriteRequest request); + ResponseEntity toggleLikeReview(@PathVariable final Long reviewId, + @AuthenticationPrincipal final LoginInfo loginInfo, + @RequestBody final ReviewFavoriteRequest request); @Operation(summary = "리뷰를 정렬후 조회", description = "리뷰를 정렬후 조회한다.") @ApiResponse( @@ -47,9 +50,9 @@ ResponseEntity toggleLikeReview(@PathVariable Long reviewId, @Authenticati description = "리뷰 정렬후 조회 성공." ) @GetMapping - ResponseEntity getSortingReviews(@AuthenticationPrincipal LoginInfo loginInfo, - @PathVariable Long productId, - @PageableDefault Pageable pageable); + ResponseEntity getSortingReviews(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long productId, + @PageableDefault final Pageable pageable); @Operation(summary = "리뷰 랭킹 Top3 조회", description = "리뷰 랭킹 Top3 조회한다.") @ApiResponse( diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index 2f71c6e2a..e9e6a6acf 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -21,3 +21,12 @@ kakao: rest-api-key: { LOCAL_REST_API_KEY } redirect-uri: { LOCAL_REDIRECT_URI } admin-key: { LOCAL_ADMIN_KEY } + +cloud: + aws: + region: + static: { S3_REGION } + s3: + bucket: { S3_BUCKET } + folder: { S3_DEV_FOLDER } + cloudfrontPath: { S3_DEV_CLOUDFRONT_PATH } diff --git a/backend/src/test/java/com/funeat/FuneatApplicationTests.java b/backend/src/test/java/com/funeat/FuneatApplicationTests.java index f8bc6d3e7..6648db315 100644 --- a/backend/src/test/java/com/funeat/FuneatApplicationTests.java +++ b/backend/src/test/java/com/funeat/FuneatApplicationTests.java @@ -6,7 +6,7 @@ @SpringBootTest class FuneatApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java index a239e35ee..69d454ef1 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java @@ -6,6 +6,7 @@ import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import io.restassured.specification.MultiPartSpecification; +import java.util.Objects; @SuppressWarnings("NonAsciiCharacters") public class MemberSteps { @@ -25,7 +26,7 @@ public class MemberSteps { final var requestSpec = given() .cookie("FUNEAT", loginCookie); - if (image != null) { + if (Objects.nonNull(image)) { requestSpec.multiPart(image); } diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index 06f47f651..b003353a6 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -7,6 +7,7 @@ import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import io.restassured.specification.MultiPartSpecification; +import java.util.Objects; @SuppressWarnings("NonAsciiCharacters") public class ReviewSteps { @@ -16,7 +17,7 @@ public class ReviewSteps { final var requestSpec = given() .cookie("FUNEAT", loginCookie); - if (image != null) { + if (Objects.nonNull(image)) { requestSpec.multiPart(image); } diff --git a/backend/src/test/java/com/funeat/fixture/CategoryFixture.java b/backend/src/test/java/com/funeat/fixture/CategoryFixture.java index b41401ce3..9d6da56ab 100644 --- a/backend/src/test/java/com/funeat/fixture/CategoryFixture.java +++ b/backend/src/test/java/com/funeat/fixture/CategoryFixture.java @@ -7,42 +7,42 @@ public class CategoryFixture { public static Category 카테고리_간편식사_생성() { - return new Category("간편식사", CategoryType.FOOD); + return new Category("간편식사", CategoryType.FOOD, "siksa.jpeg"); } public static Category 카테고리_즉석조리_생성() { - return new Category("즉석조리", CategoryType.FOOD); + return new Category("즉석조리", CategoryType.FOOD, "direct.jpeg"); } public static Category 카테고리_과자류_생성() { - return new Category("과자류", CategoryType.FOOD); + return new Category("과자류", CategoryType.FOOD, "snack.jpeg"); } public static Category 카테고리_아이스크림_생성() { - return new Category("아이스크림", CategoryType.FOOD); + return new Category("아이스크림", CategoryType.FOOD, "ice.jpeg"); } public static Category 카테고리_식품_생성() { - return new Category("식품", CategoryType.FOOD); + return new Category("식품", CategoryType.FOOD, "food.jpeg"); } public static Category 카테고리_음료_생성() { - return new Category("음료", CategoryType.FOOD); + return new Category("음료", CategoryType.FOOD, "drink.jpeg"); } public static Category 카테고리_CU_생성() { - return new Category("CU", CategoryType.STORE); + return new Category("CU", CategoryType.STORE, "cu.jpeg"); } public static Category 카테고리_GS25_생성() { - return new Category("GS25", CategoryType.STORE); + return new Category("GS25", CategoryType.STORE, "gs25.jpeg"); } public static Category 카테고리_EMART24_생성() { - return new Category("EMART24", CategoryType.STORE); + return new Category("EMART24", CategoryType.STORE, "emart.jpeg"); } public static Category 카테고리_세븐일레븐_생성() { - return new Category("세븐일레븐", CategoryType.STORE); + return new Category("세븐일레븐", CategoryType.STORE, "seven.jpeg"); } } diff --git a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java index f68b15d33..1b9b09aba 100644 --- a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java +++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java @@ -1,28 +1,5 @@ package com.funeat.recipe.application; -import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; -import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; -import static com.funeat.fixture.ImageFixture.여러_이미지_생성; -import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; -import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; -import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; -import static com.funeat.fixture.PageFixture.페이지요청_생성_시간_내림차순_생성; -import static com.funeat.fixture.PageFixture.페이지요청_생성_시간_오름차순_생성; -import static com.funeat.fixture.PageFixture.페이지요청_좋아요_내림차순_생성; -import static com.funeat.fixture.ProductFixture.레시피_안에_들어가는_상품_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점4점_생성; -import static com.funeat.fixture.RecipeFixture.레시피_생성; -import static com.funeat.fixture.RecipeFixture.레시피이미지_생성; -import static com.funeat.fixture.RecipeFixture.레시피좋아요요청_생성; -import static com.funeat.fixture.RecipeFixture.레시피추가요청_생성; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.SoftAssertions.assertSoftly; - import com.funeat.common.ServiceTest; import com.funeat.common.dto.PageDto; import com.funeat.member.domain.Member; @@ -38,12 +15,24 @@ import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; + +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.ImageFixture.여러_이미지_생성; +import static com.funeat.fixture.MemberFixture.*; +import static com.funeat.fixture.PageFixture.*; +import static com.funeat.fixture.ProductFixture.*; +import static com.funeat.fixture.RecipeFixture.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; @SuppressWarnings("NonAsciiCharacters") class RecipeServiceTest extends ServiceTest { @@ -88,7 +77,7 @@ class create_성공_테스트 { @Test void 레시피의_상세_정보를_조회할_수_있다() { // given - final var category = 카테고리_추가_요청(new Category("간편식사", CategoryType.FOOD)); + final var category = 카테고리_추가_요청(new Category("간편식사", CategoryType.FOOD, "siksa.jpeg")); final var product1 = new Product("불닭볶음면", 1000L, "image.png", "엄청 매운 불닭", category); final var product2 = new Product("참치 삼김", 2000L, "image.png", "담백한 참치마요 삼김", category); final var product3 = new Product("스트링 치즈", 1500L, "image.png", "고소한 치즈", category); diff --git a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java index 17857b13d..f701969bd 100644 --- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java @@ -8,7 +8,6 @@ import static com.funeat.fixture.PageFixture.페이지요청_생성_시간_내림차순_생성; import static com.funeat.fixture.PageFixture.페이지요청_생성_시간_오름차순_생성; import static com.funeat.fixture.PageFixture.페이지요청_좋아요_내림차순_생성; -import static com.funeat.fixture.PageFixture.페이지요청_기본_생성; import static com.funeat.fixture.ProductFixture.레시피_안에_들어가는_상품_생성; import static com.funeat.fixture.ProductFixture.상품_망고빙수_가격5000원_평점4점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; @@ -66,7 +65,7 @@ class findAllByProductNameContaining_성공_테스트 { // then assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + .isEqualTo(expected); } } @@ -248,7 +247,7 @@ class findRecipesByProduct_성공_테스트 { // when final var actual = recipeRepository.findRecipesByProduct(product1, page).getContent(); - + // then assertThat(actual).usingRecursiveComparison() .isEqualTo(expected); @@ -262,7 +261,7 @@ class findRecipesByOrderByFavoriteCountDesc_성공_테스트 { void 좋아요순으로_상위_3개의_레시피들을_조회한다() { // given final var member = 멤버_멤버1_생성(); - 단일_멤버_저장(member); + 단일_멤버_저장(member); final var recipe1 = 레시피_생성(member, 1L); final var recipe2 = 레시피_생성(member, 2L); diff --git a/frontend/__tests__/hooks/useTabMenu.test.ts b/frontend/__tests__/hooks/useTabMenu.test.ts new file mode 100644 index 000000000..d8ca53178 --- /dev/null +++ b/frontend/__tests__/hooks/useTabMenu.test.ts @@ -0,0 +1,30 @@ +import { useTabMenu } from '@/hooks/common'; +import { renderHook, act } from '@testing-library/react'; + +it('선택된 탭 초기 상태는 0번 인덱스이다.', () => { + const { result } = renderHook(() => useTabMenu()); + + expect(result.current.selectedTabMenu).toBe(0); + expect(result.current.isFirstTabMenu).toBe(true); +}); + +it('handleTabMenuClick를 사용하여 선택한 탭 인덱스를 저장할 수 있다. ', () => { + const { result } = renderHook(() => useTabMenu()); + + act(() => { + result.current.handleTabMenuClick(1); + }); + + expect(result.current.selectedTabMenu).toBe(1); +}); + +it('initTabMenu를 사용하여 선택된 탭을 맨 처음 탭으로 초기화할 수 있다.', () => { + const { result } = renderHook(() => useTabMenu()); + + act(() => { + result.current.handleTabMenuClick(1); + result.current.initTabMenu(); + }); + + expect(result.current.selectedTabMenu).toBe(0); +}); diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js index 36a992745..d8bc3ace3 100644 --- a/frontend/public/mockServiceWorker.js +++ b/frontend/public/mockServiceWorker.js @@ -2,7 +2,7 @@ /* tslint:disable */ /** - * Mock Service Worker (1.2.3). + * Mock Service Worker (1.3.0). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. diff --git a/frontend/src/components/Common/Carousel/Carousel.tsx b/frontend/src/components/Common/Carousel/Carousel.tsx index 0964a9a6f..5f7c3ad23 100644 --- a/frontend/src/components/Common/Carousel/Carousel.tsx +++ b/frontend/src/components/Common/Carousel/Carousel.tsx @@ -7,12 +7,12 @@ interface CarouselProps { carouselList: CarouselChildren[]; } -const CAROUSEL_WIDTH = window.innerWidth; - const Carousel = ({ carouselList }: CarouselProps) => { const extendedCarouselList = [...carouselList, carouselList[0]]; const [currentIndex, setCurrentIndex] = useState(0); + const CAROUSEL_WIDTH = window.innerWidth; + const showNextSlide = () => { setCurrentIndex((prev) => (prev === carouselList.length ? 0 : prev + 1)); }; @@ -25,9 +25,16 @@ const Carousel = ({ carouselList }: CarouselProps) => { return ( - + {extendedCarouselList.map(({ id, children }) => ( - {children} + + {children} + ))} @@ -38,17 +45,16 @@ export default Carousel; const CarouselContainer = styled.div` display: flex; - width: ${CAROUSEL_WIDTH}px; + width: 100%; + border: 1px solid ${({ theme }) => theme.colors.gray2}; + border-radius: 10px; overflow: hidden; `; -const CarouselWrapper = styled.ul<{ currentIndex: number }>` +const CarouselWrapper = styled.ul` display: flex; - transform: ${({ currentIndex }) => 'translateX(-' + currentIndex * CAROUSEL_WIDTH + 'px)'}; - transition: ${({ currentIndex }) => (currentIndex === length - 1 ? '' : 'all 0.5s ease-in-out')}; `; const CarouselItem = styled.li` - width: ${CAROUSEL_WIDTH}px; height: fit-content; `; diff --git a/frontend/src/components/Common/CategoryItem/CategoryItem.stories.tsx b/frontend/src/components/Common/CategoryItem/CategoryItem.stories.tsx new file mode 100644 index 000000000..b6158f5b6 --- /dev/null +++ b/frontend/src/components/Common/CategoryItem/CategoryItem.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CategoryItem from './CategoryItem'; + +const meta: Meta = { + title: 'common/CategoryItem', + component: CategoryItem, + args: { + name: '즉석 식품', + image: 'https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/CategoryItem/CategoryItem.tsx b/frontend/src/components/Common/CategoryItem/CategoryItem.tsx new file mode 100644 index 000000000..4ce54ce72 --- /dev/null +++ b/frontend/src/components/Common/CategoryItem/CategoryItem.tsx @@ -0,0 +1,48 @@ +import { Button } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +interface CategoryItemProps { + name: string; + image: string; +} + +const CategoryItem = ({ name, image }: CategoryItemProps) => { + return ( + + + {name} + + {name} + + ); +}; + +export default CategoryItem; + +const CategoryItemContainer = styled(Button)` + width: 60px; + height: 100px; + text-align: center; +`; + +const ImageWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 60px; + height: 60px; + border-radius: 10px; + background: ${({ theme }) => theme.colors.white}; + + & > img { + width: 100%; + height: auto; + object-fit: cover; + } +`; + +const CategoryName = styled.p` + margin-top: 10px; + font-weight: 600; + font-size: ${({ theme }) => theme.fontSizes.xs}; +`; diff --git a/frontend/src/components/Common/CategoryList/CategoryList.stories.tsx b/frontend/src/components/Common/CategoryList/CategoryList.stories.tsx new file mode 100644 index 000000000..ca76f7b46 --- /dev/null +++ b/frontend/src/components/Common/CategoryList/CategoryList.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CategoryList from './CategoryList'; + +const meta: Meta = { + title: 'common/CategoryList', + component: CategoryList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/CategoryList/CategoryList.tsx b/frontend/src/components/Common/CategoryList/CategoryList.tsx new file mode 100644 index 000000000..675ab02fe --- /dev/null +++ b/frontend/src/components/Common/CategoryList/CategoryList.tsx @@ -0,0 +1,50 @@ +import { Link } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import CategoryItem from '../CategoryItem/CategoryItem'; + +import { CATEGORY_TYPE } from '@/constants'; +import { useCategoryQuery } from '@/hooks/queries/product'; + +interface CategoryListProps { + menuVariant: keyof typeof CATEGORY_TYPE; +} + +const CategoryList = ({ menuVariant }: CategoryListProps) => { + const { data: categories } = useCategoryQuery(CATEGORY_TYPE[menuVariant]); + + return ( + + + {categories.map((menu) => ( + + + + ))} + + + ); +}; + +export default CategoryList; + +const CategoryListContainer = styled.div` + overflow-x: auto; + overflow-y: hidden; + + @media screen and (min-width: 500px) { + display: flex; + flex-direction: column; + align-items: center; + } + + &::-webkit-scrollbar { + display: none; + } +`; + +const CategoryListWrapper = styled.div` + display: flex; + gap: 20px; +`; diff --git a/frontend/src/components/Common/CategoryMenu/CategoryMenu.stories.tsx b/frontend/src/components/Common/CategoryTab/CategoryTab.stories.tsx similarity index 56% rename from frontend/src/components/Common/CategoryMenu/CategoryMenu.stories.tsx rename to frontend/src/components/Common/CategoryTab/CategoryTab.stories.tsx index b43814b70..45bfac20d 100644 --- a/frontend/src/components/Common/CategoryMenu/CategoryMenu.stories.tsx +++ b/frontend/src/components/Common/CategoryTab/CategoryTab.stories.tsx @@ -1,14 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react'; -import CategoryMenu from './CategoryMenu'; +import CategoryTab from './CategoryTab'; -const meta: Meta = { - title: 'common/CategoryMenu', - component: CategoryMenu, +const meta: Meta = { + title: 'common/CategoryTab', + component: CategoryTab, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const FoodCategory: Story = { args: { diff --git a/frontend/src/components/Common/CategoryMenu/CategoryMenu.tsx b/frontend/src/components/Common/CategoryTab/CategoryTab.tsx similarity index 74% rename from frontend/src/components/Common/CategoryMenu/CategoryMenu.tsx rename to frontend/src/components/Common/CategoryTab/CategoryTab.tsx index b262b8acd..8796c65f1 100644 --- a/frontend/src/components/Common/CategoryMenu/CategoryMenu.tsx +++ b/frontend/src/components/Common/CategoryTab/CategoryTab.tsx @@ -1,8 +1,10 @@ import { Button, theme } from '@fun-eat/design-system'; +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import type { CSSProp } from 'styled-components'; import styled from 'styled-components'; -import { useCategoryContext } from '@/hooks/context'; +import { useCategoryValueContext, useCategoryActionContext } from '@/hooks/context'; import { useCategoryQuery } from '@/hooks/queries/product'; import type { CategoryVariant } from '@/types/common'; @@ -10,12 +12,23 @@ interface CategoryMenuProps { menuVariant: CategoryVariant; } -const CategoryMenu = ({ menuVariant }: CategoryMenuProps) => { +const CategoryTab = ({ menuVariant }: CategoryMenuProps) => { const { data: categories } = useCategoryQuery(menuVariant); - const { categoryIds, selectCategory } = useCategoryContext(); + const { categoryIds } = useCategoryValueContext(); + const { selectCategory } = useCategoryActionContext(); const currentCategoryId = categoryIds[menuVariant]; + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const categoryIdFromURL = queryParams.get('category'); + + useEffect(() => { + if (categoryIdFromURL) { + selectCategory(menuVariant, parseInt(categoryIdFromURL)); + } + }, [location]); + return ( {categories.map((menu) => { @@ -43,7 +56,7 @@ const CategoryMenu = ({ menuVariant }: CategoryMenuProps) => { ); }; -export default CategoryMenu; +export default CategoryTab; type CategoryMenuStyleProps = Pick; diff --git a/frontend/src/components/Common/Header/Header.tsx b/frontend/src/components/Common/Header/Header.tsx index a52adaf21..ae2431ed8 100644 --- a/frontend/src/components/Common/Header/Header.tsx +++ b/frontend/src/components/Common/Header/Header.tsx @@ -2,14 +2,33 @@ import { Link } from '@fun-eat/design-system'; import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; +import SvgIcon from '../Svg/SvgIcon'; + import Logo from '@/assets/logo.svg'; import { PATH } from '@/constants/path'; -const Header = () => { +interface HeaderProps { + hasSearch?: boolean; +} + +const Header = ({ hasSearch = true }: HeaderProps) => { + if (hasSearch) { + return ( + + + + + + + + + ); + } + return ( - + ); @@ -17,6 +36,15 @@ const Header = () => { export default Header; +const HeaderWithSearchContainer = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + width: calc(100% - 40px); + height: 60px; + margin: 0 auto; +`; + const HeaderContainer = styled.header` display: flex; justify-content: center; diff --git a/frontend/src/components/Common/ScrollButton/ScrollButton.tsx b/frontend/src/components/Common/ScrollButton/ScrollButton.tsx index 09827b2b4..6bfcc5095 100644 --- a/frontend/src/components/Common/ScrollButton/ScrollButton.tsx +++ b/frontend/src/components/Common/ScrollButton/ScrollButton.tsx @@ -1,5 +1,5 @@ import { Button } from '@fun-eat/design-system'; -import { useState, useEffect } from 'react'; +import type { RefObject } from 'react'; import { styled } from 'styled-components'; import SvgIcon from '../Svg/SvgIcon'; @@ -7,24 +7,18 @@ import SvgIcon from '../Svg/SvgIcon'; import { useScroll } from '@/hooks/common'; interface ScrollButtonProps { + targetRef: RefObject; isRecipePage?: boolean; } -const ScrollButton = ({ isRecipePage = false }: ScrollButtonProps) => { +const ScrollButton = ({ targetRef, isRecipePage = false }: ScrollButtonProps) => { const { scrollToTop } = useScroll(); - const [scrollTop, setScrollTop] = useState(false); const handleScroll = () => { - setScrollTop(true); - }; - - useEffect(() => { - const mainElement = document.getElementById('main'); - if (mainElement) { - scrollToTop(mainElement); - setScrollTop(false); + if (targetRef) { + scrollToTop(targetRef); } - }, [scrollTop]); + }; return ( { customWidth="45px" customHeight="45px" variant="filled" - color="gray5" + color="white" onClick={handleScroll} > - + ); }; export default ScrollButton; -const ScrollButtonWrapper = styled(Button)` +const ScrollButtonWrapper = styled(Button)>` position: fixed; bottom: ${({ isRecipePage }) => (isRecipePage ? '210px' : '90px')}; right: 20px; border-radius: 50%; + box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 4px; @media screen and (min-width: 600px) { left: calc(50% + 234px); @@ -56,4 +51,8 @@ const ScrollButtonWrapper = styled(Button)` transform: scale(1.1); transition: all 200ms ease-in-out; } + + svg { + rotate: 90deg; + } `; diff --git a/frontend/src/components/Common/TabMenu/TabMenu.tsx b/frontend/src/components/Common/TabMenu/TabMenu.tsx index 44cd8d012..548b2f36b 100644 --- a/frontend/src/components/Common/TabMenu/TabMenu.tsx +++ b/frontend/src/components/Common/TabMenu/TabMenu.tsx @@ -5,18 +5,26 @@ import styled from 'styled-components'; interface TabMenuProps { tabMenus: readonly string[]; - selectedTabMenu: string; - handleTabMenuSelect: MouseEventHandler; + selectedTabMenu: number; + handleTabMenuSelect: (index: number) => void; } const TabMenu = ( { tabMenus, selectedTabMenu, handleTabMenuSelect }: TabMenuProps, ref: ForwardedRef ) => { + const handleTabMenuClick: MouseEventHandler = (event) => { + const { index } = event.currentTarget.dataset; + + if (index) { + handleTabMenuSelect(Number(index)); + } + }; + return ( - {tabMenus.map((menu) => { - const isSelected = selectedTabMenu === menu; + {tabMenus.map((menu, index) => { + const isSelected = selectedTabMenu === index; return ( {menu} diff --git a/frontend/src/components/Common/Title/Title.stories.tsx b/frontend/src/components/Common/Title/Title.stories.tsx deleted file mode 100644 index 9f455c1a5..000000000 --- a/frontend/src/components/Common/Title/Title.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import Title from './Title'; - -const meta: Meta = { - title: 'common/Title', - component: Title, - args: { - headingTitle: '상품 목록', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/components/Common/Title/Title.tsx b/frontend/src/components/Common/Title/Title.tsx deleted file mode 100644 index 96aa3e7ef..000000000 --- a/frontend/src/components/Common/Title/Title.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Link, Text, theme } from '@fun-eat/design-system'; -import { Link as RouterLink } from 'react-router-dom'; -import styled from 'styled-components'; - -import SvgIcon from '../Svg/SvgIcon'; - -import { PATH } from '@/constants/path'; - -interface TitleProps { - headingTitle: string; - routeDestination: string; -} - -const Title = ({ headingTitle, routeDestination }: TitleProps) => { - return ( - - - - - - - {headingTitle} - - - - - ); -}; - -export default Title; - -const TitleContainer = styled.div` - position: relative; - display: flex; - flex-direction: row; - justify-content: center; -`; - -const HomeLink = styled(Link)` - position: absolute; - top: 8px; - left: 0; -`; - -const TitleLink = styled(Link)` - display: flex; - gap: 20px; - align-items: center; -`; - -const DropDownIcon = styled(SvgIcon)` - rotate: 270deg; -`; diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts index 395277010..493fe72bb 100644 --- a/frontend/src/components/Common/index.ts +++ b/frontend/src/components/Common/index.ts @@ -1,4 +1,4 @@ -export { default as CategoryMenu } from './CategoryMenu/CategoryMenu'; +export { default as CategoryTab } from './CategoryTab/CategoryTab'; export { default as Header } from './Header/Header'; export { default as NavigationBar } from './NavigationBar/NavigationBar'; export { default as SortButton } from './SortButton/SortButton'; @@ -7,7 +7,6 @@ export { default as SvgSprite } from './Svg/SvgSprite'; export { default as SvgIcon } from './Svg/SvgIcon'; export { default as TabMenu } from './TabMenu/TabMenu'; export { default as TagList } from './TagList/TagList'; -export { default as Title } from './Title/Title'; export { default as SectionTitle } from './SectionTitle/SectionTitle'; export { default as ScrollButton } from './ScrollButton/ScrollButton'; export { default as Input } from './Input/Input'; @@ -20,3 +19,5 @@ export { default as MoreButton } from './MoreButton/MoreButton'; export { default as NavigableSectionTitle } from './NavigableSectionTitle/NavigableSectionTitle'; export { default as Carousel } from './Carousel/Carousel'; export { default as RegisterButton } from './RegisterButton/RegisterButton'; +export { default as CategoryItem } from './CategoryItem/CategoryItem'; +export { default as CategoryList } from './CategoryList/CategoryList'; diff --git a/frontend/src/components/Layout/AuthLayout.tsx b/frontend/src/components/Layout/AuthLayout.tsx index e65109b6b..0cc0570c2 100644 --- a/frontend/src/components/Layout/AuthLayout.tsx +++ b/frontend/src/components/Layout/AuthLayout.tsx @@ -1,10 +1,13 @@ -import type { PropsWithChildren } from 'react'; import { Navigate } from 'react-router-dom'; import { PATH } from '@/constants/path'; import { useMemberQuery } from '@/hooks/queries/members'; -const AuthLayout = ({ children }: PropsWithChildren) => { +interface AuthLayoutProps { + children: JSX.Element; +} + +const AuthLayout = ({ children }: AuthLayoutProps) => { const { data: member } = useMemberQuery(); if (!member) { diff --git a/frontend/src/components/Layout/DefaultLayout.tsx b/frontend/src/components/Layout/DefaultLayout.tsx index fe6391912..39f1f8cff 100644 --- a/frontend/src/components/Layout/DefaultLayout.tsx +++ b/frontend/src/components/Layout/DefaultLayout.tsx @@ -25,7 +25,6 @@ const DefaultLayoutContainer = styled.div` const MainWrapper = styled.main` position: relative; height: calc(100% - 120px); - padding: 20px; overflow-x: hidden; overflow-y: auto; `; diff --git a/frontend/src/components/Layout/SimpleHeaderLayout.tsx b/frontend/src/components/Layout/SimpleHeaderLayout.tsx new file mode 100644 index 000000000..4f17b93a2 --- /dev/null +++ b/frontend/src/components/Layout/SimpleHeaderLayout.tsx @@ -0,0 +1,31 @@ +import type { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +import Header from '../Common/Header/Header'; +import NavigationBar from '../Common/NavigationBar/NavigationBar'; + +const SimpleHeaderLayout = ({ children }: PropsWithChildren) => { + return ( + +
+ {children} + + + ); +}; + +export default SimpleHeaderLayout; + +const SimpleHeaderLayoutContainer = styled.div` + height: 100%; + max-width: 600px; + margin: 0 auto; +`; + +const MainWrapper = styled.main` + position: relative; + height: calc(100% - 120px); + padding: 20px 20px 0; + overflow-x: hidden; + overflow-y: auto; +`; diff --git a/frontend/src/components/Layout/index.ts b/frontend/src/components/Layout/index.ts index 32a1e00e0..69ec1a40c 100644 --- a/frontend/src/components/Layout/index.ts +++ b/frontend/src/components/Layout/index.ts @@ -2,3 +2,4 @@ export { default as DefaultLayout } from './DefaultLayout'; export { default as MinimalLayout } from './MinimalLayout'; export { default as HeaderOnlyLayout } from './HeaderOnlyLayout'; export { default as AuthLayout } from './AuthLayout'; +export { default as SimpleHeaderLayout } from './SimpleHeaderLayout'; diff --git a/frontend/src/components/Members/MemberModifyInput/MemberModifyInput.tsx b/frontend/src/components/Members/MemberModifyInput/MemberModifyInput.tsx new file mode 100644 index 000000000..39a78e6c5 --- /dev/null +++ b/frontend/src/components/Members/MemberModifyInput/MemberModifyInput.tsx @@ -0,0 +1,48 @@ +import { Heading, Spacing, Text, useTheme } from '@fun-eat/design-system'; +import type { ChangeEventHandler } from 'react'; +import styled from 'styled-components'; + +import { Input } from '@/components/Common'; + +const MIN_LENGTH = 1; +const MAX_LENGTH = 10; + +interface MemberModifyInputProps { + nickname: string; + modifyNickname: ChangeEventHandler; +} + +const MemberModifyInput = ({ nickname, modifyNickname }: MemberModifyInputProps) => { + const theme = useTheme(); + + return ( + + + 닉네임 + + + {nickname.length}자 / {MAX_LENGTH}자 + + + + + ); +}; + +export default MemberModifyInput; + +const MemberModifyInputContainer = styled.div` + position: relative; +`; + +const NicknameStatusText = styled(Text)` + position: absolute; + top: 0; + right: 0; +`; diff --git a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx index 0c150018d..50398fedf 100644 --- a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx +++ b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx @@ -48,7 +48,13 @@ const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { {reviewsToDisplay.map((reviewRanking) => (
  • - + + +
  • ))}
    diff --git a/frontend/src/components/Members/index.ts b/frontend/src/components/Members/index.ts index 78d0544d2..a295e2728 100644 --- a/frontend/src/components/Members/index.ts +++ b/frontend/src/components/Members/index.ts @@ -1,3 +1,4 @@ export { default as MembersInfo } from './MembersInfo/MembersInfo'; export { default as MemberReviewList } from './MemberReviewList/MemberReviewList'; export { default as MemberRecipeList } from './MemberRecipeList/MemberRecipeList'; +export { default as MemberModifyInput } from './MemberModifyInput/MemberModifyInput'; diff --git a/frontend/src/components/Product/PBProductList/PBProductList.tsx b/frontend/src/components/Product/PBProductList/PBProductList.tsx index 105415ff7..576ea05a4 100644 --- a/frontend/src/components/Product/PBProductList/PBProductList.tsx +++ b/frontend/src/components/Product/PBProductList/PBProductList.tsx @@ -6,25 +6,19 @@ import PBProductItem from '../PBProductItem/PBProductItem'; import { MoreButton } from '@/components/Common'; import { PATH } from '@/constants/path'; -import { useCategoryContext } from '@/hooks/context'; +import { useCategoryValueContext } from '@/hooks/context'; import { useInfiniteProductsQuery } from '@/hooks/queries/product'; -import displaySlice from '@/utils/displaySlice'; -interface PBProductListProps { - isHomePage?: boolean; -} - -const PBProductList = ({ isHomePage }: PBProductListProps) => { - const { categoryIds } = useCategoryContext(); +const PBProductList = () => { + const { categoryIds } = useCategoryValueContext(); const { data: pbProductListResponse } = useInfiniteProductsQuery(categoryIds.store); const pbProducts = pbProductListResponse.pages.flatMap((page) => page.products); - const pbProductsToDisplay = displaySlice(isHomePage, pbProducts, 10); return ( <> - {pbProductsToDisplay.map((pbProduct) => ( + {pbProducts.map((pbProduct) => (
  • diff --git a/frontend/src/components/Product/ProductItem/ProductItem.tsx b/frontend/src/components/Product/ProductItem/ProductItem.tsx index 64f4172f6..6fb9ce673 100644 --- a/frontend/src/components/Product/ProductItem/ProductItem.tsx +++ b/frontend/src/components/Product/ProductItem/ProductItem.tsx @@ -16,7 +16,7 @@ const ProductItem = ({ product }: ProductItemProps) => { return ( {image !== null ? ( - {`${name}사진`} + ) : ( )} @@ -54,6 +54,10 @@ const ProductItemContainer = styled.div` padding: 12px 0; `; +const ProductImage = styled.img` + object-fit: cover; +`; + const ProductInfoWrapper = styled.div` display: flex; flex-direction: column; diff --git a/frontend/src/components/Product/ProductList/ProductList.tsx b/frontend/src/components/Product/ProductList/ProductList.tsx index 63e2cbaac..114405aab 100644 --- a/frontend/src/components/Product/ProductList/ProductList.tsx +++ b/frontend/src/components/Product/ProductList/ProductList.tsx @@ -7,35 +7,32 @@ import ProductItem from '../ProductItem/ProductItem'; import { PATH } from '@/constants/path'; import { useIntersectionObserver } from '@/hooks/common'; -import { useCategoryContext } from '@/hooks/context'; +import { useCategoryValueContext } from '@/hooks/context'; import { useInfiniteProductsQuery } from '@/hooks/queries/product'; import type { CategoryVariant, SortOption } from '@/types/common'; -import displaySlice from '@/utils/displaySlice'; interface ProductListProps { category: CategoryVariant; - isHomePage?: boolean; selectedOption?: SortOption; } -const ProductList = ({ category, isHomePage, selectedOption }: ProductListProps) => { +const ProductList = ({ category, selectedOption }: ProductListProps) => { const scrollRef = useRef(null); - - const { categoryIds } = useCategoryContext(); + const { categoryIds } = useCategoryValueContext(); const { fetchNextPage, hasNextPage, data } = useInfiniteProductsQuery( categoryIds[category], selectedOption?.value ?? 'reviewCount,desc' ); - const productList = data.pages.flatMap((page) => page.products); - const productsToDisplay = displaySlice(isHomePage, productList); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); + const productList = data.pages.flatMap((page) => page.products); + return ( <> - {productsToDisplay.map((product) => ( + {productList.map((product) => (
  • @@ -47,6 +44,7 @@ const ProductList = ({ category, isHomePage, selectedOption }: ProductListProps) ); }; + export default ProductList; const ProductListContainer = styled.ul` diff --git a/frontend/src/components/Product/ProductTitle/ProductTitle.stories.tsx b/frontend/src/components/Product/ProductTitle/ProductTitle.stories.tsx new file mode 100644 index 000000000..fbd2b1ed7 --- /dev/null +++ b/frontend/src/components/Product/ProductTitle/ProductTitle.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ProductTitle from './ProductTitle'; + +const meta: Meta = { + title: 'common/ProductTitle', + component: ProductTitle, + args: { + content: '상품 목록', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Product/ProductTitle/ProductTitle.tsx b/frontend/src/components/Product/ProductTitle/ProductTitle.tsx new file mode 100644 index 000000000..be0e499f1 --- /dev/null +++ b/frontend/src/components/Product/ProductTitle/ProductTitle.tsx @@ -0,0 +1,51 @@ +import { Heading, Link, theme } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import SvgIcon from '../../Common/Svg/SvgIcon'; + +import { PATH } from '@/constants/path'; + +interface ProductTitleProps { + content: string; + routeDestination: string; +} + +const ProductTitle = ({ content, routeDestination }: ProductTitleProps) => { + return ( + + + {content} + + + + + + + ); +}; + +export default ProductTitle; + +const ProductTitleContainer = styled.div` + position: relative; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ProductTitleLink = styled(Link)` + display: flex; + gap: 20px; + align-items: center; + margin-left: 36%; +`; + +const HeadingTitle = styled(Heading)` + font-size: 2.4rem; +`; + +const DropDownIcon = styled(SvgIcon)` + rotate: 270deg; +`; diff --git a/frontend/src/components/Product/index.ts b/frontend/src/components/Product/index.ts index 4038ee740..47ce2703d 100644 --- a/frontend/src/components/Product/index.ts +++ b/frontend/src/components/Product/index.ts @@ -4,3 +4,4 @@ export { default as ProductList } from './ProductList/ProductList'; export { default as ProductOverviewItem } from './ProductOverviewItem/ProductOverviewItem'; export { default as PBProductList } from './PBProductList/PBProductList'; export { default as ProductRecipeList } from './ProductRecipeList/ProductRecipeList'; +export { default as ProductTitle } from './ProductTitle/ProductTitle'; diff --git a/frontend/src/components/Rank/RecipeRankingItem/RecipeRankingItem.tsx b/frontend/src/components/Rank/RecipeRankingItem/RecipeRankingItem.tsx index 2573169fc..b9ac0cab6 100644 --- a/frontend/src/components/Rank/RecipeRankingItem/RecipeRankingItem.tsx +++ b/frontend/src/components/Rank/RecipeRankingItem/RecipeRankingItem.tsx @@ -24,14 +24,13 @@ const RecipeRankingItem = ({ rank, recipe }: RecipeRankingItemProps) => { - {rank} {image !== null ? ( ) : ( )} - + {title} @@ -57,16 +56,15 @@ export default RecipeRankingItem; const RecipeRankingItemContainer = styled.div` width: calc(100% - 50px); - height: 72px; max-width: 560px; margin: 12px 0; - padding: 0 24px; + padding: 0 5px; `; const RecipeRankingWrapper = styled.div` display: flex; justify-content: space-between; - width: 100%; + width: 95%; `; const RankingRecipeWrapper = styled.div` diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx index 59f7425f6..ca874ebf6 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx @@ -14,6 +14,7 @@ const meta: Meta = { '할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다', rating: 4.0, favoriteCount: 1256, + categoryType: 'food', }, }, }; diff --git a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx index 2f3079cd7..9621c40ce 100644 --- a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx +++ b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx @@ -1,7 +1,10 @@ +import { Link } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; import ReviewRankingItem from '../ReviewRankingItem/ReviewRankingItem'; +import { PATH } from '@/constants/path'; import { useReviewRankingQuery } from '@/hooks/queries/rank'; import useDisplaySlice from '@/utils/displaySlice'; @@ -17,7 +20,13 @@ const ReviewRankingList = ({ isHomePage = false }: ReviewRankingListProps) => { {reviewsToDisplay.map((reviewRanking) => (
  • - + + +
  • ))} diff --git a/frontend/src/components/Recipe/RecipeNameInput/RecipeNameInput.tsx b/frontend/src/components/Recipe/RecipeNameInput/RecipeNameInput.tsx index 3ac8c2eaf..3ab0d8bc8 100644 --- a/frontend/src/components/Recipe/RecipeNameInput/RecipeNameInput.tsx +++ b/frontend/src/components/Recipe/RecipeNameInput/RecipeNameInput.tsx @@ -1,35 +1,59 @@ -import { Heading, Spacing } from '@fun-eat/design-system'; +import { Heading, Spacing, Text, useTheme } from '@fun-eat/design-system'; import type { ChangeEventHandler } from 'react'; import styled from 'styled-components'; import { Input } from '@/components/Common'; import { useRecipeFormActionContext } from '@/hooks/context'; +const MIN_LENGTH = 1; +const MAX_LENGTH = 15; interface RecipeNameInputProps { recipeName: string; } const RecipeNameInput = ({ recipeName }: RecipeNameInputProps) => { const { handleRecipeFormValue } = useRecipeFormActionContext(); + const theme = useTheme(); const handleRecipeName: ChangeEventHandler = (e) => { handleRecipeFormValue({ target: 'title', value: e.currentTarget.value }); }; return ( - <> + 꿀조합 이름 * + + {recipeName.length}자 / {MAX_LENGTH}자 + - - + + ); }; export default RecipeNameInput; +const RecipeNameInputContainer = styled.div` + position: relative; + width: 300px; +`; + const RequiredMark = styled.sup` color: ${({ theme }) => theme.colors.error}; `; + +const RecipeNameStatusText = styled(Text)` + position: absolute; + top: 0; + right: 0; + line-height: 28px; +`; diff --git a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx index 96f13fad4..85caf35ca 100644 --- a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx +++ b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx @@ -31,7 +31,7 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => { formContent: recipeFormValue, }); - const { mutate } = useRecipeRegisterFormMutation(); + const { mutate, isLoading } = useRecipeRegisterFormMutation(); const isValid = recipeFormValue.title.length > 0 && recipeFormValue.content.length > 0 && recipeFormValue.productIds.length > 0; @@ -90,7 +90,7 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => { [작성시 유의사항] 신뢰성 확보에 저해되는 게시물은 삭제하거나 보이지 않게 할 수 있습니다. - + 레시피 등록하기 diff --git a/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx b/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx index 5956d2cae..32207d184 100644 --- a/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx +++ b/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx @@ -24,9 +24,10 @@ interface ReviewRegisterFormProps { productId: number; targetRef: RefObject; closeReviewDialog: () => void; + initTabMenu: () => void; } -const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog }: ReviewRegisterFormProps) => { +const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMenu }: ReviewRegisterFormProps) => { const { scrollToPosition } = useScroll(); const { previewImage, imageFile, uploadImage, deleteImage } = useImageUploader(); @@ -34,7 +35,7 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog }: ReviewR const { resetReviewFormValue } = useReviewFormActionContext(); const { data: productDetail } = useProductDetailQuery(productId); - const { mutate } = useReviewRegisterFormMutation(productId); + const { mutate, isLoading } = useReviewRegisterFormMutation(productId); const isValid = reviewFormValue.rating > MIN_RATING_SCORE && @@ -61,6 +62,7 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog }: ReviewR mutate(formData, { onSuccess: () => { resetAndCloseForm(); + initTabMenu(); scrollToPosition(targetRef); }, onError: (error) => { @@ -108,10 +110,17 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog }: ReviewR - [작성시 유의사항] 신뢰성 확보에 저해되는 게시물은 삭제하거나 보이지 않게 할 수 있습니다.{' '} + [작성시 유의사항] 신뢰성 확보에 저해되는 게시물은 삭제하거나 보이지 않게 할 수 있습니다. - + {isValid ? '리뷰 등록하기' : '꼭 입력해야 하는 항목이 있어요'} diff --git a/frontend/src/components/Review/ReviewTagList/ReviewTagList.tsx b/frontend/src/components/Review/ReviewTagList/ReviewTagList.tsx index 6d093b78c..54c717be4 100644 --- a/frontend/src/components/Review/ReviewTagList/ReviewTagList.tsx +++ b/frontend/src/components/Review/ReviewTagList/ReviewTagList.tsx @@ -24,26 +24,24 @@ const ReviewTagList = ({ selectedTags }: ReviewTagListProps) => { - {tagsData.map(({ tagType, tags }) => { - return ( - - - {TAG_TITLE[tagType]} - - -
      - {tags.slice(0, minDisplayedTags).map(({ id, name }) => ( - <> -
    • - -
    • - - - ))} -
    -
    - ); - })} + {tagsData.map(({ tagType, tags }) => ( + + + {TAG_TITLE[tagType]} + + +
      + {tags.slice(0, minDisplayedTags).map(({ id, name }) => ( + <> +
    • + +
    • + + + ))} +
    +
    + ))}
    {canShowMore && ( @@ -82,14 +80,6 @@ const TagListWrapper = styled.div` column-gap: 20px; overflow-x: auto; - @media screen and (min-width: 420px) { - justify-content: center; - - & > div { - flex-grow: 0; - } - } - &::-webkit-scrollbar { display: none; } @@ -97,11 +87,21 @@ const TagListWrapper = styled.div` & > div { flex-grow: 1; } + + @media screen and (min-width: 420px) { + justify-content: center; + + & > div { + flex-grow: 0; + } + } `; const TagItemWrapper = styled.div` display: flex; flex-direction: column; + align-items: center; + min-width: 100px; `; const TagTitle = styled(Heading)` diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index a45e4103f..81b964c6c 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -3,11 +3,6 @@ import { PATH } from './path'; import type { NavigationMenu } from '@/types/common'; export const NAVIGATION_MENU: NavigationMenu[] = [ - { - variant: 'search', - name: '검색', - path: PATH.SEARCH, - }, { variant: 'list', name: '목록', @@ -58,7 +53,8 @@ export const TAG_TITLE = { export const MIN_DISPLAYED_TAGS_LENGTH = 3; -export const SEARCH_PAGE_TABS = ['상품', '꿀조합'] as const; +export const SEARCH_TAB_VARIANTS = ['상품', '꿀조합']; +export const SEARCH_PAGE_VARIANTS = { products: '상품', recipes: '꿀조합', integrated: '통합' } as const; export const CATEGORY_TYPE = { FOOD: 'food', @@ -68,3 +64,6 @@ export const CATEGORY_TYPE = { export const IMAGE_MAX_SIZE = 5 * 1024 * 1024; export const ENVIRONMENT = window.location.href.includes('dev') ? 'dev' : 'prod'; + +export const IMAGE_URL = + ENVIRONMENT === 'dev' ? process.env.S3_DEV_CLOUDFRONT_PATH : process.env.S3_PROD_CLOUDFRONT_PATH; diff --git a/frontend/src/contexts/CategoryContext.tsx b/frontend/src/contexts/CategoryContext.tsx index a7ceb895c..e77029003 100644 --- a/frontend/src/contexts/CategoryContext.tsx +++ b/frontend/src/contexts/CategoryContext.tsx @@ -8,33 +8,50 @@ const initialState = { store: 7, }; -type CategoryIds = { +export type CategoryIds = { [k in CategoryVariant]: number; }; -interface CategoryState { +interface CategoryValue { categoryIds: CategoryIds; + currentTabScroll: { [key: number]: number }; +} + +interface CategoryAction { selectCategory: (menuVariant: string, categoryId: number) => void; + saveCurrentTabScroll: (categoryId: number, scrollY: number) => void; } -export const CategoryContext = createContext({ - categoryIds: initialState, - selectCategory: () => {}, -}); +export const CategoryValueContext = createContext(null); +export const CategoryActionContext = createContext(null); const CategoryProvider = ({ children }: PropsWithChildren) => { const [categoryIds, setCategoryIds] = useState(initialState); + const [currentTabScroll, setCurrentTabScroll] = useState({}); const selectCategory = (menuVariant: string, categoryId: number) => { setCategoryIds((prevCategory) => ({ ...prevCategory, [menuVariant]: categoryId })); }; - const categoryState: CategoryState = { + const saveCurrentTabScroll = (categoryId: number, scrollY: number) => { + setCurrentTabScroll((prevState) => ({ ...prevState, [categoryId]: scrollY })); + }; + + const categoryValue = { categoryIds, + currentTabScroll, + }; + + const categoryAction = { selectCategory, + saveCurrentTabScroll, }; - return {children}; + return ( + + {children} + + ); }; export default CategoryProvider; diff --git a/frontend/src/hooks/common/index.ts b/frontend/src/hooks/common/index.ts index 0f4756f2a..5c0ce9f37 100644 --- a/frontend/src/hooks/common/index.ts +++ b/frontend/src/hooks/common/index.ts @@ -8,3 +8,5 @@ export { default as useImageUploader } from './useImageUploader'; export { default as useFormData } from './useFormData'; export { default as useTimeout } from './useTimeout'; export { default as useRouteChangeTracker } from './useRouteChangeTracker'; +export { default as useTabMenu } from './useTabMenu'; +export { default as useScrollRestoration } from './useScrollRestoration'; diff --git a/frontend/src/hooks/common/useScroll.ts b/frontend/src/hooks/common/useScroll.ts index cfcd52a5e..b340dcf22 100644 --- a/frontend/src/hooks/common/useScroll.ts +++ b/frontend/src/hooks/common/useScroll.ts @@ -1,13 +1,18 @@ import type { RefObject } from 'react'; const useScroll = () => { - const scrollToTop = (mainElement: HTMLElement) => { - mainElement.scrollTo(0, 0); + const scrollToTop = (ref: RefObject) => { + if (ref.current) { + ref.current.scrollTo(0, 0); + } }; - const scrollToPosition = (ref?: RefObject) => { - setTimeout(() => { - ref?.current?.scrollIntoView({ behavior: 'smooth' }); + const scrollToPosition = (ref: RefObject) => { + const timeout = setTimeout(() => { + if (ref.current) { + ref.current.scrollIntoView({ behavior: 'smooth' }); + clearTimeout(timeout); + } }, 100); }; diff --git a/frontend/src/hooks/common/useScrollRestoration.ts b/frontend/src/hooks/common/useScrollRestoration.ts new file mode 100644 index 000000000..6f3d00bf9 --- /dev/null +++ b/frontend/src/hooks/common/useScrollRestoration.ts @@ -0,0 +1,36 @@ +import type { RefObject } from 'react'; +import { useEffect } from 'react'; + +import useTimeout from './useTimeout'; +import { useCategoryActionContext, useCategoryValueContext } from '../context'; + +const useScrollRestoration = (currentCategoryId: number, ref: RefObject) => { + const { saveCurrentTabScroll } = useCategoryActionContext(); + const { currentTabScroll } = useCategoryValueContext(); + + const handleScroll = () => { + if (!ref.current) { + return; + } + saveCurrentTabScroll(currentCategoryId, ref.current.scrollTop); + }; + + const [timeoutFn] = useTimeout(handleScroll, 300); + + useEffect(() => { + if (!ref.current) { + return; + } + + ref.current.addEventListener('scroll', timeoutFn); + + const scrollY = currentTabScroll[currentCategoryId]; + ref.current.scrollTo(0, scrollY); + + return () => { + ref.current?.removeEventListener('scroll', timeoutFn); + }; + }, [currentCategoryId]); +}; + +export default useScrollRestoration; diff --git a/frontend/src/hooks/common/useTabMenu.ts b/frontend/src/hooks/common/useTabMenu.ts new file mode 100644 index 000000000..f778e5348 --- /dev/null +++ b/frontend/src/hooks/common/useTabMenu.ts @@ -0,0 +1,26 @@ +import { useState } from 'react'; + +const INIT_TAB_INDEX = 0; + +const useTabMenu = () => { + const [selectedTabMenu, setSelectedTabMenu] = useState(INIT_TAB_INDEX); + + const isFirstTabMenu = selectedTabMenu === INIT_TAB_INDEX; + + const handleTabMenuClick = (index: number) => { + setSelectedTabMenu(index); + }; + + const initTabMenu = () => { + setSelectedTabMenu(INIT_TAB_INDEX); + }; + + return { + selectedTabMenu, + isFirstTabMenu, + handleTabMenuClick, + initTabMenu, + }; +}; + +export default useTabMenu; diff --git a/frontend/src/hooks/context/index.ts b/frontend/src/hooks/context/index.ts index a0a01b373..56470cfbb 100644 --- a/frontend/src/hooks/context/index.ts +++ b/frontend/src/hooks/context/index.ts @@ -1,4 +1,5 @@ -export { default as useCategoryContext } from './useCategoryContext'; +export { default as useCategoryValueContext } from './useCategoryValueContext'; +export { default as useCategoryActionContext } from './useCategoryActionContext'; export { default as useReviewFormActionContext } from './useReviewFormActionContext'; export { default as useReviewFormValueContext } from './useReviewFormValueContext'; export { default as useRecipeFormActionContext } from './useRecipeFormActionContext'; diff --git a/frontend/src/hooks/context/useCategoryActionContext.ts b/frontend/src/hooks/context/useCategoryActionContext.ts new file mode 100644 index 000000000..bc353818d --- /dev/null +++ b/frontend/src/hooks/context/useCategoryActionContext.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; + +import { CategoryActionContext } from '@/contexts/CategoryContext'; + +const useCategoryActionContext = () => { + const categoryAction = useContext(CategoryActionContext); + if (categoryAction === null || categoryAction === undefined) { + throw new Error('useCategoryActionContext는 Category Provider 안에서 사용해야 합니다.'); + } + + return categoryAction; +}; + +export default useCategoryActionContext; diff --git a/frontend/src/hooks/context/useCategoryContext.ts b/frontend/src/hooks/context/useCategoryContext.ts deleted file mode 100644 index b67a9d0f5..000000000 --- a/frontend/src/hooks/context/useCategoryContext.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from 'react'; - -import { CategoryContext } from '@/contexts/CategoryContext'; - -const useCategoryContext = () => { - const { categoryIds, selectCategory } = useContext(CategoryContext); - - return { categoryIds, selectCategory }; -}; - -export default useCategoryContext; diff --git a/frontend/src/hooks/context/useCategoryValueContext.ts b/frontend/src/hooks/context/useCategoryValueContext.ts new file mode 100644 index 000000000..2f159e2dd --- /dev/null +++ b/frontend/src/hooks/context/useCategoryValueContext.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; + +import { CategoryValueContext } from '@/contexts/CategoryContext'; + +const useCategoryValueContext = () => { + const categoryValue = useContext(CategoryValueContext); + if (categoryValue === null || categoryValue === undefined) { + throw new Error('useCategoryValueContext는 Category Provider 안에서 사용해야 합니다.'); + } + + return categoryValue; +}; + +export default useCategoryValueContext; diff --git a/frontend/src/mocks/data/foodCategory.json b/frontend/src/mocks/data/foodCategory.json index 5840826e5..daff82ce0 100644 --- a/frontend/src/mocks/data/foodCategory.json +++ b/frontend/src/mocks/data/foodCategory.json @@ -1,8 +1,7 @@ [ - { "id": 1, "name": "즉석조리" }, - { "id": 2, "name": "과자" }, - { "id": 3, "name": "간편식사" }, - { "id": 4, "name": "아이스크림" }, - { "id": 5, "name": "식품" }, - { "id": 6, "name": "음료" } + { "id": 1, "name": "간편식사", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" }, + { "id": 2, "name": "과자류", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" }, + { "id": 3, "name": "아이스크림", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" }, + { "id": 4, "name": "식품", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" }, + { "id": 5, "name": "음료", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" } ] diff --git a/frontend/src/mocks/data/memberReviews.json b/frontend/src/mocks/data/memberReviews.json index 17f8e1412..153565319 100644 --- a/frontend/src/mocks/data/memberReviews.json +++ b/frontend/src/mocks/data/memberReviews.json @@ -10,35 +10,21 @@ "reviews": [ { "reviewId": 1, - "productId": 5, + "productId": 1, "productName": "구운감자슬림명란마요", "content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다", "rating": 4.0, - "favoriteCount": 1256 - }, - { - "reviewId": 2, - "productId": 3, - "productName": "하얀짜파게티큰사발", - "content": "하얀 짜파게티라니 말이 안된다고 생각했었죠. 실제로 맛을 보니까 까만 짜파게티랑 맛이 뭔가 다를게 없네요.", - "rating": 4.4, - "favoriteCount": 870 + "favoriteCount": 1256, + "categoryType": "food" }, { "reviewId": 1, - "productId": 5, - "productName": "구운감자슬림명란마요", - "content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다", - "rating": 4.0, - "favoriteCount": 1256 - }, - { - "reviewId": 2, - "productId": 3, + "productId": 2, "productName": "하얀짜파게티큰사발", "content": "하얀 짜파게티라니 말이 안된다고 생각했었죠. 실제로 맛을 보니까 까만 짜파게티랑 맛이 뭔가 다를게 없네요.", "rating": 4.4, - "favoriteCount": 870 + "favoriteCount": 870, + "categoryType": "food" } ] } diff --git a/frontend/src/mocks/data/reviewRankingList.json b/frontend/src/mocks/data/reviewRankingList.json index 1c7fa137e..3a5cc0f73 100644 --- a/frontend/src/mocks/data/reviewRankingList.json +++ b/frontend/src/mocks/data/reviewRankingList.json @@ -2,19 +2,21 @@ "reviews": [ { "reviewId": 1, - "productId": 5, + "productId": 1, "productName": "구운감자슬림명란마요", "content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다", "rating": 4.0, - "favoriteCount": 1256 + "favoriteCount": 1256, + "categoryType": "food" }, { - "reviewId": 2, - "productId": 3, + "reviewId": 1, + "productId": 2, "productName": "하얀짜파게티큰사발", "content": "하얀 짜파게티라니 말이 안된다고 생각했었죠. 실제로 맛을 보니까 까만 짜파게티랑 맛이 뭔가 다를게 없네요.", "rating": 4.4, - "favoriteCount": 870 + "favoriteCount": 870, + "categoryType": "food" } ] } diff --git a/frontend/src/mocks/data/storeCategory.json b/frontend/src/mocks/data/storeCategory.json index c849f5c00..b9c7c9445 100644 --- a/frontend/src/mocks/data/storeCategory.json +++ b/frontend/src/mocks/data/storeCategory.json @@ -1,5 +1,6 @@ [ - { "id": 7, "name": "CU" }, - { "id": 8, "name": "GS25" }, - { "id": 9, "name": "이마트24" } + { "id": 6, "name": "CU", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" }, + { "id": 7, "name": "GS25", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" }, + { "id": 8, "name": "이마트24", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" }, + { "id": 9, "name": "세븐일레븐", "image": "https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg" } ] diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 9048e1492..e6e701b07 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,13 +1,11 @@ import { Heading, Link, Spacing } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; import { Suspense } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; -import { CategoryMenu, SvgIcon, ScrollButton, Loading, ErrorBoundary, ErrorComponent } from '@/components/Common'; -import { PBProductList, ProductList } from '@/components/Product'; +import { Loading, ErrorBoundary, ErrorComponent, CategoryList } from '@/components/Common'; import { ProductRankingList, ReviewRankingList, RecipeRankingList } from '@/components/Rank'; -import { PATH } from '@/constants/path'; +import { IMAGE_URL } from '@/constants'; import channelTalk from '@/service/channelTalk'; const HomePage = () => { @@ -22,92 +20,69 @@ const HomePage = () => { return ( <>
    - - 공통 상품 - - - - - - - - }> - - - 전체 보기 - - - + + +
    - -
    + + - 편의점 특산품 + 카테고리 - + + - - - }> - - - -
    + + -
    + 🍯 꿀조합 랭킹 + }> -
    + -
    + 👑 상품 랭킹 - + }> -
    + -
    + - 리뷰 랭킹 + 📝 리뷰 랭킹 - + }> -
    - + + ); }; export default HomePage; -const ProductListRouteLink = styled(Link)` - display: flex; - justify-content: center; - align-items: center; +const Banner = styled.img` width: 100%; - padding: 12px 0; - text-align: center; - border-bottom: 1px solid ${({ theme }) => theme.borderColors.disabled}; +`; - svg { - margin-left: 4px; - transform: rotate(180deg); - } +const SectionWrapper = styled.section` + padding: 0 20px; `; diff --git a/frontend/src/pages/IntegratedSearchPage.tsx b/frontend/src/pages/IntegratedSearchPage.tsx new file mode 100644 index 000000000..285fc5c2e --- /dev/null +++ b/frontend/src/pages/IntegratedSearchPage.tsx @@ -0,0 +1,119 @@ +import { Button, Heading, Spacing, Text } from '@fun-eat/design-system'; +import { useQueryErrorResetBoundary } from '@tanstack/react-query'; +import { Suspense, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { ErrorBoundary, ErrorComponent, Input, Loading, SvgIcon, TabMenu } from '@/components/Common'; +import { RecommendList, ProductSearchResultList, RecipeSearchResultList } from '@/components/Search'; +import { SEARCH_TAB_VARIANTS } from '@/constants'; +import { useDebounce, useTabMenu } from '@/hooks/common'; +import { useSearch } from '@/hooks/search'; + +const PRODUCT_PLACEHOLDER = '상품 이름을 검색해보세요.'; +const RECIPE_PLACEHOLDER = '꿀조합에 포함된 상품을 입력해보세요.'; + +const IntegratedSearchPage = () => { + const { + inputRef, + searchQuery, + isSubmitted, + isAutocompleteOpen, + handleSearchQuery, + handleSearch, + handleSearchClick, + handleAutocompleteClose, + } = useSearch(); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery || ''); + const { reset } = useQueryErrorResetBoundary(); + + const { selectedTabMenu, isFirstTabMenu: isProductSearchTab, handleTabMenuClick } = useTabMenu(); + const inputPlaceholder = isProductSearchTab ? PRODUCT_PLACEHOLDER : RECIPE_PLACEHOLDER; + + useDebounce( + () => { + setDebouncedSearchQuery(searchQuery); + }, + 200, + [searchQuery] + ); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + return ( + <> + +
    + + + + } + value={searchQuery} + onChange={handleSearchQuery} + ref={inputRef} + /> + + {!isSubmitted && debouncedSearchQuery && isAutocompleteOpen && ( + + }> + + + + )} +
    + + + + {isSubmitted && debouncedSearchQuery ? ( + <> + + '{searchQuery}'에 대한 검색결과입니다. + + + }> + + {isProductSearchTab ? ( + + ) : ( + + )} + + + + ) : ( + {SEARCH_TAB_VARIANTS[selectedTabMenu]}을 검색해보세요. + )} + + + ); +}; + +export default IntegratedSearchPage; + +const SearchSection = styled.section` + position: relative; +`; + +const SearchResultSection = styled.section` + margin-top: 30px; +`; + +const Mark = styled.mark` + font-weight: ${({ theme }) => theme.fontWeights.bold}; + background-color: ${({ theme }) => theme.backgroundColors.default}; +`; diff --git a/frontend/src/pages/MemberModifyPage.tsx b/frontend/src/pages/MemberModifyPage.tsx index 0c9477add..50e98f690 100644 --- a/frontend/src/pages/MemberModifyPage.tsx +++ b/frontend/src/pages/MemberModifyPage.tsx @@ -1,10 +1,11 @@ -import { Button, Heading, Spacing } from '@fun-eat/design-system'; +import { Button, Spacing } from '@fun-eat/design-system'; import type { ChangeEventHandler, FormEventHandler } from 'react'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; -import { Input, SectionTitle, SvgIcon } from '@/components/Common'; +import { SectionTitle, SvgIcon } from '@/components/Common'; +import { MemberModifyInput } from '@/components/Members'; import { IMAGE_MAX_SIZE } from '@/constants'; import { useFormData, useImageUploader } from '@/hooks/common'; import { useMemberModifyMutation, useMemberQuery } from '@/hooks/queries/members'; @@ -85,11 +86,7 @@ const MemberModifyPage = () => { - - 닉네임 - - - + 수정하기 diff --git a/frontend/src/pages/MemberPage.tsx b/frontend/src/pages/MemberPage.tsx index 129e65f53..bb5fca6bb 100644 --- a/frontend/src/pages/MemberPage.tsx +++ b/frontend/src/pages/MemberPage.tsx @@ -1,6 +1,7 @@ import { Spacing } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; import { Suspense } from 'react'; +import styled from 'styled-components'; import { ErrorBoundary, ErrorComponent, Loading, NavigableSectionTitle } from '@/components/Common'; import { MembersInfo, MemberReviewList, MemberRecipeList } from '@/components/Members'; @@ -10,7 +11,7 @@ const MemberPage = () => { const { reset } = useQueryErrorResetBoundary(); return ( - <> + }> @@ -30,8 +31,12 @@ const MemberPage = () => { - + ); }; export default MemberPage; + +const MemberPageContainer = styled.div` + padding: 20px 20px 0; +`; diff --git a/frontend/src/pages/MemberRecipePage.tsx b/frontend/src/pages/MemberRecipePage.tsx index 1b7d1eaa3..90bddc44b 100644 --- a/frontend/src/pages/MemberRecipePage.tsx +++ b/frontend/src/pages/MemberRecipePage.tsx @@ -1,15 +1,17 @@ import { Spacing } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import { Suspense } from 'react'; +import { Suspense, useRef } from 'react'; +import styled from 'styled-components'; import { ErrorBoundary, ErrorComponent, Loading, ScrollButton, SectionTitle } from '@/components/Common'; import { MemberRecipeList } from '@/components/Members'; const MemberRecipePage = () => { const { reset } = useQueryErrorResetBoundary(); + const memberRecipeRef = useRef(null); return ( - <> + @@ -17,9 +19,16 @@ const MemberRecipePage = () => { - - + + + ); }; export default MemberRecipePage; + +const MemberRecipePageContainer = styled.div` + height: 100%; + padding: 20px 20px 0; + overflow-y: auto; +`; diff --git a/frontend/src/pages/MemberReviewPage.tsx b/frontend/src/pages/MemberReviewPage.tsx index fe8584078..9f15905fd 100644 --- a/frontend/src/pages/MemberReviewPage.tsx +++ b/frontend/src/pages/MemberReviewPage.tsx @@ -1,15 +1,17 @@ import { Spacing } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import { Suspense } from 'react'; +import { Suspense, useRef } from 'react'; +import styled from 'styled-components'; import { ErrorBoundary, ErrorComponent, Loading, ScrollButton, SectionTitle } from '@/components/Common'; import { MemberReviewList } from '@/components/Members'; const MemberReviewPage = () => { const { reset } = useQueryErrorResetBoundary(); + const memberReviewRef = useRef(null); return ( - <> + @@ -17,9 +19,16 @@ const MemberReviewPage = () => { - - + + + ); }; export default MemberReviewPage; + +const MemberReviewPageContainer = styled.div` + height: 100%; + padding: 20px 20px 0; + overflow-y: auto; +`; diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 338279145..11f668dd2 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -1,6 +1,5 @@ import { BottomSheet, Spacing, useBottomSheet, Text, Link } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import type { MouseEventHandler } from 'react'; import { useState, useRef, Suspense } from 'react'; import ReactGA from 'react-ga4'; import { useParams, Link as RouterLink } from 'react-router-dom'; @@ -22,7 +21,7 @@ import { ReviewList, ReviewRegisterForm } from '@/components/Review'; import { RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import ReviewFormProvider from '@/contexts/ReviewFormContext'; -import { useSortOption } from '@/hooks/common'; +import { useSortOption, useTabMenu } from '@/hooks/common'; import { useMemberQuery } from '@/hooks/queries/members'; import { useProductDetailQuery } from '@/hooks/queries/product'; @@ -37,19 +36,19 @@ const ProductDetailPage = () => { const { data: productDetail } = useProductDetailQuery(Number(productId)); const { reset } = useQueryErrorResetBoundary(); - const tabMenus = [`리뷰 ${productDetail.reviewCount}`, '꿀조합']; - const [selectedTabMenu, setSelectedTabMenu] = useState(tabMenus[0]); + const { selectedTabMenu, isFirstTabMenu: isReviewTab, handleTabMenuClick, initTabMenu } = useTabMenu(); const tabRef = useRef(null); - const isReviewTab = selectedTabMenu === tabMenus[0]; - const sortOptions = isReviewTab ? REVIEW_SORT_OPTIONS : RECIPE_SORT_OPTIONS; - const initialSortOption = isReviewTab ? REVIEW_SORT_OPTIONS[0] : RECIPE_SORT_OPTIONS[0]; - - const { selectedOption, selectSortOption } = useSortOption(initialSortOption); + const { selectedOption, selectSortOption } = useSortOption(REVIEW_SORT_OPTIONS[0]); const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); - const [activeSheet, setActiveSheet] = useState<'registerReview' | 'sortOption'>('sortOption'); + const productDetailPageRef = useRef(null); + + const tabMenus = [`리뷰 ${productDetail.reviewCount}`, '꿀조합']; + const sortOptions = isReviewTab ? REVIEW_SORT_OPTIONS : RECIPE_SORT_OPTIONS; + const currentSortOption = isReviewTab ? REVIEW_SORT_OPTIONS[0] : RECIPE_SORT_OPTIONS[0]; + if (!category) { return null; } @@ -64,9 +63,9 @@ const ProductDetailPage = () => { handleOpenBottomSheet(); }; - const handleTabMenuSelect: MouseEventHandler = (event) => { - setSelectedTabMenu(event.currentTarget.value); - selectSortOption(initialSortOption); + const handleTabMenuSelect = (index: number) => { + handleTabMenuClick(index); + selectSortOption(currentSortOption); ReactGA.event({ category: '버튼', @@ -76,7 +75,7 @@ const ProductDetailPage = () => { }; return ( - <> + @@ -124,7 +123,7 @@ const ProductDetailPage = () => { onClick={handleOpenRegisterReviewSheet} /> - + {activeSheet === 'registerReview' ? ( @@ -132,6 +131,7 @@ const ProductDetailPage = () => { targetRef={tabRef} productId={Number(productId)} closeReviewDialog={handleCloseBottomSheet} + initTabMenu={initTabMenu} /> ) : ( @@ -143,12 +143,21 @@ const ProductDetailPage = () => { /> )} - + ); }; export default ProductDetailPage; +const ProductDetailPageContainer = styled.div` + height: 100%; + overflow-y: auto; + + &::-webkit-scrollbar { + display: none; + } +`; + const SortButtonWrapper = styled.div` display: flex; justify-content: flex-end; diff --git a/frontend/src/pages/ProductListPage.tsx b/frontend/src/pages/ProductListPage.tsx index 8e7da4b50..b4961e746 100644 --- a/frontend/src/pages/ProductListPage.tsx +++ b/frontend/src/pages/ProductListPage.tsx @@ -1,63 +1,68 @@ import { BottomSheet, Spacing, useBottomSheet } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import { Suspense } from 'react'; +import { Suspense, useRef } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import { - CategoryMenu, + CategoryTab, SortButton, SortOptionList, - Title, ScrollButton, Loading, ErrorBoundary, ErrorComponent, } from '@/components/Common'; -import { ProductList } from '@/components/Product'; +import { ProductTitle, ProductList } from '@/components/Product'; import { PRODUCT_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; -import { useSortOption } from '@/hooks/common'; +import type { CategoryIds } from '@/contexts/CategoryContext'; +import { useScrollRestoration, useSortOption } from '@/hooks/common'; +import { useCategoryValueContext } from '@/hooks/context'; import { isCategoryVariant } from '@/types/common'; const PAGE_TITLE = { food: '공통 상품', store: 'PB 상품' }; const ProductListPage = () => { + const { category } = useParams(); + const productListRef = useRef(null); + const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { selectedOption, selectSortOption } = useSortOption(PRODUCT_SORT_OPTIONS[0]); const { reset } = useQueryErrorResetBoundary(); - const { category } = useParams(); + const { categoryIds } = useCategoryValueContext(); - if (!category) { - return null; - } + useScrollRestoration(categoryIds[category as keyof CategoryIds], productListRef); - if (!isCategoryVariant(category)) { + if (!category || !isCategoryVariant(category)) { return null; } return ( <> -
    - + <ProductTitle + content={PAGE_TITLE[category]} routeDestination={PATH.PRODUCT_LIST + '/' + (category === 'store' ? 'food' : 'store')} /> - <Spacing size={30} /> + <Spacing size={20} /> <Suspense fallback={null}> - <CategoryMenu menuVariant={category} /> + <CategoryTab menuVariant={category} /> </Suspense> - <ErrorBoundary fallback={ErrorComponent} handleReset={reset}> - <Suspense fallback={<Loading />}> - <SortButtonWrapper> - <SortButton option={selectedOption} onClick={handleOpenBottomSheet} /> - </SortButtonWrapper> - <ProductList category={category} selectedOption={selectedOption} /> - </Suspense> - </ErrorBoundary> - </section> - <ScrollButton /> + <Spacing size={20} /> + <ProductListContainer ref={productListRef}> + <ErrorBoundary fallback={ErrorComponent} handleReset={reset}> + <Suspense fallback={<Loading />}> + <SortButtonWrapper> + <SortButton option={selectedOption} onClick={handleOpenBottomSheet} /> + </SortButtonWrapper> + <ProductList category={category} selectedOption={selectedOption} /> + </Suspense> + </ErrorBoundary> + </ProductListContainer> + </ProductListSection> + <ScrollButton targetRef={productListRef} /> <BottomSheet ref={ref} isClosing={isClosing} maxWidth="600px" close={handleCloseBottomSheet}> <SortOptionList options={PRODUCT_SORT_OPTIONS} @@ -69,10 +74,19 @@ const ProductListPage = () => { </> ); }; + export default ProductListPage; +const ProductListSection = styled.section` + height: 100%; +`; + const SortButtonWrapper = styled.div` display: flex; justify-content: flex-end; - margin: 20px 0; +`; + +const ProductListContainer = styled.div` + height: calc(100% - 100px); + overflow-y: auto; `; diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index 5ad75b4a5..cc77efb77 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -15,7 +15,7 @@ const RecipeDetailPage = () => { const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail; return ( - <> + <RecipeDetailPageContainer> <SectionTitle name={title} /> <Spacing size={24} /> {images.length > 0 ? ( @@ -65,12 +65,17 @@ const RecipeDetailPage = () => { <RecipeContent size="lg" lineHeight="lg"> {content} </RecipeContent> - </> + <Spacing size={40} /> + </RecipeDetailPageContainer> ); }; export default RecipeDetailPage; +const RecipeDetailPageContainer = styled.div` + padding: 20px 20px 0; +`; + const RecipeImageContainer = styled.ul` display: flex; flex-direction: column; diff --git a/frontend/src/pages/RecipePage.tsx b/frontend/src/pages/RecipePage.tsx index 43bf5a82f..52a7f0928 100644 --- a/frontend/src/pages/RecipePage.tsx +++ b/frontend/src/pages/RecipePage.tsx @@ -1,6 +1,6 @@ import { BottomSheet, Heading, Link, Spacing, useBottomSheet } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import { Suspense, useState } from 'react'; +import { Suspense, useRef, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; @@ -16,6 +16,7 @@ import { } from '@/components/Common'; import { RecipeList, RecipeRegisterForm } from '@/components/Recipe'; import { RECIPE_SORT_OPTIONS } from '@/constants'; +import { PATH } from '@/constants/path'; import RecipeFormProvider from '@/contexts/RecipeFormContext'; import { useSortOption } from '@/hooks/common'; @@ -29,6 +30,8 @@ const RecipePage = () => { const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { reset } = useQueryErrorResetBoundary(); + const recipeRef = useRef<HTMLDivElement>(null); + const handleOpenRegisterRecipeSheet = () => { setActiveSheet('registerRecipe'); handleOpenBottomSheet(); @@ -41,18 +44,20 @@ const RecipePage = () => { return ( <> - <Title size="xl" weight="bold"> - {RECIPE_PAGE_TITLE} - - - - + + {RECIPE_PAGE_TITLE} + + + + }> - + + + @@ -63,7 +68,7 @@ const RecipePage = () => { onClick={handleOpenRegisterRecipeSheet} /> - + {activeSheet === 'sortOption' ? ( { export default RecipePage; -const Title = styled(Heading)` - font-size: 24px; +const TitleWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; `; -const SearchPageLink = styled(Link)` - position: absolute; - top: 24px; - right: 20px; +const Title = styled(Heading)` + font-size: 24px; `; const SortButtonWrapper = styled.div` @@ -100,6 +105,11 @@ const SortButtonWrapper = styled.div` margin: 20px 0; `; +const RecipeListWrapper = styled.div` + height: calc(100% - 192px); + overflow-y: auto; +`; + const RecipeRegisterButtonWrapper = styled.div` position: fixed; left: 20px; diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 7089b0dfc..f63232dba 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -1,18 +1,19 @@ -import { Button, Heading, Spacing, Text } from '@fun-eat/design-system'; +import { Button, Heading, Spacing, Text, useTheme } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import type { MouseEventHandler } from 'react'; import { Suspense, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; import styled from 'styled-components'; -import { ErrorBoundary, ErrorComponent, Input, Loading, SvgIcon, TabMenu } from '@/components/Common'; +import { ErrorBoundary, ErrorComponent, Input, Loading, SvgIcon } from '@/components/Common'; import { RecommendList, ProductSearchResultList, RecipeSearchResultList } from '@/components/Search'; -import { SEARCH_PAGE_TABS } from '@/constants'; -import { useDebounce } from '@/hooks/common'; +import { SEARCH_PAGE_VARIANTS } from '@/constants'; +import { useDebounce, useRoutePage } from '@/hooks/common'; import { useSearch } from '@/hooks/search'; -const isProductSearchTab = (tabMenu: string) => tabMenu === SEARCH_PAGE_TABS[0]; -const getInputPlaceholder = (tabMenu: string) => - isProductSearchTab(tabMenu) ? '상품 이름을 검색해보세요.' : '꿀조합에 포함된 상품을 입력해보세요.'; +const PRODUCT_PLACEHOLDER = '상품 이름을 검색해보세요.'; +const RECIPE_PLACEHOLDER = '꿀조합에 포함된 상품을 입력해보세요.'; + +type SearchPageType = keyof typeof SEARCH_PAGE_VARIANTS; const SearchPage = () => { const { @@ -26,12 +27,12 @@ const SearchPage = () => { handleAutocompleteClose, } = useSearch(); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery || ''); - const [selectedTabMenu, setSelectedTabMenu] = useState(SEARCH_PAGE_TABS[0]); const { reset } = useQueryErrorResetBoundary(); + const { routeBack } = useRoutePage(); - const handleTabMenuSelect: MouseEventHandler = (event) => { - setSelectedTabMenu(event.currentTarget.value); - }; + const theme = useTheme(); + + const { searchVariant } = useParams(); useDebounce( () => { @@ -47,13 +48,32 @@ const SearchPage = () => { } }, []); + const isSearchVariant = (value: string): value is SearchPageType => { + return value === 'products' || value === 'recipes'; + }; + + if (!searchVariant || !isSearchVariant(searchVariant)) { + return null; + } + + const isProductSearchPage = searchVariant === 'products'; + const isRecipeSearchPage = searchVariant === 'recipes'; + const inputPlaceholder = isProductSearchPage ? PRODUCT_PLACEHOLDER : RECIPE_PLACEHOLDER; + return ( <> + + + {SEARCH_PAGE_VARIANTS[searchVariant]} 검색 + +
    @@ -76,12 +96,6 @@ const SearchPage = () => { )} - - {isSubmitted && debouncedSearchQuery ? ( <> @@ -91,16 +105,13 @@ const SearchPage = () => { }> - {isProductSearchTab(selectedTabMenu) ? ( - - ) : ( - - )} + {isProductSearchPage && } + {isRecipeSearchPage && } ) : ( - {selectedTabMenu}을 검색해보세요. + {SEARCH_PAGE_VARIANTS[searchVariant]}을 검색해보세요. )} @@ -117,6 +128,16 @@ const SearchResultSection = styled.section` margin-top: 30px; `; +const TitleWrapper = styled.div` + display: flex; + gap: 12px; + align-items: center; +`; + +const HeadingTitle = styled(Heading)` + font-size: 2.4rem; +`; + const Mark = styled.mark` font-weight: ${({ theme }) => theme.fontWeights.bold}; background-color: ${({ theme }) => theme.backgroundColors.default}; diff --git a/frontend/src/router/App.tsx b/frontend/src/router/App.tsx index b4428b177..a8c4bfd10 100644 --- a/frontend/src/router/App.tsx +++ b/frontend/src/router/App.tsx @@ -3,11 +3,11 @@ import { Suspense } from 'react'; import { Outlet } from 'react-router-dom'; import { ErrorBoundary, ErrorComponent, Loading } from '@/components/Common'; -import { MinimalLayout, DefaultLayout, HeaderOnlyLayout } from '@/components/Layout'; +import { MinimalLayout, DefaultLayout, HeaderOnlyLayout, SimpleHeaderLayout } from '@/components/Layout'; import { useRouteChangeTracker } from '@/hooks/common'; interface AppProps { - layout?: 'default' | 'headerOnly' | 'minimal'; + layout?: 'default' | 'headerOnly' | 'minimal' | 'simpleHeader'; } const App = ({ layout = 'default' }: AppProps) => { @@ -39,6 +39,18 @@ const App = ({ layout = 'default' }: AppProps) => { ); } + if (layout === 'simpleHeader') { + return ( + + }> + + + + + + ); + } + return ( }> diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 8391765a3..7bf9565ea 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -7,6 +7,7 @@ import { PATH } from '@/constants/path'; import CategoryProvider from '@/contexts/CategoryContext'; import AuthPage from '@/pages/AuthPage'; import HomePage from '@/pages/HomePage'; +import IntegratedSearchPage from '@/pages/IntegratedSearchPage'; import LoginPage from '@/pages/LoginPage'; import MemberModifyPage from '@/pages/MemberModifyPage'; import MemberPage from '@/pages/MemberPage'; @@ -22,28 +23,16 @@ import SearchPage from '@/pages/SearchPage'; const router = createBrowserRouter([ { path: '/', - element: , + element: ( + + + + ), errorElement: , children: [ { index: true, - element: ( - - - - ), - }, - { - path: `${PATH.PRODUCT_LIST}/:category`, - element: ( - - - - ), - }, - { - path: PATH.RECIPE, - element: , + element: , }, { path: `${PATH.RECIPE}/:recipeId`, @@ -53,10 +42,6 @@ const router = createBrowserRouter([ ), }, - { - path: PATH.SEARCH, - element: , - }, { path: PATH.MEMBER, element: ( @@ -108,7 +93,11 @@ const router = createBrowserRouter([ }, { path: '/', - element: , + element: ( + + + + ), errorElement: , children: [ { @@ -117,6 +106,33 @@ const router = createBrowserRouter([ }, ], }, + { + path: '/', + element: ( + + + + ), + errorElement: , + children: [ + { + path: `${PATH.PRODUCT_LIST}/:category`, + element: , + }, + { + path: PATH.RECIPE, + element: , + }, + { + path: `${PATH.SEARCH}/integrated`, + element: , + }, + { + path: `${PATH.SEARCH}/:searchVariant`, + element: , + }, + ], + }, ]); export default router; diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index 1d2dbb1f7..3bfcc8576 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -12,6 +12,7 @@ export const isCategoryVariant = (value: string): value is CategoryVariant => { export interface Category { id: number; name: string; + image: string; } export interface Tag { diff --git a/frontend/src/types/ranking.ts b/frontend/src/types/ranking.ts index 746fde2a2..9f0707f68 100644 --- a/frontend/src/types/ranking.ts +++ b/frontend/src/types/ranking.ts @@ -10,6 +10,7 @@ export interface ReviewRanking { content: string; rating: number; favoriteCount: number; + categoryType: string; } export interface RecipeRanking {