diff --git a/.github/workflows/frontend-e2e-test.yml b/.github/workflows/frontend-e2e-test.yml index 54f03fd37..ef06fa8bf 100644 --- a/.github/workflows/frontend-e2e-test.yml +++ b/.github/workflows/frontend-e2e-test.yml @@ -31,8 +31,8 @@ jobs: uses: cypress-io/github-action@v5 with: browser: chrome - start: npm run dev - wait-on: 'http://localhost:3000' + start: npm run serve:dev + wait-on: "http://localhost:3000" record: false working-directory: ./frontend env: diff --git a/backend/backend-submodule b/backend/backend-submodule index f87cebb03..251536b9c 160000 --- a/backend/backend-submodule +++ b/backend/backend-submodule @@ -1 +1 @@ -Subproject commit f87cebb0398e845d3fd34a35174552a48d8cb7eb +Subproject commit 251536b9ca71fca672f25d5685a3526634739c22 diff --git a/backend/build.gradle b/backend/build.gradle index 4a53d5e9b..056e6bebe 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -47,6 +47,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' + + + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' } test { diff --git a/backend/src/docs/asciidoc/docs.adoc b/backend/src/docs/asciidoc/docs.adoc index 1f2ac5377..e55941857 100644 --- a/backend/src/docs/asciidoc/docs.adoc +++ b/backend/src/docs/asciidoc/docs.adoc @@ -298,3 +298,13 @@ include::{snippets}/shared-trip-controller-test/update-shared-status/path-parame ==== 응답 include::{snippets}/shared-trip-controller-test/update-shared-status/http-response.adoc[] include::{snippets}/shared-trip-controller-test/update-shared-status/path-parameters.adoc[] + +=== 공유된 여행 경비 조회 (GET /shared-trips/:shareCode/expense) + +==== 요청 +include::{snippets}/shared-trip-controller-test/get-shared-expenses/http-request.adoc[] +include::{snippets}/shared-trip-controller-test/get-shared-expenses/path-parameters.adoc[] + +==== 응답 +include::{snippets}/shared-trip-controller-test/get-shared-expenses/http-response.adoc[] +include::{snippets}/shared-trip-controller-test/get-shared-expenses/response-fields.adoc[] diff --git a/backend/src/main/java/hanglog/auth/Auth.java b/backend/src/main/java/hanglog/auth/Auth.java index e0506fa9d..17bfb100e 100644 --- a/backend/src/main/java/hanglog/auth/Auth.java +++ b/backend/src/main/java/hanglog/auth/Auth.java @@ -9,6 +9,4 @@ @Target(PARAMETER) @Retention(RUNTIME) public @interface Auth { - - boolean required() default true; } diff --git a/backend/src/main/java/hanglog/auth/AuthArgumentResolver.java b/backend/src/main/java/hanglog/auth/AuthArgumentResolver.java index a03b6074d..5bbf8f9d7 100644 --- a/backend/src/main/java/hanglog/auth/AuthArgumentResolver.java +++ b/backend/src/main/java/hanglog/auth/AuthArgumentResolver.java @@ -1,12 +1,17 @@ package hanglog.auth; -import static hanglog.global.exception.ExceptionCode.NULL_REFRESH_TOKEN; +import static hanglog.global.exception.ExceptionCode.INVALID_REQUEST; +import static hanglog.global.exception.ExceptionCode.NOT_FOUND_REFRESH_TOKEN; import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import hanglog.auth.domain.Accessor; import hanglog.auth.domain.BearerAuthorizationExtractor; import hanglog.auth.domain.JwtProvider; import hanglog.auth.domain.MemberTokens; -import hanglog.global.exception.AuthException; +import hanglog.auth.domain.repository.RefreshTokenRepository; +import hanglog.global.exception.BadRequestException; +import hanglog.global.exception.RefreshTokenException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; import lombok.RequiredArgsConstructor; @@ -27,6 +32,8 @@ public class AuthArgumentResolver implements HandlerMethodArgumentResolver { private final BearerAuthorizationExtractor extractor; + private final RefreshTokenRepository refreshTokenRepository; + @Override public boolean supportsParameter(final MethodParameter parameter) { return parameter.withContainingClass(Long.class) @@ -34,21 +41,43 @@ public boolean supportsParameter(final MethodParameter parameter) { } @Override - public Long resolveArgument( + public Accessor resolveArgument( final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory - ) throws Exception { + ) { final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); - final String refreshToken = Arrays.stream(request.getCookies()) - .filter(cookie -> REFRESH_TOKEN.equals(cookie.getName())) + if (request == null) { + throw new BadRequestException(INVALID_REQUEST); + } + + try { + final String refreshToken = extractRefreshToken(request.getCookies()); + final String accessToken = extractor.extractAccessToken(webRequest.getHeader(AUTHORIZATION)); + jwtProvider.validateTokens(new MemberTokens(refreshToken, accessToken)); + + final Long memberId = Long.valueOf(jwtProvider.getSubject(accessToken)); + return Accessor.member(memberId); + } catch (final RefreshTokenException e) { + return Accessor.guest(); + } + } + + private String extractRefreshToken(final Cookie... cookies) { + if (cookies == null) { + throw new RefreshTokenException(NOT_FOUND_REFRESH_TOKEN); + } + return Arrays.stream(cookies) + .filter(this::isValidRefreshToken) .findFirst() - .orElseThrow(() -> new AuthException(NULL_REFRESH_TOKEN)) + .orElseThrow(() -> new RefreshTokenException(NOT_FOUND_REFRESH_TOKEN)) .getValue(); + } - final String accessToken = extractor.extractAccessToken(webRequest.getHeader(AUTHORIZATION)); - jwtProvider.validateTokens(new MemberTokens(refreshToken, accessToken)); - return Long.valueOf(jwtProvider.getSubject(accessToken)); + private boolean isValidRefreshToken(final Cookie cookie) { + // TODO: refreshToken 만료 기한 검사 필요 + return REFRESH_TOKEN.equals(cookie.getName()) && + refreshTokenRepository.existsByToken(cookie.getValue()); } } diff --git a/backend/src/main/java/hanglog/auth/MemberOnly.java b/backend/src/main/java/hanglog/auth/MemberOnly.java new file mode 100644 index 000000000..adef61da2 --- /dev/null +++ b/backend/src/main/java/hanglog/auth/MemberOnly.java @@ -0,0 +1,12 @@ +package hanglog.auth; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target(METHOD) +@Retention(RUNTIME) +public @interface MemberOnly { +} diff --git a/backend/src/main/java/hanglog/auth/MemberOnlyChecker.java b/backend/src/main/java/hanglog/auth/MemberOnlyChecker.java new file mode 100644 index 000000000..6cca65668 --- /dev/null +++ b/backend/src/main/java/hanglog/auth/MemberOnlyChecker.java @@ -0,0 +1,26 @@ +package hanglog.auth; + +import static hanglog.global.exception.ExceptionCode.INVALID_AUTHORITY; + +import hanglog.auth.domain.Accessor; +import hanglog.global.exception.AuthException; +import java.util.Arrays; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class MemberOnlyChecker { + + @Before("@annotation(hanglog.auth.MemberOnly)") + public void check(final JoinPoint joinPoint) { + Arrays.stream(joinPoint.getArgs()) + .filter(Accessor.class::isInstance) + .map(Accessor.class::cast) + .filter(Accessor::isMember) + .findFirst() + .orElseThrow(() -> new AuthException(INVALID_AUTHORITY)); + } +} diff --git a/backend/src/main/java/hanglog/auth/domain/Accessor.java b/backend/src/main/java/hanglog/auth/domain/Accessor.java new file mode 100644 index 000000000..c61e49ae0 --- /dev/null +++ b/backend/src/main/java/hanglog/auth/domain/Accessor.java @@ -0,0 +1,29 @@ +package hanglog.auth.domain; + +import static hanglog.auth.domain.Authority.MEMBER; + +import lombok.Getter; + +@Getter +public class Accessor { + + private final Long memberId; + private final Authority authority; + + private Accessor(final Long memberId, final Authority authority) { + this.memberId = memberId; + this.authority = authority; + } + + public static Accessor guest() { + return new Accessor(0L, Authority.GUEST); + } + + public static Accessor member(final Long memberId) { + return new Accessor(memberId, MEMBER); + } + + public boolean isMember() { + return MEMBER.equals(authority); + } +} diff --git a/backend/src/main/java/hanglog/auth/domain/Authority.java b/backend/src/main/java/hanglog/auth/domain/Authority.java new file mode 100644 index 000000000..4d2148882 --- /dev/null +++ b/backend/src/main/java/hanglog/auth/domain/Authority.java @@ -0,0 +1,5 @@ +package hanglog.auth.domain; + +public enum Authority { + GUEST, MEMBER +} diff --git a/backend/src/main/java/hanglog/auth/domain/RefreshToken.java b/backend/src/main/java/hanglog/auth/domain/RefreshToken.java index 8f4f515fd..e0da5f34e 100644 --- a/backend/src/main/java/hanglog/auth/domain/RefreshToken.java +++ b/backend/src/main/java/hanglog/auth/domain/RefreshToken.java @@ -16,7 +16,7 @@ public class RefreshToken { @Id private String token; - @Column(nullable = false, unique = true) + @Column(nullable = false) private Long memberId; public RefreshToken(final String token, final Long memberId) { diff --git a/backend/src/main/java/hanglog/auth/domain/repository/RefreshTokenRepository.java b/backend/src/main/java/hanglog/auth/domain/repository/RefreshTokenRepository.java index 7b150983d..bf6d97512 100644 --- a/backend/src/main/java/hanglog/auth/domain/repository/RefreshTokenRepository.java +++ b/backend/src/main/java/hanglog/auth/domain/repository/RefreshTokenRepository.java @@ -7,6 +7,8 @@ public interface RefreshTokenRepository extends JpaRepository { Optional findByToken(final String token); + + boolean existsByToken(final String token); void deleteByMemberId(final Long memberId); } diff --git a/backend/src/main/java/hanglog/auth/presentation/AuthController.java b/backend/src/main/java/hanglog/auth/presentation/AuthController.java index 492c8feaf..269b6c8e4 100644 --- a/backend/src/main/java/hanglog/auth/presentation/AuthController.java +++ b/backend/src/main/java/hanglog/auth/presentation/AuthController.java @@ -4,6 +4,8 @@ import static org.springframework.http.HttpStatus.CREATED; import hanglog.auth.Auth; +import hanglog.auth.MemberOnly; +import hanglog.auth.domain.Accessor; import hanglog.auth.domain.MemberTokens; import hanglog.auth.dto.AccessTokenResponse; import hanglog.auth.dto.LoginRequest; @@ -56,14 +58,16 @@ public ResponseEntity extendLogin( } @DeleteMapping("/logout") - public ResponseEntity logout(@Auth final Long memberId) { - authService.removeMemberRefreshToken(memberId); + @MemberOnly + public ResponseEntity logout(@Auth final Accessor accessor) { + authService.removeMemberRefreshToken(accessor.getMemberId()); return ResponseEntity.noContent().build(); } @DeleteMapping("/account") - public ResponseEntity deleteAccount(@Auth final Long memberId) { - authService.deleteAccount(memberId); + @MemberOnly + public ResponseEntity deleteAccount(@Auth final Accessor accessor) { + authService.deleteAccount(accessor.getMemberId()); return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/hanglog/auth/service/AuthService.java b/backend/src/main/java/hanglog/auth/service/AuthService.java index 529416b4f..ee94c9bb2 100644 --- a/backend/src/main/java/hanglog/auth/service/AuthService.java +++ b/backend/src/main/java/hanglog/auth/service/AuthService.java @@ -1,8 +1,5 @@ package hanglog.auth.service; -import static hanglog.global.exception.ExceptionCode.FAIL_TO_VALIDATE_TOKEN; -import static hanglog.global.exception.ExceptionCode.INVALID_REFRESH_TOKEN; - import hanglog.auth.domain.BearerAuthorizationExtractor; import hanglog.auth.domain.JwtProvider; import hanglog.auth.domain.MemberTokens; @@ -19,11 +16,16 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static hanglog.global.exception.ExceptionCode.*; + @Service @Transactional @RequiredArgsConstructor public class AuthService { + private static final int MAX_TRY_COUNT = 5; + private static final int FOUR_DIGIT_RANGE = 10000; + private final MemberRepository memberRepository; private final OauthProviders oauthProviders; private final RefreshTokenRepository refreshTokenRepository; @@ -47,7 +49,24 @@ public MemberTokens login(final String providerName, final String code) { private Member findOrCreateMember(final String socialLoginId, final String nickname, final String imageUrl) { return memberRepository.findBySocialLoginId(socialLoginId) - .orElseGet(() -> memberRepository.save(new Member(socialLoginId, nickname, imageUrl))); + .orElseGet(() -> createMember(socialLoginId, nickname, imageUrl)); + } + + private Member createMember(final String socialLoginId, final String nickname, final String imageUrl) { + int tryCount = 0; + while (tryCount < MAX_TRY_COUNT) { + final String nicknameWithRandomNumber = nickname + generateRandomFourDigitCode(); + if (!memberRepository.existsByNickname(nicknameWithRandomNumber)) { + return memberRepository.save(new Member(socialLoginId, nicknameWithRandomNumber, imageUrl)); + } + tryCount += 1; + } + throw new AuthException(FAIL_TO_GENERATE_RANDOM_NICKNAME); + } + + public String generateRandomFourDigitCode() { + final int randomNumber = (int) (Math.random() * FOUR_DIGIT_RANGE); + return String.format("%04d", randomNumber); } public String renewalAccessToken(final String refreshTokenRequest, final String authorizationHeader) { diff --git a/backend/src/main/java/hanglog/trip/domain/City.java b/backend/src/main/java/hanglog/city/domain/City.java similarity index 96% rename from backend/src/main/java/hanglog/trip/domain/City.java rename to backend/src/main/java/hanglog/city/domain/City.java index 8c5ea6f1a..544570b66 100644 --- a/backend/src/main/java/hanglog/trip/domain/City.java +++ b/backend/src/main/java/hanglog/city/domain/City.java @@ -1,4 +1,4 @@ -package hanglog.trip.domain; +package hanglog.city.domain; import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; diff --git a/backend/src/main/java/hanglog/trip/domain/repository/CityRepository.java b/backend/src/main/java/hanglog/city/domain/repository/CityRepository.java similarity index 64% rename from backend/src/main/java/hanglog/trip/domain/repository/CityRepository.java rename to backend/src/main/java/hanglog/city/domain/repository/CityRepository.java index f835609b8..04b45d7ff 100644 --- a/backend/src/main/java/hanglog/trip/domain/repository/CityRepository.java +++ b/backend/src/main/java/hanglog/city/domain/repository/CityRepository.java @@ -1,6 +1,6 @@ -package hanglog.trip.domain.repository; +package hanglog.city.domain.repository; -import hanglog.trip.domain.City; +import hanglog.city.domain.City; import org.springframework.data.jpa.repository.JpaRepository; public interface CityRepository extends JpaRepository { diff --git a/backend/src/main/java/hanglog/city/dto/response/CityResponse.java b/backend/src/main/java/hanglog/city/dto/response/CityResponse.java index ac427a8f5..6af45a91e 100644 --- a/backend/src/main/java/hanglog/city/dto/response/CityResponse.java +++ b/backend/src/main/java/hanglog/city/dto/response/CityResponse.java @@ -2,7 +2,7 @@ import static lombok.AccessLevel.PRIVATE; -import hanglog.trip.domain.City; +import hanglog.city.domain.City; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/backend/src/main/java/hanglog/trip/dto/response/CityWithPositionResponse.java b/backend/src/main/java/hanglog/city/dto/response/CityWithPositionResponse.java similarity index 90% rename from backend/src/main/java/hanglog/trip/dto/response/CityWithPositionResponse.java rename to backend/src/main/java/hanglog/city/dto/response/CityWithPositionResponse.java index fa3762a99..c9b51306f 100644 --- a/backend/src/main/java/hanglog/trip/dto/response/CityWithPositionResponse.java +++ b/backend/src/main/java/hanglog/city/dto/response/CityWithPositionResponse.java @@ -1,8 +1,8 @@ -package hanglog.trip.dto.response; +package hanglog.city.dto.response; import static lombok.AccessLevel.PRIVATE; -import hanglog.trip.domain.City; +import hanglog.city.domain.City; import java.math.BigDecimal; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/backend/src/main/java/hanglog/trip/presentation/CityController.java b/backend/src/main/java/hanglog/city/presentation/CityController.java similarity index 90% rename from backend/src/main/java/hanglog/trip/presentation/CityController.java rename to backend/src/main/java/hanglog/city/presentation/CityController.java index f181cdd95..9dcdb32c1 100644 --- a/backend/src/main/java/hanglog/trip/presentation/CityController.java +++ b/backend/src/main/java/hanglog/city/presentation/CityController.java @@ -1,7 +1,7 @@ -package hanglog.trip.presentation; +package hanglog.city.presentation; import hanglog.city.dto.response.CityResponse; -import hanglog.trip.service.CityService; +import hanglog.city.service.CityService; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; diff --git a/backend/src/main/java/hanglog/trip/service/CityService.java b/backend/src/main/java/hanglog/city/service/CityService.java similarity index 82% rename from backend/src/main/java/hanglog/trip/service/CityService.java rename to backend/src/main/java/hanglog/city/service/CityService.java index 7597e513b..a0f864707 100644 --- a/backend/src/main/java/hanglog/trip/service/CityService.java +++ b/backend/src/main/java/hanglog/city/service/CityService.java @@ -1,8 +1,8 @@ -package hanglog.trip.service; +package hanglog.city.service; import hanglog.city.dto.response.CityResponse; -import hanglog.trip.domain.City; -import hanglog.trip.domain.repository.CityRepository; +import hanglog.city.domain.City; +import hanglog.city.domain.repository.CityRepository; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/hanglog/expense/presentation/ExpenseController.java b/backend/src/main/java/hanglog/expense/presentation/ExpenseController.java index 872a77263..fa68e1d5f 100644 --- a/backend/src/main/java/hanglog/expense/presentation/ExpenseController.java +++ b/backend/src/main/java/hanglog/expense/presentation/ExpenseController.java @@ -1,6 +1,8 @@ package hanglog.expense.presentation; import hanglog.auth.Auth; +import hanglog.auth.MemberOnly; +import hanglog.auth.domain.Accessor; import hanglog.expense.dto.response.TripExpenseResponse; import hanglog.expense.service.ExpenseService; import hanglog.trip.service.TripService; @@ -20,8 +22,12 @@ public class ExpenseController { private final TripService tripService; @GetMapping - public ResponseEntity getExpenses(@Auth final Long memberId, @PathVariable final Long tripId) { - tripService.validateTripByMember(memberId, tripId); + @MemberOnly + public ResponseEntity getExpenses( + @Auth final Accessor accessor, + @PathVariable final Long tripId + ) { + tripService.validateTripByMember(accessor.getMemberId(), tripId); final TripExpenseResponse tripExpenseResponse = expenseService.getAllExpenses(tripId); return ResponseEntity.ok().body(tripExpenseResponse); } diff --git a/backend/src/main/java/hanglog/expense/service/ExpenseService.java b/backend/src/main/java/hanglog/expense/service/ExpenseService.java index b814fa43d..17fcfe93b 100644 --- a/backend/src/main/java/hanglog/expense/service/ExpenseService.java +++ b/backend/src/main/java/hanglog/expense/service/ExpenseService.java @@ -56,6 +56,7 @@ public TripExpenseResponse getAllExpenses(final Long tripId) { final List categoryExpenses = categoryAmounts.entrySet().stream() .map(entry -> new CategoryExpense(entry.getKey(), entry.getValue(), totalAmount)) + .sorted((o1,o2) -> o2.getAmount().compareTo(o1.getAmount())) .toList(); final List dayLogExpenses = dayLogAmounts.entrySet().stream() diff --git a/backend/src/main/java/hanglog/global/config/CorsConfig.java b/backend/src/main/java/hanglog/global/config/CorsConfig.java index cdb9ab918..92c231791 100644 --- a/backend/src/main/java/hanglog/global/config/CorsConfig.java +++ b/backend/src/main/java/hanglog/global/config/CorsConfig.java @@ -11,8 +11,8 @@ public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(final CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("*") - .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH") + .allowedOrigins("https://hanglog.com", "https://hanglog.site", "http://localhost:3000") + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .exposedHeaders(HttpHeaders.LOCATION); WebMvcConfigurer.super.addCorsMappings(registry); } diff --git a/backend/src/main/java/hanglog/global/exception/ExceptionCode.java b/backend/src/main/java/hanglog/global/exception/ExceptionCode.java index 1057cec00..8ba054aad 100644 --- a/backend/src/main/java/hanglog/global/exception/ExceptionCode.java +++ b/backend/src/main/java/hanglog/global/exception/ExceptionCode.java @@ -21,6 +21,8 @@ public enum ExceptionCode { NOT_FOUND_CURRENCY_DATA(1009, "요청한 날짜에 해당하는 환율 정보가 존재하지 않습니다."), NOT_FOUND_MEMBER_ID(1010, "요청한 ID에 해당하는 멤버가 존재하지 않습니다."), INVALID_TRIP_WITH_MEMBER(1011, "요청한 멤버와 ID에 해당하는 여행이 존재하지 않습니다."), + FAIL_TO_GENERATE_RANDOM_NICKNAME(1012, "랜덤한 닉네임을 생성하는데 실패하였습니다."), + DUPLICATED_MEMBER_NICKNAME(1013, "중복된 닉네임입니다."), ALREADY_DELETED_TRIP_ITEM(2001, "이미 삭제된 여행 아이템입니다."), ALREADY_DELETED_DATE(2002, "이미 삭제된 날짜입니다."), @@ -60,7 +62,8 @@ public enum ExceptionCode { EXPIRED_PERIOD_REFRESH_TOKEN(9103, "기한이 만료된 RefreshToken입니다."), EXPIRED_PERIOD_ACCESS_TOKEN(9104, "기한이 만료된 AccessToken입니다."), FAIL_TO_VALIDATE_TOKEN(9105, "토큰 유효성 검사 중 오류가 발생했습니다."), - NULL_REFRESH_TOKEN(9106, "refresh-token에 해당하는 쿠키 정보가 없습니다."), + NOT_FOUND_REFRESH_TOKEN(9106, "refresh-token에 해당하는 쿠키 정보가 없습니다."), + INVALID_AUTHORITY(9201, "해당 요청에 대한 접근 권한이 없습니다."), INTERNAL_SEVER_ERROR(9999, "서버 에러가 발생하였습니다. 관리자에게 문의해 주세요."); diff --git a/backend/src/main/java/hanglog/global/exception/RefreshTokenException.java b/backend/src/main/java/hanglog/global/exception/RefreshTokenException.java new file mode 100644 index 000000000..75e29fcc7 --- /dev/null +++ b/backend/src/main/java/hanglog/global/exception/RefreshTokenException.java @@ -0,0 +1,11 @@ +package hanglog.global.exception; + +import lombok.Getter; + +@Getter +public class RefreshTokenException extends AuthException { + + public RefreshTokenException(final ExceptionCode exceptionCode) { + super(exceptionCode); + } +} diff --git a/backend/src/main/java/hanglog/image/service/ImageService.java b/backend/src/main/java/hanglog/image/service/ImageService.java index 596120749..ed0aed663 100644 --- a/backend/src/main/java/hanglog/image/service/ImageService.java +++ b/backend/src/main/java/hanglog/image/service/ImageService.java @@ -25,6 +25,7 @@ public class ImageService { private static final int MAX_IMAGE_LIST_SIZE = 5; private static final int EMPTY_LIST_SIZE = 0; + private static final String CACHE_CONTROL_VALUE = "max-age=3153600"; private final AmazonS3 s3Client; @@ -63,6 +64,7 @@ private String uploadImage(final ImageFile imageFile) { final ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentType(imageFile.getContentType()); metadata.setContentLength(imageFile.getSize()); + metadata.setCacheControl(CACHE_CONTROL_VALUE); try (final InputStream inputStream = imageFile.getInputStream()) { s3Client.putObject(bucket, path, inputStream, metadata); diff --git a/backend/src/main/java/hanglog/member/domain/Member.java b/backend/src/main/java/hanglog/member/domain/Member.java index c5cd762fa..3ae430c58 100644 --- a/backend/src/main/java/hanglog/member/domain/Member.java +++ b/backend/src/main/java/hanglog/member/domain/Member.java @@ -32,7 +32,7 @@ public class Member { @Column(nullable = false, length = 30) private String socialLoginId; - @Column(nullable = false, length = 20) + @Column(nullable = false, unique = true, length = 20) private String nickname; @Column(nullable = false) @@ -59,9 +59,14 @@ public Member(final Long id, final String socialLoginId, final String nickname, this.imageUrl = imageUrl; this.status = ACTIVE; this.createdAt = LocalDateTime.now(); + this.modifiedAt = LocalDateTime.now(); } public Member(final String socialLoginId, final String nickname, final String imageUrl) { this(null, socialLoginId, nickname, imageUrl); } + + public boolean isNicknameChanged(final String inputNickname) { + return !nickname.equals(inputNickname); + } } diff --git a/backend/src/main/java/hanglog/member/domain/repository/MemberRepository.java b/backend/src/main/java/hanglog/member/domain/repository/MemberRepository.java index e9f0b5621..2d4c9d933 100644 --- a/backend/src/main/java/hanglog/member/domain/repository/MemberRepository.java +++ b/backend/src/main/java/hanglog/member/domain/repository/MemberRepository.java @@ -7,4 +7,6 @@ public interface MemberRepository extends JpaRepository { Optional findBySocialLoginId(String socialLoginId); + + boolean existsByNickname(String nickname); } diff --git a/backend/src/main/java/hanglog/member/presentation/MemberController.java b/backend/src/main/java/hanglog/member/presentation/MemberController.java index abf2a8d28..1a4abddc8 100644 --- a/backend/src/main/java/hanglog/member/presentation/MemberController.java +++ b/backend/src/main/java/hanglog/member/presentation/MemberController.java @@ -1,6 +1,8 @@ package hanglog.member.presentation; import hanglog.auth.Auth; +import hanglog.auth.MemberOnly; +import hanglog.auth.domain.Accessor; import hanglog.member.dto.request.MyPageRequest; import hanglog.member.dto.response.MyPageResponse; import hanglog.member.service.MemberService; @@ -15,23 +17,25 @@ @RestController @RequiredArgsConstructor -@RequestMapping("mypage") +@RequestMapping("/mypage") public class MemberController { public final MemberService memberService; @GetMapping - public ResponseEntity getMyInfo(@Auth final Long memberId) { - final MyPageResponse myPageResponse = memberService.getMyPageInfo(memberId); + @MemberOnly + public ResponseEntity getMyInfo(@Auth final Accessor accessor) { + final MyPageResponse myPageResponse = memberService.getMyPageInfo(accessor.getMemberId()); return ResponseEntity.ok().body(myPageResponse); } @PutMapping + @MemberOnly public ResponseEntity updateMyInfo( - @Auth final Long memberId, + @Auth final Accessor accessor, @RequestBody @Valid final MyPageRequest myPageRequest ) { - memberService.updateMyPageInfo(memberId, myPageRequest); + memberService.updateMyPageInfo(accessor.getMemberId(), myPageRequest); return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/hanglog/member/service/MemberService.java b/backend/src/main/java/hanglog/member/service/MemberService.java index 341cbfee9..a018a3f66 100644 --- a/backend/src/main/java/hanglog/member/service/MemberService.java +++ b/backend/src/main/java/hanglog/member/service/MemberService.java @@ -1,5 +1,6 @@ package hanglog.member.service; +import static hanglog.global.exception.ExceptionCode.DUPLICATED_MEMBER_NICKNAME; import static hanglog.global.exception.ExceptionCode.NOT_FOUND_MEMBER_ID; import hanglog.global.exception.BadRequestException; @@ -28,6 +29,11 @@ public MyPageResponse getMyPageInfo(final Long memberId) { public void updateMyPageInfo(final Long memberId, final MyPageRequest myPageRequest) { final Member member = memberRepository.findById(memberId) .orElseThrow(() -> new BadRequestException(NOT_FOUND_MEMBER_ID)); + + if (member.isNicknameChanged(myPageRequest.getNickname())) { + checkDuplicatedNickname(myPageRequest.getNickname()); + } + final Member updateMember = new Member( memberId, member.getSocialLoginId(), @@ -36,4 +42,10 @@ public void updateMyPageInfo(final Long memberId, final MyPageRequest myPageRequ ); memberRepository.save(updateMember); } + + private void checkDuplicatedNickname(final String nickname) { + if(memberRepository.existsByNickname(nickname)) { + throw new BadRequestException(DUPLICATED_MEMBER_NICKNAME); + } + } } diff --git a/backend/src/main/java/hanglog/share/domain/SharedTrip.java b/backend/src/main/java/hanglog/share/domain/SharedTrip.java index 1972f89a4..ded58e16f 100644 --- a/backend/src/main/java/hanglog/share/domain/SharedTrip.java +++ b/backend/src/main/java/hanglog/share/domain/SharedTrip.java @@ -8,6 +8,7 @@ import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; +import hanglog.global.BaseEntity; import hanglog.global.exception.InvalidDomainException; import hanglog.share.domain.type.SharedStatusType; import hanglog.trip.domain.Trip; @@ -16,8 +17,10 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -27,12 +30,17 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; @Entity @Getter @AllArgsConstructor +@SQLDelete(sql = "UPDATE shared_trip SET status = 'DELETED' WHERE id = ?") @NoArgsConstructor(access = PROTECTED) -public class SharedTrip { +@Where(clause = "status = 'USABLE'") +@Table(name = "shared_trip", indexes = @Index(name = "ux_shared_trip_shared_code", columnList = "sharedCode", unique = true)) +public class SharedTrip extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) diff --git a/backend/src/main/java/hanglog/share/presentation/SharedTripController.java b/backend/src/main/java/hanglog/share/presentation/SharedTripController.java index 812d43615..7fe40e7c2 100644 --- a/backend/src/main/java/hanglog/share/presentation/SharedTripController.java +++ b/backend/src/main/java/hanglog/share/presentation/SharedTripController.java @@ -1,6 +1,9 @@ package hanglog.share.presentation; import hanglog.auth.Auth; +import hanglog.auth.domain.Accessor; +import hanglog.expense.dto.response.TripExpenseResponse; +import hanglog.expense.service.ExpenseService; import hanglog.share.dto.request.SharedTripStatusRequest; import hanglog.share.dto.response.SharedTripCodeResponse; import hanglog.share.service.SharedTripService; @@ -22,6 +25,7 @@ public class SharedTripController { private final SharedTripService sharedTripService; private final TripService tripService; + private final ExpenseService expenseService; @GetMapping("/shared-trips/{sharedCode}") public ResponseEntity getSharedTrip(@PathVariable final String sharedCode) { @@ -32,15 +36,22 @@ public ResponseEntity getSharedTrip(@PathVariable final Stri @PatchMapping("/trips/{tripId}/share") public ResponseEntity updateSharedStatus( - @Auth final Long memberId, + @Auth final Accessor accessor, @PathVariable final Long tripId, @RequestBody @Valid final SharedTripStatusRequest sharedTripStatusRequest ) { - tripService.validateTripByMember(memberId, tripId); + tripService.validateTripByMember(accessor.getMemberId(), tripId); final SharedTripCodeResponse sharedTripCodeResponse = sharedTripService.updateSharedTripStatus( tripId, sharedTripStatusRequest ); return ResponseEntity.ok().body(sharedTripCodeResponse); } + + @GetMapping("/shared-trips/{sharedCode}/expense") + public ResponseEntity getSharedExpenses(@PathVariable final String sharedCode) { + final Long tripId = sharedTripService.getTripId(sharedCode); + final TripExpenseResponse tripExpenseResponse = expenseService.getAllExpenses(tripId); + return ResponseEntity.ok().body(tripExpenseResponse); + } } diff --git a/backend/src/main/java/hanglog/trip/domain/Like.java b/backend/src/main/java/hanglog/trip/domain/Like.java new file mode 100644 index 000000000..6984a48aa --- /dev/null +++ b/backend/src/main/java/hanglog/trip/domain/Like.java @@ -0,0 +1,34 @@ +package hanglog.trip.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import hanglog.member.domain.Member; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +public class Like { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "trip_id", nullable = false) + private Trip trip; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; +} diff --git a/backend/src/main/java/hanglog/trip/domain/PublishedTrip.java b/backend/src/main/java/hanglog/trip/domain/PublishedTrip.java new file mode 100644 index 000000000..82bada7b6 --- /dev/null +++ b/backend/src/main/java/hanglog/trip/domain/PublishedTrip.java @@ -0,0 +1,34 @@ +package hanglog.trip.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import hanglog.global.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +@Entity +@Getter +@AllArgsConstructor +@SQLDelete(sql = "UPDATE shared_trip SET status = 'DELETED' WHERE id = ?") +@NoArgsConstructor(access = PROTECTED) +@Where(clause = "status = 'USABLE'") +public class PublishedTrip extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "trip_id", nullable = false) + private Trip trip; +} diff --git a/backend/src/main/java/hanglog/trip/domain/Trip.java b/backend/src/main/java/hanglog/trip/domain/Trip.java index 3fcdd1195..3ad6528dd 100644 --- a/backend/src/main/java/hanglog/trip/domain/Trip.java +++ b/backend/src/main/java/hanglog/trip/domain/Trip.java @@ -5,6 +5,7 @@ import static jakarta.persistence.CascadeType.MERGE; import static jakarta.persistence.CascadeType.PERSIST; import static jakarta.persistence.CascadeType.REMOVE; +import static jakarta.persistence.EnumType.STRING; import static jakarta.persistence.FetchType.LAZY; import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; @@ -12,9 +13,11 @@ import hanglog.global.BaseEntity; import hanglog.member.domain.Member; import hanglog.share.domain.SharedTrip; +import hanglog.trip.domain.type.PublishedStatusType; import hanglog.trip.dto.request.TripUpdateRequest; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -25,6 +28,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.SQLDelete; @@ -62,6 +66,10 @@ public class Trip extends BaseEntity { @Column(nullable = false) private String description; + @Column(nullable = false) + @Enumerated(value = STRING) + private PublishedStatusType publishedStatus; + @OneToMany(mappedBy = "trip", cascade = {PERSIST, REMOVE, MERGE}, orphanRemoval = true) @OrderBy(value = "ordinal ASC") private List dayLogs = new ArrayList<>(); @@ -78,7 +86,8 @@ public Trip( final LocalDate endDate, final String description, final SharedTrip sharedTrip, - final List dayLogs + final List dayLogs, + final PublishedStatusType publishedStatusType ) { super(USABLE); this.id = id; @@ -90,6 +99,7 @@ public Trip( this.description = description; this.sharedTrip = sharedTrip; this.dayLogs = dayLogs; + this.publishedStatus = publishedStatusType; } public static Trip of(final Member member, final String title, final LocalDate startDate, final LocalDate endDate) { @@ -102,7 +112,8 @@ public static Trip of(final Member member, final String title, final LocalDate s endDate, "", null, - new ArrayList<>() + new ArrayList<>(), + PublishedStatusType.UNPUBLISHED ); } @@ -120,4 +131,14 @@ private String updateImageUrl(final String imageUrl) { } return convertUrlToName(imageUrl); } + + public Optional getSharedCode() { + if (Optional.ofNullable(sharedTrip).isEmpty()) { + return Optional.empty(); + } + if (sharedTrip.isUnShared()) { + return Optional.empty(); + } + return Optional.of(sharedTrip.getSharedCode()); + } } diff --git a/backend/src/main/java/hanglog/trip/domain/TripCity.java b/backend/src/main/java/hanglog/trip/domain/TripCity.java index 8bd142236..ed40f62ed 100644 --- a/backend/src/main/java/hanglog/trip/domain/TripCity.java +++ b/backend/src/main/java/hanglog/trip/domain/TripCity.java @@ -4,6 +4,7 @@ import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; +import hanglog.city.domain.City; import hanglog.global.BaseEntity; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; diff --git a/backend/src/main/java/hanglog/trip/domain/type/PublishedStatusType.java b/backend/src/main/java/hanglog/trip/domain/type/PublishedStatusType.java new file mode 100644 index 000000000..b0c6fda9a --- /dev/null +++ b/backend/src/main/java/hanglog/trip/domain/type/PublishedStatusType.java @@ -0,0 +1,7 @@ +package hanglog.trip.domain.type; + +public enum PublishedStatusType { + + PUBLISHED, + UNPUBLISHED +} diff --git a/backend/src/main/java/hanglog/trip/dto/response/TripDetailResponse.java b/backend/src/main/java/hanglog/trip/dto/response/TripDetailResponse.java index 76d82c975..70cebbc53 100644 --- a/backend/src/main/java/hanglog/trip/dto/response/TripDetailResponse.java +++ b/backend/src/main/java/hanglog/trip/dto/response/TripDetailResponse.java @@ -3,12 +3,11 @@ import static hanglog.image.util.ImageUrlConverter.convertNameToUrl; import static lombok.AccessLevel.PRIVATE; -import hanglog.share.domain.SharedTrip; -import hanglog.trip.domain.City; +import hanglog.city.dto.response.CityWithPositionResponse; +import hanglog.city.domain.City; import hanglog.trip.domain.Trip; import java.time.LocalDate; import java.util.List; -import java.util.Optional; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -35,8 +34,7 @@ public static TripDetailResponse of(final Trip trip, final List cities) { .map(CityWithPositionResponse::of) .toList(); - final Optional sharedTrip = Optional.ofNullable(trip.getSharedTrip()); - final String sharedCode = sharedTrip.map(SharedTrip::getSharedCode).orElse(null); + final String sharedCode = trip.getSharedCode().orElse(null); return new TripDetailResponse( trip.getId(), diff --git a/backend/src/main/java/hanglog/trip/dto/response/TripResponse.java b/backend/src/main/java/hanglog/trip/dto/response/TripResponse.java index 91007c833..d6e8978aa 100644 --- a/backend/src/main/java/hanglog/trip/dto/response/TripResponse.java +++ b/backend/src/main/java/hanglog/trip/dto/response/TripResponse.java @@ -4,7 +4,7 @@ import static lombok.AccessLevel.PRIVATE; import hanglog.city.dto.response.CityResponse; -import hanglog.trip.domain.City; +import hanglog.city.domain.City; import hanglog.trip.domain.Trip; import java.time.LocalDate; import java.util.List; diff --git a/backend/src/main/java/hanglog/trip/presentation/DayLogController.java b/backend/src/main/java/hanglog/trip/presentation/DayLogController.java index 1f31fba24..199cc298e 100644 --- a/backend/src/main/java/hanglog/trip/presentation/DayLogController.java +++ b/backend/src/main/java/hanglog/trip/presentation/DayLogController.java @@ -2,6 +2,8 @@ import hanglog.auth.Auth; +import hanglog.auth.MemberOnly; +import hanglog.auth.domain.Accessor; import hanglog.trip.dto.request.DayLogUpdateTitleRequest; import hanglog.trip.dto.request.ItemsOrdinalUpdateRequest; import hanglog.trip.dto.response.DayLogResponse; @@ -26,36 +28,39 @@ public class DayLogController { private final TripService tripService; @GetMapping + @MemberOnly public ResponseEntity getDayLog( - @Auth final Long memberId, + @Auth final Accessor accessor, @PathVariable final Long tripId, @PathVariable final Long dayLogId ) { - tripService.validateTripByMember(memberId, tripId); + tripService.validateTripByMember(accessor.getMemberId(), tripId); final DayLogResponse response = dayLogService.getById(dayLogId); return ResponseEntity.ok(response); } @PatchMapping + @MemberOnly public ResponseEntity updateDayLogTitle( - @Auth final Long memberId, + @Auth final Accessor accessor, @PathVariable final Long tripId, @PathVariable final Long dayLogId, @RequestBody @Valid final DayLogUpdateTitleRequest request ) { - tripService.validateTripByMember(memberId, tripId); + tripService.validateTripByMember(accessor.getMemberId(), tripId); dayLogService.updateTitle(dayLogId, request); return ResponseEntity.noContent().build(); } @PatchMapping("/order") + @MemberOnly public ResponseEntity updateOrdinalOfItems( - @Auth final Long memberId, + @Auth final Accessor accessor, @PathVariable final Long tripId, @PathVariable final Long dayLogId, @RequestBody @Valid final ItemsOrdinalUpdateRequest itemsOrdinalUpdateRequest ) { - tripService.validateTripByMember(memberId, tripId); + tripService.validateTripByMember(accessor.getMemberId(), tripId); dayLogService.updateOrdinalOfItems(dayLogId, itemsOrdinalUpdateRequest); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/hanglog/trip/presentation/ItemController.java b/backend/src/main/java/hanglog/trip/presentation/ItemController.java index 606886939..b3ca75bec 100644 --- a/backend/src/main/java/hanglog/trip/presentation/ItemController.java +++ b/backend/src/main/java/hanglog/trip/presentation/ItemController.java @@ -1,6 +1,8 @@ package hanglog.trip.presentation; import hanglog.auth.Auth; +import hanglog.auth.MemberOnly; +import hanglog.auth.domain.Accessor; import hanglog.trip.dto.request.ItemRequest; import hanglog.trip.dto.request.ItemUpdateRequest; import hanglog.trip.service.ItemService; @@ -26,35 +28,38 @@ public class ItemController { private final TripService tripService; @PostMapping + @MemberOnly public ResponseEntity createItem( - @Auth final Long memberId, + @Auth final Accessor accessor, @PathVariable final Long tripId, @RequestBody @Valid final ItemRequest itemRequest ) { - tripService.validateTripByMember(memberId, tripId); + tripService.validateTripByMember(accessor.getMemberId(), tripId); final Long itemId = itemService.save(tripId, itemRequest); return ResponseEntity.created(URI.create("/trips/" + tripId + "/items/" + itemId)).build(); } @PutMapping("/{itemId}") + @MemberOnly public ResponseEntity updateItem( - @Auth final Long memberId, + @Auth final Accessor accessor, @PathVariable final Long tripId, @PathVariable final Long itemId, @RequestBody @Valid final ItemUpdateRequest itemUpdateRequest ) { - tripService.validateTripByMember(memberId, tripId); + tripService.validateTripByMember(accessor.getMemberId(), tripId); itemService.update(tripId, itemId, itemUpdateRequest); return ResponseEntity.noContent().build(); } @DeleteMapping("/{itemId}") + @MemberOnly public ResponseEntity deleteItem( - @Auth final Long memberId, + @Auth final Accessor accessor, @PathVariable final Long tripId, @PathVariable final Long itemId ) { - tripService.validateTripByMember(memberId, tripId); + tripService.validateTripByMember(accessor.getMemberId(), tripId); itemService.delete(itemId); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/hanglog/trip/presentation/TripController.java b/backend/src/main/java/hanglog/trip/presentation/TripController.java index 67b451068..aa31a15d0 100644 --- a/backend/src/main/java/hanglog/trip/presentation/TripController.java +++ b/backend/src/main/java/hanglog/trip/presentation/TripController.java @@ -1,6 +1,8 @@ package hanglog.trip.presentation; import hanglog.auth.Auth; +import hanglog.auth.MemberOnly; +import hanglog.auth.domain.Accessor; import hanglog.trip.dto.request.TripCreateRequest; import hanglog.trip.dto.request.TripUpdateRequest; import hanglog.trip.dto.response.TripDetailResponse; @@ -28,41 +30,46 @@ public class TripController { private final TripService tripService; @PostMapping + @MemberOnly public ResponseEntity createTrip( - @Auth final Long memberId, + @Auth final Accessor accessor, @RequestBody @Valid final TripCreateRequest tripCreateRequest ) { - final Long tripId = tripService.save(memberId, tripCreateRequest); + final Long tripId = tripService.save(accessor.getMemberId(), tripCreateRequest); return ResponseEntity.created(URI.create("/trips/" + tripId)).build(); } @GetMapping - public ResponseEntity> getTrips(@Auth final Long memberId) { - final List tripResponses = tripService.getAllTrips(memberId); + @MemberOnly + public ResponseEntity> getTrips(@Auth final Accessor accessor) { + final List tripResponses = tripService.getAllTrips(accessor.getMemberId()); return ResponseEntity.ok().body(tripResponses); } @GetMapping("/{tripId}") - public ResponseEntity getTrip(@Auth final Long memberId, @PathVariable final Long tripId) { - tripService.validateTripByMember(memberId, tripId); + @MemberOnly + public ResponseEntity getTrip(@Auth final Accessor accessor, @PathVariable final Long tripId) { + tripService.validateTripByMember(accessor.getMemberId(), tripId); final TripDetailResponse tripDetailResponse = tripService.getTripDetail(tripId); return ResponseEntity.ok().body(tripDetailResponse); } @PutMapping("/{tripId}") + @MemberOnly public ResponseEntity updateTrip( - @Auth final Long memberId, + @Auth final Accessor accessor, @PathVariable final Long tripId, @RequestBody @Valid final TripUpdateRequest updateRequest ) { - tripService.validateTripByMember(memberId, tripId); + tripService.validateTripByMember(accessor.getMemberId(), tripId); tripService.update(tripId, updateRequest); return ResponseEntity.noContent().build(); } @DeleteMapping("/{tripId}") - public ResponseEntity deleteTrip(@Auth final Long memberId, @PathVariable final Long tripId) { - tripService.validateTripByMember(memberId, tripId); + @MemberOnly + public ResponseEntity deleteTrip(@Auth final Accessor accessor, @PathVariable final Long tripId) { + tripService.validateTripByMember(accessor.getMemberId(), tripId); tripService.delete(tripId); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/hanglog/trip/service/TripService.java b/backend/src/main/java/hanglog/trip/service/TripService.java index 32107324c..710652939 100644 --- a/backend/src/main/java/hanglog/trip/service/TripService.java +++ b/backend/src/main/java/hanglog/trip/service/TripService.java @@ -5,15 +5,15 @@ import static hanglog.global.exception.ExceptionCode.NOT_FOUND_MEMBER_ID; import static hanglog.global.exception.ExceptionCode.NOT_FOUND_TRIP_ID; +import hanglog.city.domain.City; +import hanglog.city.domain.repository.CityRepository; import hanglog.global.exception.AuthException; import hanglog.global.exception.BadRequestException; import hanglog.member.domain.Member; import hanglog.member.domain.repository.MemberRepository; -import hanglog.trip.domain.City; import hanglog.trip.domain.DayLog; import hanglog.trip.domain.Trip; import hanglog.trip.domain.TripCity; -import hanglog.trip.domain.repository.CityRepository; import hanglog.trip.domain.repository.TripCityRepository; import hanglog.trip.domain.repository.TripRepository; import hanglog.trip.dto.request.TripCreateRequest; @@ -169,6 +169,7 @@ public void delete(final Long tripId) { final Trip trip = tripRepository.findById(tripId) .orElseThrow(() -> new BadRequestException(NOT_FOUND_TRIP_ID)); tripRepository.delete(trip); + tripCityRepository.deleteAllByTripId(tripId); } private String generateInitialTitle(final List cites) { diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 000000000..8600517b0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,158 @@ +CREATE TABLE IF NOT EXISTS category ( + created_at DATETIME(6) NOT NULL, + id BIGINT NOT NULL, + modified_at DATETIME(6) NOT NULL, + eng_name VARCHAR(50) NOT NULL, + kor_name VARCHAR(50) NOT NULL, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS city ( + latitude DECIMAL(16,13) NOT NULL, + longitude DECIMAL(16,13) NOT NULL, + created_at DATETIME(6) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + modified_at DATETIME(6) NOT NULL, + country VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS currency ( + chf FLOAT(53) NOT NULL, + cny FLOAT(53) NOT NULL, + date DATE NOT NULL UNIQUE, + eur FLOAT(53) NOT NULL, + gbp FLOAT(53) NOT NULL, + hkd FLOAT(53) NOT NULL, + jpy FLOAT(53) NOT NULL, + krw FLOAT(53) NOT NULL, + sgd FLOAT(53) NOT NULL, + thb FLOAT(53) NOT NULL, + usd FLOAT(53) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS member ( + created_at DATETIME(6), + id BIGINT NOT NULL AUTO_INCREMENT, + last_login_date DATETIME(6) NOT NULL, + modified_at DATETIME(6), + nickname VARCHAR(20) NOT NULL, + social_login_id VARCHAR(30) NOT NULL, + image_url VARCHAR(255) NOT NULL, + status ENUM ('ACTIVE','DELETED','DORMANT'), + PRIMARY KEY (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS trip ( + end_date DATE NOT NULL, + start_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT, + modified_at DATETIME(6) NOT NULL, + title VARCHAR(50) NOT NULL, + description VARCHAR(255) NOT NULL, + image_name VARCHAR(255) NOT NULL, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (member_id) REFERENCES member (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS day_log ( + ordinal INTEGER NOT NULL, + created_at DATETIME(6) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + modified_at DATETIME(6) NOT NULL, + trip_id BIGINT NOT NULL, + title VARCHAR(50) NOT NULL, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (trip_id) REFERENCES trip (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS expense ( + amount DECIMAL(38,3), + category_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + modified_at DATETIME(6) NOT NULL, + currency VARCHAR(255) NOT NULL, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (category_id) REFERENCES category (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS place ( + latitude DECIMAL(16,13) NOT NULL, + longitude DECIMAL(16,13) NOT NULL, + category_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + modified_at DATETIME(6) NOT NULL, + name VARCHAR(255) NOT NULL, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (category_id) REFERENCES category (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS item ( + ordinal INTEGER NOT NULL, + rating FLOAT(53), + created_at DATETIME(6) NOT NULL, + day_log_id BIGINT NOT NULL, + expense_id BIGINT UNIQUE, + id BIGINT NOT NULL AUTO_INCREMENT, + modified_at DATETIME(6) NOT NULL, + place_id BIGINT UNIQUE, + title VARCHAR(50) NOT NULL, + item_type ENUM ('NON_SPOT','SPOT') NOT NULL, + memo VARCHAR(255), + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (day_log_id) REFERENCES day_log (id), + FOREIGN KEY (expense_id) REFERENCES expense (id), + FOREIGN KEY (place_id) REFERENCES place (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS image ( + created_at DATETIME(6) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + item_id BIGINT, + modified_at DATETIME(6) NOT NULL, + name VARCHAR(255) NOT NULL UNIQUE, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (item_id) REFERENCES item (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS refresh_token ( + member_id BIGINT NOT NULL UNIQUE, + token VARCHAR(255) NOT NULL, + PRIMARY KEY (token) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS shared_trip ( + id BIGINT NOT NULL AUTO_INCREMENT, + trip_id BIGINT NOT NULL UNIQUE, + shared_code VARCHAR(255) NOT NULL, + shared_status ENUM ('SHARED','UNSHARED') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (trip_id) REFERENCES trip (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS trip_city ( + city_id BIGINT, + created_at DATETIME(6) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + modified_at DATETIME(6) NOT NULL, + trip_id BIGINT, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (city_id) REFERENCES city (id), + FOREIGN KEY (trip_id) REFERENCES trip (id) +) engine=InnoDB; diff --git a/backend/src/main/resources/db/migration/V2__Add_base_entity_to_shared_trip.sql b/backend/src/main/resources/db/migration/V2__Add_base_entity_to_shared_trip.sql new file mode 100644 index 000000000..c047ad0b0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__Add_base_entity_to_shared_trip.sql @@ -0,0 +1,9 @@ +ALTER TABLE shared_trip ADD COLUMN created_at DATETIME(6); +ALTER TABLE shared_trip ADD COLUMN modified_at DATETIME(6); +ALTER TABLE shared_trip ADD COLUMN status ENUM ('DELETED','USABLE'); + +UPDATE shared_trip SET created_at = now(), modified_at = now(), status = 'USABLE'; + +ALTER TABLE shared_trip MODIFY COLUMN created_at DATETIME(6) NOT NULL; +ALTER TABLE shared_trip MODIFY COLUMN modified_at DATETIME(6) NOT NULL; +ALTER TABLE shared_trip MODIFY COLUMN status ENUM ('DELETED','USABLE') NOT NULL; diff --git a/backend/src/main/resources/db/migration/V3__Add_unique_to_member_nickname.sql b/backend/src/main/resources/db/migration/V3__Add_unique_to_member_nickname.sql new file mode 100644 index 000000000..d36d264d9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__Add_unique_to_member_nickname.sql @@ -0,0 +1 @@ +ALTER TABLE member ADD CONSTRAINT unique_nickname UNIQUE (nickname); diff --git a/backend/src/main/resources/db/migration/V4__Creat_table_published_trip_and_like.sql b/backend/src/main/resources/db/migration/V4__Creat_table_published_trip_and_like.sql new file mode 100644 index 000000000..b5eba3b2a --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__Creat_table_published_trip_and_like.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS `like` ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + trip_id BIGINT NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (member_id) REFERENCES member (id), + FOREIGN KEY (trip_id) REFERENCES trip (id) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS published_trip ( + id BIGINT NOT NULL AUTO_INCREMENT, + trip_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + modified_at DATETIME(6) NOT NULL, + status ENUM ('DELETED','USABLE') NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (trip_id) REFERENCES trip (id) +) engine=InnoDB; + +ALTER TABLE trip ADD COLUMN published_status ENUM ('PUBLISHED','UNPUBLISHED'); + +UPDATE trip SET published_status = 'UNPUBLISHED'; + +ALTER TABLE trip MODIFY COLUMN published_status ENUM ('PUBLISHED','UNPUBLISHED') NOT NULL; diff --git a/backend/src/main/resources/db/migration/V5__Add_index_to_shared_trip.sql b/backend/src/main/resources/db/migration/V5__Add_index_to_shared_trip.sql new file mode 100644 index 000000000..c52a76a2e --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__Add_index_to_shared_trip.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX ux_shared_trip_shared_code ON shared_trip (shared_code); diff --git a/backend/src/main/resources/db/migration/V6__Unset_memberid_unique.sql b/backend/src/main/resources/db/migration/V6__Unset_memberid_unique.sql new file mode 100644 index 000000000..aad0783f4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__Unset_memberid_unique.sql @@ -0,0 +1 @@ +DROP INDEX member_id ON refresh_token; diff --git a/backend/src/test/java/hanglog/auth/presentation/AuthControllerTest.java b/backend/src/test/java/hanglog/auth/presentation/AuthControllerTest.java index 2bbd37056..d9bf4235d 100644 --- a/backend/src/test/java/hanglog/auth/presentation/AuthControllerTest.java +++ b/backend/src/test/java/hanglog/auth/presentation/AuthControllerTest.java @@ -1,6 +1,6 @@ package hanglog.auth.presentation; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -158,6 +158,7 @@ void extendLogin() throws Exception { @Test void logout() throws Exception { // given + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); doNothing().when(authService).removeMemberRefreshToken(anyLong()); @@ -193,6 +194,7 @@ void logout() throws Exception { @Test void deleteAccount() throws Exception { // given + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); doNothing().when(authService).deleteAccount(anyLong()); diff --git a/backend/src/test/java/hanglog/category/presentation/CategoryControllerTest.java b/backend/src/test/java/hanglog/category/presentation/CategoryControllerTest.java index be14468ec..8d9124fc0 100644 --- a/backend/src/test/java/hanglog/category/presentation/CategoryControllerTest.java +++ b/backend/src/test/java/hanglog/category/presentation/CategoryControllerTest.java @@ -1,7 +1,7 @@ package hanglog.category.presentation; import static hanglog.category.fixture.CategoryFixture.EXPENSE_CATEGORIES; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import static org.springframework.http.MediaType.APPLICATION_JSON; diff --git a/backend/src/test/java/hanglog/expense/fixture/TripExpenseFixture.java b/backend/src/test/java/hanglog/expense/fixture/TripExpenseFixture.java index de65d75dc..1f36d8e9c 100644 --- a/backend/src/test/java/hanglog/expense/fixture/TripExpenseFixture.java +++ b/backend/src/test/java/hanglog/expense/fixture/TripExpenseFixture.java @@ -3,12 +3,13 @@ import static hanglog.expense.fixture.ExpenseFixture.EUR_100_SHOPPING_EXPENSE; import static hanglog.expense.fixture.ExpenseFixture.KRW_100_FOOD_EXPENSE; import static hanglog.expense.fixture.ExpenseFixture.USD_100_ACCOMMODATION_EXPENSE; -import static hanglog.global.IntegrationFixture.MEMBER; +import static hanglog.integration.IntegrationFixture.MEMBER; import hanglog.trip.domain.DayLog; import hanglog.trip.domain.Item; import hanglog.trip.domain.Trip; import hanglog.trip.domain.type.ItemType; +import hanglog.trip.domain.type.PublishedStatusType; import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; @@ -25,7 +26,8 @@ public class TripExpenseFixture { LocalDate.of(2023, 7, 2), "", null, - new ArrayList<>() + new ArrayList<>(), + PublishedStatusType.UNPUBLISHED ); public static final DayLog DAYLOG_1_FOR_EXPENSE = new DayLog( diff --git a/backend/src/test/java/hanglog/expense/presentation/ExpenseControllerTest.java b/backend/src/test/java/hanglog/expense/presentation/ExpenseControllerTest.java index cd1c61a02..8cec19f58 100644 --- a/backend/src/test/java/hanglog/expense/presentation/ExpenseControllerTest.java +++ b/backend/src/test/java/hanglog/expense/presentation/ExpenseControllerTest.java @@ -3,12 +3,12 @@ import static hanglog.category.fixture.CategoryFixture.EXPENSE_CATEGORIES; import static hanglog.expense.fixture.AmountFixture.AMOUNT_20000; import static hanglog.expense.fixture.CurrencyFixture.DEFAULT_CURRENCY; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static hanglog.trip.fixture.CityFixture.LONDON; import static hanglog.trip.fixture.CityFixture.TOKYO; import static hanglog.trip.fixture.DayLogFixture.EXPENSE_LONDON_DAYLOG; import static hanglog.trip.fixture.TripFixture.LONDON_TO_JAPAN; import static hanglog.trip.fixture.TripFixture.LONDON_TRIP; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; @@ -66,6 +66,7 @@ private ResultActions performGetRequest(final int tripId) throws Exception { @Test void getExpenses() throws Exception { // given + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); doNothing().when(tripService).validateTripByMember(anyLong(), anyLong()); @@ -79,7 +80,6 @@ void getExpenses() throws Exception { List.of(new DayLogExpense(EXPENSE_LONDON_DAYLOG, AMOUNT_20000)) ); - // when & then when(expenseService.getAllExpenses(1L)).thenReturn(tripExpenseResponse); // when diff --git a/backend/src/test/java/hanglog/expense/service/ExpenseServiceTest.java b/backend/src/test/java/hanglog/expense/service/ExpenseServiceTest.java index 3dde9a815..7fc600161 100644 --- a/backend/src/test/java/hanglog/expense/service/ExpenseServiceTest.java +++ b/backend/src/test/java/hanglog/expense/service/ExpenseServiceTest.java @@ -91,11 +91,6 @@ void getAllExpenses() { totalAmount, tripCities, List.of( - new CategoryExpense( - FOOD, - KRW_100_FOOD.exchangeAmount, - totalAmount - ), new CategoryExpense( SHOPPING, EUR_100_SHOPPING.exchangeAmount, @@ -105,6 +100,11 @@ void getAllExpenses() { ACCOMMODATION, USD_100_ACCOMMODATION.exchangeAmount, totalAmount + ), + new CategoryExpense( + FOOD, + KRW_100_FOOD.exchangeAmount, + totalAmount ) ), DEFAULT_CURRENCY, @@ -113,10 +113,16 @@ void getAllExpenses() { new DayLogExpense(DAYLOG_2_FOR_EXPENSE, day2Amount) ) ); + final List expectCategories = expected.getCategories().stream() + .map(categoryExpenseResponse -> categoryExpenseResponse.getCategory().getName()) + .toList(); // when final TripExpenseResponse actual = expenseService.getAllExpenses(1L); - + final List actualCategories = actual.getCategories().stream() + .filter(categoryExpenseResponse -> !categoryExpenseResponse.getAmount().equals(BigDecimal.ZERO)) + .map(categoryExpenseResponse -> categoryExpenseResponse.getCategory().getName()) + .toList(); // then assertSoftly(softly -> { softly.assertThat(actual) @@ -126,6 +132,8 @@ void getAllExpenses() { softly.assertThat(actual.getCategories()) .usingRecursiveFieldByFieldElementComparatorOnFields() .containsAll(expected.getCategories()); + softly.assertThat(actualCategories) + .isEqualTo(expectCategories); }); } diff --git a/backend/src/test/java/hanglog/global/ControllerTest.java b/backend/src/test/java/hanglog/global/ControllerTest.java index 2518c5dd6..44c9594b4 100644 --- a/backend/src/test/java/hanglog/global/ControllerTest.java +++ b/backend/src/test/java/hanglog/global/ControllerTest.java @@ -5,7 +5,8 @@ import hanglog.auth.AuthArgumentResolver; import hanglog.auth.domain.BearerAuthorizationExtractor; import hanglog.auth.domain.JwtProvider; -import hanglog.trip.restdocs.RestDocsConfiguration; +import hanglog.auth.domain.repository.RefreshTokenRepository; +import hanglog.global.restdocs.RestDocsConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -35,9 +36,13 @@ public abstract class ControllerTest { @MockBean protected JwtProvider jwtProvider; + @MockBean + protected RefreshTokenRepository refreshTokenRepository; + @MockBean BearerAuthorizationExtractor bearerExtractor; + @BeforeEach void setUp( final WebApplicationContext context, diff --git a/backend/src/test/java/hanglog/trip/restdocs/RestDocsConfiguration.java b/backend/src/test/java/hanglog/global/restdocs/RestDocsConfiguration.java similarity index 97% rename from backend/src/test/java/hanglog/trip/restdocs/RestDocsConfiguration.java rename to backend/src/test/java/hanglog/global/restdocs/RestDocsConfiguration.java index 4568c7aab..8874c0132 100644 --- a/backend/src/test/java/hanglog/trip/restdocs/RestDocsConfiguration.java +++ b/backend/src/test/java/hanglog/global/restdocs/RestDocsConfiguration.java @@ -1,4 +1,4 @@ -package hanglog.trip.restdocs; +package hanglog.global.restdocs; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/backend/src/test/java/hanglog/image/presentation/ImageControllerTest.java b/backend/src/test/java/hanglog/image/presentation/ImageControllerTest.java index 8985a9d3c..4b23c9a25 100644 --- a/backend/src/test/java/hanglog/image/presentation/ImageControllerTest.java +++ b/backend/src/test/java/hanglog/image/presentation/ImageControllerTest.java @@ -1,6 +1,6 @@ package hanglog.image.presentation; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.http.HttpMethod.POST; diff --git a/backend/src/test/java/hanglog/global/IntegrationFixture.java b/backend/src/test/java/hanglog/integration/IntegrationFixture.java similarity index 98% rename from backend/src/test/java/hanglog/global/IntegrationFixture.java rename to backend/src/test/java/hanglog/integration/IntegrationFixture.java index b0c45fc1f..7977c1f04 100644 --- a/backend/src/test/java/hanglog/global/IntegrationFixture.java +++ b/backend/src/test/java/hanglog/integration/IntegrationFixture.java @@ -1,13 +1,13 @@ -package hanglog.global; +package hanglog.integration; import static hanglog.trip.domain.type.ItemType.SPOT; import hanglog.category.domain.Category; +import hanglog.city.domain.City; import hanglog.expense.domain.Amount; import hanglog.expense.domain.Expense; import hanglog.image.domain.Image; import hanglog.member.domain.Member; -import hanglog.trip.domain.City; import hanglog.trip.domain.DayLog; import hanglog.trip.domain.Item; import hanglog.trip.domain.Place; diff --git a/backend/src/test/java/hanglog/trip/integration/DayLogIntegrationTest.java b/backend/src/test/java/hanglog/integration/controller/DayLogIntegrationTest.java similarity index 96% rename from backend/src/test/java/hanglog/trip/integration/DayLogIntegrationTest.java rename to backend/src/test/java/hanglog/integration/controller/DayLogIntegrationTest.java index 87a439b44..d632f2e63 100644 --- a/backend/src/test/java/hanglog/trip/integration/DayLogIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/controller/DayLogIntegrationTest.java @@ -1,13 +1,12 @@ -package hanglog.trip.integration; +package hanglog.integration.controller; -import static hanglog.global.IntegrationFixture.START_DATE; -import static hanglog.global.IntegrationFixture.TRIP_CREATE_REQUEST; -import static hanglog.trip.integration.ItemIntegrationTest.requestCreateItem; +import static hanglog.integration.IntegrationFixture.START_DATE; +import static hanglog.integration.IntegrationFixture.TRIP_CREATE_REQUEST; +import static hanglog.integration.controller.ItemIntegrationTest.requestCreateItem; import static io.restassured.http.ContentType.JSON; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.springframework.http.HttpHeaders.AUTHORIZATION; -import hanglog.global.IntegrationTest; import hanglog.trip.dto.request.DayLogUpdateTitleRequest; import hanglog.trip.dto.request.ItemRequest; import hanglog.trip.dto.request.ItemsOrdinalUpdateRequest; diff --git a/backend/src/test/java/hanglog/global/IntegrationTest.java b/backend/src/test/java/hanglog/integration/controller/IntegrationTest.java similarity index 80% rename from backend/src/test/java/hanglog/global/IntegrationTest.java rename to backend/src/test/java/hanglog/integration/controller/IntegrationTest.java index df1d9c566..24088c170 100644 --- a/backend/src/test/java/hanglog/global/IntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/controller/IntegrationTest.java @@ -1,7 +1,9 @@ -package hanglog.global; +package hanglog.integration.controller; import hanglog.auth.domain.JwtProvider; import hanglog.auth.domain.MemberTokens; +import hanglog.auth.domain.RefreshToken; +import hanglog.auth.domain.repository.RefreshTokenRepository; import hanglog.member.domain.Member; import hanglog.member.domain.repository.MemberRepository; import io.restassured.RestAssured; @@ -27,6 +29,9 @@ public abstract class IntegrationTest { @Autowired private MemberRepository memberRepository; + @Autowired + private RefreshTokenRepository refreshTokenRepository; + @Autowired private JwtProvider jwtProvider; @@ -43,6 +48,8 @@ void setAuth() { memberRepository.save(member); final Long memberId = member.getId(); memberTokens = jwtProvider.generateLoginToken(memberId.toString()); + final RefreshToken refreshToken = new RefreshToken(memberTokens.getRefreshToken(), memberId); + refreshTokenRepository.save(refreshToken); } @BeforeEach diff --git a/backend/src/test/java/hanglog/trip/integration/ItemIntegrationTest.java b/backend/src/test/java/hanglog/integration/controller/ItemIntegrationTest.java similarity index 96% rename from backend/src/test/java/hanglog/trip/integration/ItemIntegrationTest.java rename to backend/src/test/java/hanglog/integration/controller/ItemIntegrationTest.java index 5d63d46f2..d39d021d6 100644 --- a/backend/src/test/java/hanglog/trip/integration/ItemIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/controller/ItemIntegrationTest.java @@ -1,13 +1,13 @@ -package hanglog.trip.integration; +package hanglog.integration.controller; -import static hanglog.global.IntegrationFixture.EDINBURGH; -import static hanglog.global.IntegrationFixture.END_DATE; -import static hanglog.global.IntegrationFixture.LONDON; -import static hanglog.global.IntegrationFixture.START_DATE; import static hanglog.global.exception.ExceptionCode.NOT_FOUND_CATEGORY_ID; +import static hanglog.integration.IntegrationFixture.EDINBURGH; +import static hanglog.integration.IntegrationFixture.END_DATE; +import static hanglog.integration.IntegrationFixture.LONDON; +import static hanglog.integration.IntegrationFixture.START_DATE; import static io.restassured.http.ContentType.JSON; -import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.NO_CONTENT; @@ -17,7 +17,6 @@ import hanglog.category.dto.CategoryResponse; import hanglog.currency.domain.type.CurrencyType; import hanglog.expense.dto.response.ItemExpenseResponse; -import hanglog.global.IntegrationTest; import hanglog.global.exception.BadRequestException; import hanglog.trip.dto.request.ExpenseRequest; import hanglog.trip.dto.request.ItemRequest; @@ -177,7 +176,8 @@ void updateItem_NonSpotToSpot() { null ); - final ExtractableResponse response = requestUpdateItem(memberTokens, tripId, itemId, itemUpdateRequest); + final ExtractableResponse response = requestUpdateItem(memberTokens, tripId, itemId, + itemUpdateRequest); final List itemResponses = requestGetItems(memberTokens, tripId, dayLogId); final ItemResponse expectedItemResponse = createMockIdResponseBy(1, itemUpdateRequest); @@ -221,7 +221,8 @@ void updateItem_changePlace() { getExpenseRequest() ); - final ExtractableResponse response = requestUpdateItem(memberTokens, tripId, itemId, itemUpdateRequest); + final ExtractableResponse response = requestUpdateItem(memberTokens, tripId, itemId, + itemUpdateRequest); final List itemResponses = requestGetItems(memberTokens, tripId, dayLogId); final ItemResponse expectedItemResponse = createMockIdResponseBy(1, itemUpdateRequest); @@ -258,7 +259,8 @@ void updateItem_SpotToNonSpot() { getExpenseRequest() ); - final ExtractableResponse response = requestUpdateItem(memberTokens, tripId, itemId, itemUpdateRequest); + final ExtractableResponse response = requestUpdateItem(memberTokens, tripId, itemId, + itemUpdateRequest); final List itemResponses = requestGetItems(memberTokens, tripId, dayLogId); final ItemResponse expectedItemResponse = createMockIdResponseBy(1, itemUpdateRequest); @@ -279,7 +281,8 @@ void updateItem_SpotToNonSpot() { @Test void deleteItem() { // given - final ExtractableResponse createResponse = requestCreateItem(memberTokens, tripId, getNonSpotItemRequest()); + final ExtractableResponse createResponse = requestCreateItem(memberTokens, tripId, + getNonSpotItemRequest()); final Long itemId = Long.parseLong(parseUri(createResponse.header("Location"))); // when @@ -298,7 +301,7 @@ protected static ExtractableResponse requestCreateItem( final MemberTokens memberTokens, final Long tripId, final ItemRequest itemRequest - ) { + ) { return RestAssured .given().log().all() .header(AUTHORIZATION, diff --git a/backend/src/test/java/hanglog/share/integration/SharedTripIntegrationTest.java b/backend/src/test/java/hanglog/integration/controller/SharedTripIntegrationTest.java similarity index 55% rename from backend/src/test/java/hanglog/share/integration/SharedTripIntegrationTest.java rename to backend/src/test/java/hanglog/integration/controller/SharedTripIntegrationTest.java index b028ae8cc..0d7f643f7 100644 --- a/backend/src/test/java/hanglog/share/integration/SharedTripIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/controller/SharedTripIntegrationTest.java @@ -1,16 +1,17 @@ -package hanglog.share.integration; +package hanglog.integration.controller; -import static hanglog.global.IntegrationFixture.END_DATE; -import static hanglog.global.IntegrationFixture.START_DATE; import static hanglog.global.exception.ExceptionCode.INVALID_SHARE_CODE; +import static hanglog.global.exception.ExceptionCode.NOT_FOUND_SHARED_CODE; +import static hanglog.integration.IntegrationFixture.END_DATE; +import static hanglog.integration.IntegrationFixture.START_DATE; import static io.restassured.http.ContentType.JSON; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; -import hanglog.global.IntegrationTest; +import hanglog.auth.domain.MemberTokens; import hanglog.share.dto.request.SharedTripStatusRequest; import hanglog.trip.dto.request.TripCreateRequest; -import hanglog.trip.integration.TripIntegrationTest; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -77,6 +78,54 @@ void getSharedTrip() { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); } + @DisplayName("처음 생성된 여행은 비공유 상태이다") + @Test + void updateSharedStatus_InitialTripStatus() { + // when + final ExtractableResponse response = requestGetTrip(memberTokens, tripId); + + // then + assertThat(response.body().jsonPath().getString("sharedTrip")).isNull(); + } + + @DisplayName("비공유 여행에서는 공유 코드를 볼 수 없다") + @Test + void updateSharedStatus_UnSharedTrip() { + // given + requestUpdateSharedTripStatus(true); + requestUpdateSharedTripStatus(false); + + // when + final ExtractableResponse response = requestGetTrip(memberTokens, tripId); + + // then + assertThat(response.body().jsonPath().getString("sharedTrip")).isNull(); + } + + @DisplayName("삭제된 여행은 공유 할 수 없다") + @Test + void getSharedTrip_deleteTripFail() { + // given + final String sharedCode = requestUpdateSharedTripStatus(true).body().jsonPath().get("sharedCode"); + requestDeleteTrip(memberTokens, tripId); + + // when + final ExtractableResponse response = RestAssured.given() + .when().get("/shared-trips/{sharedCode}", sharedCode) + .then().log().all() + .extract(); + final Integer errorCode = Integer.parseInt(response.body().jsonPath().get("code").toString()); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + assertSoftly( + softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + softly.assertThat(errorCode).isEqualTo(NOT_FOUND_SHARED_CODE.getCode()); + } + ); + } + @DisplayName("비공유된 여행은 조회할 수 없다") @Test void getSharedTrip_UnsharedFail() { @@ -90,6 +139,7 @@ void getSharedTrip_UnsharedFail() { .then().log().all() .extract(); final Integer errorCode = Integer.parseInt(response.body().jsonPath().get("code").toString()); + // then assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); assertSoftly( @@ -99,4 +149,24 @@ void getSharedTrip_UnsharedFail() { } ); } + + private static ExtractableResponse requestGetTrip(final MemberTokens memberTokens, final Long tripId) { + return RestAssured + .given().log().all() + .header(AUTHORIZATION, "Bearer " + memberTokens.getAccessToken()) + .cookies("refresh-token", memberTokens.getRefreshToken()) + .when().get("/trips/{tripId}", tripId) + .then().log().all() + .extract(); + } + + private void requestDeleteTrip(final MemberTokens memberTokens, final Long tripId) { + RestAssured + .given().log().all() + .header(AUTHORIZATION, "Bearer " + memberTokens.getAccessToken()) + .cookies("refresh-token", memberTokens.getRefreshToken()) + .when().delete("/trips/{tripId}", tripId) + .then().log().all() + .extract(); + } } diff --git a/backend/src/test/java/hanglog/trip/integration/TripIntegrationTest.java b/backend/src/test/java/hanglog/integration/controller/TripIntegrationTest.java similarity index 96% rename from backend/src/test/java/hanglog/trip/integration/TripIntegrationTest.java rename to backend/src/test/java/hanglog/integration/controller/TripIntegrationTest.java index 6520e5676..0d9a05151 100644 --- a/backend/src/test/java/hanglog/trip/integration/TripIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/controller/TripIntegrationTest.java @@ -1,15 +1,14 @@ -package hanglog.trip.integration; +package hanglog.integration.controller; -import static hanglog.global.IntegrationFixture.EDINBURGH; -import static hanglog.global.IntegrationFixture.LAHGON_TRIP; -import static hanglog.global.IntegrationFixture.LONDON; -import static hanglog.global.IntegrationFixture.TRIP_CREATE_REQUEST; +import static hanglog.integration.IntegrationFixture.EDINBURGH; +import static hanglog.integration.IntegrationFixture.LAHGON_TRIP; +import static hanglog.integration.IntegrationFixture.LONDON; +import static hanglog.integration.IntegrationFixture.TRIP_CREATE_REQUEST; import static io.restassured.http.ContentType.JSON; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import hanglog.auth.domain.MemberTokens; -import hanglog.global.IntegrationTest; import hanglog.trip.dto.request.TripCreateRequest; import hanglog.trip.dto.request.TripUpdateRequest; import hanglog.trip.dto.response.TripDetailResponse; diff --git a/backend/src/test/java/hanglog/auth/service/AuthServiceIntegrationTest.java b/backend/src/test/java/hanglog/integration/service/AuthServiceIntegrationTest.java similarity index 95% rename from backend/src/test/java/hanglog/auth/service/AuthServiceIntegrationTest.java rename to backend/src/test/java/hanglog/integration/service/AuthServiceIntegrationTest.java index 6f787b017..7553fd9e9 100644 --- a/backend/src/test/java/hanglog/auth/service/AuthServiceIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/service/AuthServiceIntegrationTest.java @@ -1,4 +1,4 @@ -package hanglog.auth.service; +package hanglog.integration.service; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -7,7 +7,7 @@ import hanglog.auth.domain.JwtProvider; import hanglog.auth.domain.oauthprovider.OauthProviders; import hanglog.auth.domain.repository.RefreshTokenRepository; -import hanglog.global.ServiceIntegrationTest; +import hanglog.auth.service.AuthService; import hanglog.trip.domain.repository.TripRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/backend/src/test/java/hanglog/global/ServiceIntegrationTest.java b/backend/src/test/java/hanglog/integration/service/ServiceIntegrationTest.java similarity index 95% rename from backend/src/test/java/hanglog/global/ServiceIntegrationTest.java rename to backend/src/test/java/hanglog/integration/service/ServiceIntegrationTest.java index 0803dfc66..483c55be2 100644 --- a/backend/src/test/java/hanglog/global/ServiceIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/service/ServiceIntegrationTest.java @@ -1,4 +1,4 @@ -package hanglog.global; +package hanglog.integration.service; import hanglog.member.domain.Member; import hanglog.member.domain.repository.MemberRepository; diff --git a/backend/src/test/java/hanglog/trip/service/integration/TripServiceIntegrationTest.java b/backend/src/test/java/hanglog/integration/service/TripServiceIntegrationTest.java similarity index 94% rename from backend/src/test/java/hanglog/trip/service/integration/TripServiceIntegrationTest.java rename to backend/src/test/java/hanglog/integration/service/TripServiceIntegrationTest.java index 1d2a9b258..0010e1e65 100644 --- a/backend/src/test/java/hanglog/trip/service/integration/TripServiceIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/service/TripServiceIntegrationTest.java @@ -1,23 +1,22 @@ -package hanglog.trip.service.integration; - -import static hanglog.global.IntegrationFixture.EDINBURGH; -import static hanglog.global.IntegrationFixture.END_DATE; -import static hanglog.global.IntegrationFixture.LAHGON_TRIP; -import static hanglog.global.IntegrationFixture.LONDON; -import static hanglog.global.IntegrationFixture.PARIS; -import static hanglog.global.IntegrationFixture.START_DATE; -import static hanglog.global.IntegrationFixture.TOKYO; -import static hanglog.global.IntegrationFixture.TRIP_CREATE_REQUEST; +package hanglog.integration.service; + import static hanglog.global.exception.ExceptionCode.NOT_FOUND_CITY_ID; import static hanglog.global.exception.ExceptionCode.NOT_FOUND_TRIP_ID; +import static hanglog.integration.IntegrationFixture.EDINBURGH; +import static hanglog.integration.IntegrationFixture.END_DATE; +import static hanglog.integration.IntegrationFixture.LAHGON_TRIP; +import static hanglog.integration.IntegrationFixture.LONDON; +import static hanglog.integration.IntegrationFixture.PARIS; +import static hanglog.integration.IntegrationFixture.START_DATE; +import static hanglog.integration.IntegrationFixture.TOKYO; +import static hanglog.integration.IntegrationFixture.TRIP_CREATE_REQUEST; 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 static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import hanglog.global.ServiceIntegrationTest; +import hanglog.city.domain.repository.CityRepository; import hanglog.global.exception.BadRequestException; -import hanglog.trip.domain.repository.CityRepository; import hanglog.trip.domain.repository.TripCityRepository; import hanglog.trip.domain.repository.TripRepository; import hanglog.trip.dto.request.TripCreateRequest; @@ -35,7 +34,7 @@ import org.springframework.context.annotation.Import; @Import(TripService.class) -public class TripServiceIntegrationTest extends ServiceIntegrationTest { +class TripServiceIntegrationTest extends ServiceIntegrationTest { @Autowired private TripRepository tripRepository; diff --git a/backend/src/test/java/hanglog/member/controller/MemberControllerTest.java b/backend/src/test/java/hanglog/member/controller/MemberControllerTest.java index 94f886103..70517233c 100644 --- a/backend/src/test/java/hanglog/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/hanglog/member/controller/MemberControllerTest.java @@ -1,6 +1,6 @@ package hanglog.member.controller; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; @@ -55,6 +55,7 @@ class MemberControllerTest extends ControllerTest { @BeforeEach void setUp() { + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); } diff --git a/backend/src/test/java/hanglog/share/fixture/ShareFixture.java b/backend/src/test/java/hanglog/share/fixture/ShareFixture.java index cd5426123..01b951aa1 100644 --- a/backend/src/test/java/hanglog/share/fixture/ShareFixture.java +++ b/backend/src/test/java/hanglog/share/fixture/ShareFixture.java @@ -1,13 +1,14 @@ package hanglog.share.fixture; -import static hanglog.global.IntegrationFixture.MEMBER; +import static hanglog.integration.IntegrationFixture.MEMBER; import static hanglog.share.domain.type.SharedStatusType.SHARED; import static hanglog.share.domain.type.SharedStatusType.UNSHARED; +import hanglog.city.domain.City; import hanglog.share.domain.SharedTrip; -import hanglog.trip.domain.City; import hanglog.trip.domain.DayLog; import hanglog.trip.domain.Trip; +import hanglog.trip.domain.type.PublishedStatusType; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; @@ -25,7 +26,8 @@ public class ShareFixture { LocalDate.of(2023, 7, 2), "", new SharedTrip(null, null, "sharedCode", SHARED), - new ArrayList<>() + new ArrayList<>(), + PublishedStatusType.UNPUBLISHED ); public static final DayLog LONDON_DAYLOG_1 = new DayLog( diff --git a/backend/src/test/java/hanglog/share/presentation/SharedTripControllerTest.java b/backend/src/test/java/hanglog/share/presentation/SharedTripControllerTest.java index f99559a16..4f1834090 100644 --- a/backend/src/test/java/hanglog/share/presentation/SharedTripControllerTest.java +++ b/backend/src/test/java/hanglog/share/presentation/SharedTripControllerTest.java @@ -1,10 +1,17 @@ package hanglog.share.presentation; +import static hanglog.category.fixture.CategoryFixture.EXPENSE_CATEGORIES; +import static hanglog.expense.fixture.AmountFixture.AMOUNT_20000; +import static hanglog.expense.fixture.CurrencyFixture.DEFAULT_CURRENCY; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static hanglog.share.fixture.ShareFixture.BEIJING; import static hanglog.share.fixture.ShareFixture.CALIFORNIA; import static hanglog.share.fixture.ShareFixture.TOKYO; import static hanglog.share.fixture.ShareFixture.TRIP; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; +import static hanglog.trip.fixture.CityFixture.LONDON; +import static hanglog.trip.fixture.DayLogFixture.EXPENSE_LONDON_DAYLOG; +import static hanglog.trip.fixture.TripFixture.LONDON_TO_JAPAN; +import static hanglog.trip.fixture.TripFixture.LONDON_TRIP; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -24,11 +31,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import hanglog.auth.domain.MemberTokens; +import hanglog.expense.domain.CategoryExpense; +import hanglog.expense.domain.DayLogExpense; +import hanglog.expense.dto.response.TripExpenseResponse; +import hanglog.expense.service.ExpenseService; import hanglog.global.ControllerTest; import hanglog.share.dto.request.SharedTripStatusRequest; import hanglog.share.dto.response.SharedTripCodeResponse; import hanglog.share.service.SharedTripService; +import hanglog.trip.domain.TripCity; import hanglog.trip.dto.response.TripDetailResponse; +import hanglog.trip.fixture.CityFixture; import hanglog.trip.service.TripService; import jakarta.servlet.http.Cookie; import java.util.List; @@ -58,6 +71,9 @@ class SharedTripControllerTest extends ControllerTest { @MockBean private TripService tripService; + @MockBean + private ExpenseService expenseService; + @DisplayName("ShareCode로 단일 여행을 조회한다.") @Test void getSharedTrip() throws Exception { @@ -162,6 +178,7 @@ void updateSharedStatus() throws Exception { final SharedTripCodeResponse sharedCodeResponse = new SharedTripCodeResponse("sharedCode"); when(sharedTripService.updateSharedTripStatus(anyLong(), any(SharedTripStatusRequest.class))) .thenReturn(sharedCodeResponse); + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); doNothing().when(tripService).validateTripByMember(anyLong(), anyLong()); @@ -198,6 +215,7 @@ void getSharedTrip_NullSharedStatus() throws Exception { final SharedTripCodeResponse sharedCodeResponse = new SharedTripCodeResponse("xxxxxx"); when(sharedTripService.updateSharedTripStatus(anyLong(), any(SharedTripStatusRequest.class))) .thenReturn(sharedCodeResponse); + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); doNothing().when(tripService).validateTripByMember(anyLong(), anyLong()); @@ -211,4 +229,178 @@ void getSharedTrip_NullSharedStatus() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value("공유 상태를 선택해주세요.")); } + + @DisplayName("ShareCode로 여행에 대한 가계부를 조회한다.") + @Test + void getSharedExpenses() throws Exception { + // given + when(sharedTripService.getTripId(anyString())) + .thenReturn(1L); + + final TripExpenseResponse tripExpenseResponse = TripExpenseResponse.of( + LONDON_TO_JAPAN, + AMOUNT_20000, + List.of(new TripCity(LONDON_TRIP, LONDON), new TripCity(LONDON_TRIP, CityFixture.TOKYO)), + List.of(new CategoryExpense(EXPENSE_CATEGORIES.get(1), AMOUNT_20000, AMOUNT_20000)), + DEFAULT_CURRENCY, + List.of(new DayLogExpense(EXPENSE_LONDON_DAYLOG, AMOUNT_20000)) + ); + + // when + when(expenseService.getAllExpenses(1L)).thenReturn(tripExpenseResponse); + + // then + mockMvc.perform(get("/shared-trips/{sharedCode}/expense", "xxxxxx").contentType(APPLICATION_JSON)) + .andDo( + restDocs.document( + pathParameters( + parameterWithName("sharedCode") + .description("공유 코드") + ), + responseFields( + fieldWithPath("id") + .type(JsonFieldType.NUMBER) + .description("여행 ID") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("title") + .type(JsonFieldType.STRING) + .description("여행 제목") + .attributes(field("constraint", "50자 이하의 문자열")), + fieldWithPath("startDate") + .type(JsonFieldType.STRING) + .description("여행 시작 날짜") + .attributes(field("constraint", "yyyy-MM-dd")), + fieldWithPath("endDate") + .type(JsonFieldType.STRING) + .description("여행 종료 날짜") + .attributes(field("constraint", "yyyy-MM-dd")), + fieldWithPath("cities") + .type(JsonFieldType.ARRAY) + .description("도시 목록") + .attributes(field("constraint", "1개 이상의 city")), + fieldWithPath("cities[].id") + .type(JsonFieldType.NUMBER) + .description("도시 ID") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("cities[].name") + .type(JsonFieldType.STRING) + .description("도시 명") + .attributes(field("constraint", "20자 이하의 문자열")), + fieldWithPath("totalAmount") + .type(JsonFieldType.NUMBER) + .description("총 경비") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("categories") + .type(JsonFieldType.ARRAY) + .description("카테고리별 경비 목록") + .attributes(field("constraint", "카테고리 배열")), + fieldWithPath("categories[].category") + .type(JsonFieldType.OBJECT) + .description("카테고리") + .attributes(field("constraint", "카테고리")), + fieldWithPath("categories[].category.id") + .type(JsonFieldType.NUMBER) + .description("카테고리 ID") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("categories[].category.name") + .type(JsonFieldType.STRING) + .description("카테고리 명") + .attributes(field("constraint", "2자의 문자열")), + fieldWithPath("categories[].amount") + .type(JsonFieldType.NUMBER) + .description("카테고리 경비") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("categories[].percentage") + .type(JsonFieldType.NUMBER) + .description("카테고리 백분율") + .attributes(field("constraint", "100이하의 백분율")), + fieldWithPath("exchangeRate") + .type(JsonFieldType.OBJECT) + .description("적용된 환율") + .attributes(field("constraint", "적용된 환율")), + fieldWithPath("exchangeRate.date") + .type(JsonFieldType.STRING) + .description("환율 날짜") + .attributes(field("constraint", "yyyy-MM-dd")), + fieldWithPath("exchangeRate.currencyRates") + .type(JsonFieldType.ARRAY) + .description("통화별 환율") + .attributes(field("constraint", "currency")), + fieldWithPath("exchangeRate.currencyRates[].currency") + .type(JsonFieldType.STRING) + .description("통화") + .attributes(field("constraint", "3자의 문자열")), + fieldWithPath("exchangeRate.currencyRates[].rate") + .type(JsonFieldType.NUMBER) + .description("환율") + .attributes(field("constraint", "양의 유리수")), + fieldWithPath("dayLogs") + .type(JsonFieldType.ARRAY) + .description("날짜별 여행 기록 배열") + .attributes(field("constraint", "2개 이상의 데이 로그")), + fieldWithPath("dayLogs[].id") + .type(JsonFieldType.NUMBER) + .description("날짜별 기록 ID") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("dayLogs[].ordinal") + .type(JsonFieldType.NUMBER) + .description("여행에서의 날짜 순서") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("dayLogs[].date") + .type(JsonFieldType.STRING) + .description("실제 날짜") + .attributes(field("constraint", "yyyy-MM-dd")), + fieldWithPath("dayLogs[].totalAmount") + .type(JsonFieldType.NUMBER) + .description("날짜별 총 경비") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("dayLogs[].items") + .type(JsonFieldType.ARRAY) + .description("아이템 목록") + .attributes(field("constraint", "배열")), + fieldWithPath("dayLogs[].items[].id") + .type(JsonFieldType.NUMBER) + .description("아이템 ID") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("dayLogs[].items[].title") + .type(JsonFieldType.STRING) + .description("아이템 제목") + .attributes(field("constraint", "50자 이하의 문자열")), + fieldWithPath("dayLogs[].items[].ordinal") + .type(JsonFieldType.NUMBER) + .description("아이템의 배치 순서") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("dayLogs[].items[].expense") + .type(JsonFieldType.OBJECT) + .description("아이템 경비") + .attributes(field("constraint", "아이템 경비")), + fieldWithPath("dayLogs[].items[].expense.id") + .type(JsonFieldType.NUMBER) + .description("경비 ID") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("dayLogs[].items[].expense.currency") + .type(JsonFieldType.STRING) + .description("경비 통화") + .attributes(field("constraint", "3자의 문자열")), + fieldWithPath("dayLogs[].items[].expense.amount") + .type(JsonFieldType.NUMBER) + .description("경비") + .attributes(field("constraint", "양의 유리수")), + fieldWithPath("dayLogs[].items[].expense.category") + .type(JsonFieldType.OBJECT) + .description("경비 카테고리") + .attributes(field("constraint", "id와 이름")), + fieldWithPath("dayLogs[].items[].expense.category.id") + .type(JsonFieldType.NUMBER) + .description("카테고리 ID") + .attributes(field("constraint", "양의 정수")), + fieldWithPath("dayLogs[].items[].expense.category.name") + .type(JsonFieldType.STRING) + .description("카테고리 명") + .attributes(field("constraint", "2자 문자열")) + ) + ) + ) + .andExpect(status().isOk()); + } } diff --git a/backend/src/test/java/hanglog/trip/fixture/CityFixture.java b/backend/src/test/java/hanglog/trip/fixture/CityFixture.java index 9518e268d..7f58ccb19 100644 --- a/backend/src/test/java/hanglog/trip/fixture/CityFixture.java +++ b/backend/src/test/java/hanglog/trip/fixture/CityFixture.java @@ -1,6 +1,6 @@ package hanglog.trip.fixture; -import hanglog.trip.domain.City; +import hanglog.city.domain.City; import java.math.BigDecimal; public class CityFixture { diff --git a/backend/src/test/java/hanglog/trip/fixture/DayLogFixture.java b/backend/src/test/java/hanglog/trip/fixture/DayLogFixture.java index 44ee07270..5029afdec 100644 --- a/backend/src/test/java/hanglog/trip/fixture/DayLogFixture.java +++ b/backend/src/test/java/hanglog/trip/fixture/DayLogFixture.java @@ -1,12 +1,13 @@ package hanglog.trip.fixture; -import static hanglog.global.IntegrationFixture.MEMBER; +import static hanglog.integration.IntegrationFixture.MEMBER; import static hanglog.trip.fixture.ItemFixture.AIRPLANE_ITEM; import static hanglog.trip.fixture.ItemFixture.JAPAN_HOTEL; import static hanglog.trip.fixture.ItemFixture.LONDON_EYE_ITEM; import hanglog.trip.domain.DayLog; import hanglog.trip.domain.Trip; +import hanglog.trip.domain.type.PublishedStatusType; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -22,7 +23,8 @@ public final class DayLogFixture { LocalDate.of(2023, 7, 2), "", null, - new ArrayList<>() + new ArrayList<>(), + PublishedStatusType.UNPUBLISHED ); public static final DayLog LONDON_DAYLOG_1 = new DayLog( diff --git a/backend/src/test/java/hanglog/trip/fixture/TripFixture.java b/backend/src/test/java/hanglog/trip/fixture/TripFixture.java index 07c2df6a5..a13521afa 100644 --- a/backend/src/test/java/hanglog/trip/fixture/TripFixture.java +++ b/backend/src/test/java/hanglog/trip/fixture/TripFixture.java @@ -1,6 +1,6 @@ package hanglog.trip.fixture; -import static hanglog.global.IntegrationFixture.MEMBER; +import static hanglog.integration.IntegrationFixture.MEMBER; import static hanglog.trip.fixture.DayLogFixture.EXPENSE_JAPAN_DAYLOG; import static hanglog.trip.fixture.DayLogFixture.EXPENSE_LONDON_DAYLOG; import static hanglog.trip.fixture.DayLogFixture.LONDON_DAYLOG_1; @@ -8,6 +8,7 @@ import static hanglog.trip.fixture.DayLogFixture.LONDON_DAYLOG_EXTRA; import hanglog.trip.domain.Trip; +import hanglog.trip.domain.type.PublishedStatusType; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -23,7 +24,8 @@ public final class TripFixture { LocalDate.of(2023, 7, 2), "", null, - new ArrayList<>(List.of(LONDON_DAYLOG_1, LONDON_DAYLOG_2, LONDON_DAYLOG_EXTRA)) + new ArrayList<>(List.of(LONDON_DAYLOG_1, LONDON_DAYLOG_2, LONDON_DAYLOG_EXTRA)), + PublishedStatusType.UNPUBLISHED ); public static final Trip LONDON_TO_JAPAN = new Trip( @@ -35,6 +37,7 @@ public final class TripFixture { LocalDate.of(2023, 7, 2), "", null, - List.of(EXPENSE_LONDON_DAYLOG, EXPENSE_JAPAN_DAYLOG) + List.of(EXPENSE_LONDON_DAYLOG, EXPENSE_JAPAN_DAYLOG), + PublishedStatusType.UNPUBLISHED ); } diff --git a/backend/src/test/java/hanglog/trip/presentation/CityControllerTest.java b/backend/src/test/java/hanglog/trip/presentation/CityControllerTest.java index 13bcc0de9..6509e3969 100644 --- a/backend/src/test/java/hanglog/trip/presentation/CityControllerTest.java +++ b/backend/src/test/java/hanglog/trip/presentation/CityControllerTest.java @@ -10,9 +10,10 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import hanglog.city.presentation.CityController; import hanglog.city.dto.response.CityResponse; import hanglog.global.ControllerTest; -import hanglog.trip.service.CityService; +import hanglog.city.service.CityService; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/backend/src/test/java/hanglog/trip/presentation/DayLogControllerTest.java b/backend/src/test/java/hanglog/trip/presentation/DayLogControllerTest.java index 0992784bc..2b707d9bf 100644 --- a/backend/src/test/java/hanglog/trip/presentation/DayLogControllerTest.java +++ b/backend/src/test/java/hanglog/trip/presentation/DayLogControllerTest.java @@ -1,6 +1,6 @@ package hanglog.trip.presentation; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; @@ -58,6 +58,7 @@ class DayLogControllerTest extends ControllerTest { @BeforeEach void setUp() { + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); doNothing().when(tripService).validateTripByMember(anyLong(), anyLong()); diff --git a/backend/src/test/java/hanglog/trip/presentation/ItemControllerTest.java b/backend/src/test/java/hanglog/trip/presentation/ItemControllerTest.java index 7c3b97f22..46f0497d7 100644 --- a/backend/src/test/java/hanglog/trip/presentation/ItemControllerTest.java +++ b/backend/src/test/java/hanglog/trip/presentation/ItemControllerTest.java @@ -1,6 +1,6 @@ package hanglog.trip.presentation; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; @@ -44,7 +44,7 @@ @WebMvcTest(ItemController.class) @MockBean(JpaMetamodelMappingContext.class) -public class ItemControllerTest extends ControllerTest { +class ItemControllerTest extends ControllerTest { private static final MemberTokens MEMBER_TOKENS = new MemberTokens("refreshToken", "accessToken"); private static final Cookie COOKIE = new Cookie("refresh-token", MEMBER_TOKENS.getRefreshToken()); @@ -61,6 +61,7 @@ public class ItemControllerTest extends ControllerTest { @BeforeEach void setUp() { + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); doNothing().when(tripService).validateTripByMember(anyLong(), anyLong()); diff --git a/backend/src/test/java/hanglog/trip/presentation/TripControllerTest.java b/backend/src/test/java/hanglog/trip/presentation/TripControllerTest.java index ebdca3164..2e7c0fc39 100644 --- a/backend/src/test/java/hanglog/trip/presentation/TripControllerTest.java +++ b/backend/src/test/java/hanglog/trip/presentation/TripControllerTest.java @@ -1,9 +1,9 @@ package hanglog.trip.presentation; +import static hanglog.global.restdocs.RestDocsConfiguration.field; import static hanglog.trip.fixture.CityFixture.LONDON; import static hanglog.trip.fixture.CityFixture.PARIS; import static hanglog.trip.fixture.TripFixture.LONDON_TRIP; -import static hanglog.trip.restdocs.RestDocsConfiguration.field; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -32,8 +32,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import hanglog.auth.domain.MemberTokens; +import hanglog.city.domain.City; import hanglog.global.ControllerTest; -import hanglog.trip.domain.City; import hanglog.trip.dto.request.TripCreateRequest; import hanglog.trip.dto.request.TripUpdateRequest; import hanglog.trip.dto.response.TripDetailResponse; @@ -72,6 +72,7 @@ class TripControllerTest extends ControllerTest { @BeforeEach void setUp() { + given(refreshTokenRepository.existsByToken(any())).willReturn(true); doNothing().when(jwtProvider).validateTokens(any()); given(jwtProvider.getSubject(any())).willReturn("1"); } diff --git a/backend/src/test/java/hanglog/trip/service/CityServiceTest.java b/backend/src/test/java/hanglog/trip/service/CityServiceTest.java index 93c8cdfde..895b6410b 100644 --- a/backend/src/test/java/hanglog/trip/service/CityServiceTest.java +++ b/backend/src/test/java/hanglog/trip/service/CityServiceTest.java @@ -5,8 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import hanglog.city.service.CityService; import hanglog.city.dto.response.CityResponse; -import hanglog.trip.domain.repository.CityRepository; +import hanglog.city.domain.repository.CityRepository; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/backend/src/test/java/hanglog/trip/service/TripServiceTest.java b/backend/src/test/java/hanglog/trip/service/TripServiceTest.java index ff00b788d..638a0e003 100644 --- a/backend/src/test/java/hanglog/trip/service/TripServiceTest.java +++ b/backend/src/test/java/hanglog/trip/service/TripServiceTest.java @@ -1,7 +1,7 @@ package hanglog.trip.service; -import static hanglog.global.IntegrationFixture.MEMBER; import static hanglog.global.exception.ExceptionCode.NOT_FOUND_TRIP_ID; +import static hanglog.integration.IntegrationFixture.MEMBER; import static hanglog.trip.fixture.CityFixture.LONDON; import static hanglog.trip.fixture.CityFixture.PARIS; import static hanglog.trip.fixture.TripFixture.LONDON_TRIP; @@ -13,14 +13,15 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import hanglog.city.domain.repository.CityRepository; import hanglog.global.exception.BadRequestException; import hanglog.member.domain.repository.MemberRepository; import hanglog.trip.domain.DayLog; import hanglog.trip.domain.Trip; import hanglog.trip.domain.TripCity; -import hanglog.trip.domain.repository.CityRepository; import hanglog.trip.domain.repository.TripCityRepository; import hanglog.trip.domain.repository.TripRepository; +import hanglog.trip.domain.type.PublishedStatusType; import hanglog.trip.dto.request.TripCreateRequest; import hanglog.trip.dto.request.TripUpdateRequest; import hanglog.trip.dto.response.TripDetailResponse; @@ -210,7 +211,8 @@ void setUp() { LocalDate.of(2023, 7, 3), "", null, - new ArrayList<>(List.of(dayLog1, dayLog2, dayLog3, extraDayLog)) + new ArrayList<>(List.of(dayLog1, dayLog2, dayLog3, extraDayLog)), + PublishedStatusType.UNPUBLISHED ); } @@ -233,7 +235,8 @@ void changeDate(final int startDay, final int endDay) { updateRequest.getEndDate(), updateRequest.getDescription(), null, - trip.getDayLogs() + trip.getDayLogs(), + PublishedStatusType.UNPUBLISHED ); given(tripRepository.findById(trip.getId())) @@ -283,7 +286,8 @@ void update_DecreasePeriod() { dayLog1, dayLog2, extraDayLog - ) + ), + PublishedStatusType.UNPUBLISHED ) ) ); @@ -323,7 +327,8 @@ void update_IncreasePeriod() { DayLog.generateEmpty(4, trip), DayLog.generateEmpty(5, trip), extraDayLog - ) + ), + PublishedStatusType.UNPUBLISHED ) ) ); diff --git a/backend/src/test/resources/data/truncate.sql b/backend/src/test/resources/data/truncate.sql index c7fe2de16..ac8d9ec66 100644 --- a/backend/src/test/resources/data/truncate.sql +++ b/backend/src/test/resources/data/truncate.sql @@ -10,4 +10,5 @@ TRUNCATE TABLE currency RESTART IDENTITY; TRUNCATE TABLE city RESTART IDENTITY; TRUNCATE TABLE category RESTART IDENTITY; TRUNCATE TABLE member RESTART IDENTITY; +TRUNCATE TABLE refresh_token RESTART IDENTITY; SET referential_integrity TRUE; diff --git a/frontend/.prettierrc b/frontend/.prettierrc index aeb611e27..9abef758a 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -10,6 +10,7 @@ "^recoil(.*)", "^axios(.*)", "^msw(.*)", + "^browser-image-compression(.*)", "^hang-log-design-system(.*)", "^@pages/(.*)$", "^@components/(.*)$", diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 705be9df7..5e9b01c90 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -11,8 +11,7 @@ import { initialize, mswDecorator } from 'msw-storybook-addon'; import { HangLogProvider, Spinner } from 'hang-log-design-system'; -import { axiosInstance } from '../src/api/axiosInstance'; -import { ACCESS_TOKEN_KEY } from '../src/constants/api'; +import { ACCESS_TOKEN_KEY, NETWORK } from '../src/constants/api'; import { handlers } from '../src/mocks/handlers'; initialize(); @@ -48,7 +47,15 @@ const preview: Preview = { export default preview; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: NETWORK.RETRY_COUNT, + suspense: true, + useErrorBoundary: true, + }, + }, +}); const localStorageResetDecorator = (Story) => { window.localStorage.clear(); diff --git a/frontend/config/webpack.common.js b/frontend/config/webpack.common.js new file mode 100644 index 000000000..3fe40ac64 --- /dev/null +++ b/frontend/config/webpack.common.js @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const Dotenv = require('dotenv-webpack'); +const webpack = require('webpack'); +const { convertToAbsolutePath } = require('./webpackUtil'); + +module.exports = { + entry: convertToAbsolutePath('src/index.tsx'), + module: { + rules: [ + { + test: /\.(js|jsx|ts|tsx)$/, + exclude: /node_modules/, + use: ['ts-loader'], + }, + { + test: /\.svg$/i, + issuer: /\.[jt]sx?$/, + use: ['@svgr/webpack'], + }, + { + test: /\.svg$/i, + issuer: /\.(style.js|style.ts)$/, + use: ['url-loader'], + }, + { + test: /\.(png|jpg)$/i, + issuer: /\.[jt]sx?$/, + use: ['url-loader'], + }, + ], + }, + + output: { + path: convertToAbsolutePath('dist'), + filename: '[name].[chunkhash].bundle.js', + publicPath: '/', + clean: true, + }, + + resolve: { + extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'], + alias: { + '@': convertToAbsolutePath('src'), + '@components': convertToAbsolutePath('src/components'), + '@type': convertToAbsolutePath('src/types'), + '@hooks': convertToAbsolutePath('src/hooks'), + '@pages': convertToAbsolutePath('src/pages'), + '@styles': convertToAbsolutePath('src/styles'), + '@constants': convertToAbsolutePath('src/constants'), + '@assets': convertToAbsolutePath('src/assets'), + '@api': convertToAbsolutePath('src/api'), + '@mocks': convertToAbsolutePath('src/mocks'), + '@stories': convertToAbsolutePath('src/stories'), + '@router': convertToAbsolutePath('src/router'), + '@store': convertToAbsolutePath('src/store'), + '@utils': convertToAbsolutePath('src/utils'), + }, + }, + + plugins: [ + new webpack.ProvidePlugin({ + React: 'react', + }), + new HtmlWebpackPlugin({ + template: convertToAbsolutePath('public/index.html'), + favicon: convertToAbsolutePath('public/favicon.ico'), + }), + new Dotenv(), + ], +}; diff --git a/frontend/config/webpack.dev.js b/frontend/config/webpack.dev.js new file mode 100644 index 000000000..327161a52 --- /dev/null +++ b/frontend/config/webpack.dev.js @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const CopyPlugin = require('copy-webpack-plugin'); +const path = require('path'); +const { merge } = require('webpack-merge'); +const common = require('./webpack.common'); +const webpack = require('webpack'); + +module.exports = merge(common, { + mode: 'development', + devtool: 'eval-cheap-module-source-map', + devServer: { + hot: true, + open: true, + historyApiFallback: true, + port: 3000, + static: path.resolve(__dirname, 'dist'), + }, + plugins: [ + new CopyPlugin({ + patterns: [{ from: 'public/mockServiceWorker.js', to: '' }], + }), + new webpack.HotModuleReplacementPlugin(), + ], +}); diff --git a/frontend/config/webpack.prod.js b/frontend/config/webpack.prod.js new file mode 100644 index 000000000..5559c9287 --- /dev/null +++ b/frontend/config/webpack.prod.js @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { merge } = require('webpack-merge'); +const common = require('./webpack.common'); +const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); + +module.exports = merge(common, { + mode: 'production', + devtool: 'nosources-source-map', + optimization: { + splitChunks: { + chunks: 'all', + }, + }, + plugins: [ + new BundleAnalyzerPlugin({ + analyzerMode: 'static', + openAnalyzer: false, + generateStatsFile: true, + statsFilename: 'bundle-report.json', + }), + ], +}); diff --git a/frontend/config/webpackUtil.js b/frontend/config/webpackUtil.js new file mode 100644 index 000000000..55841ddf0 --- /dev/null +++ b/frontend/config/webpackUtil.js @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const fs = require('fs'); +const path = require('path'); + +const cwdAbsolutePath = fs.realpathSync(process.cwd()); +const convertToAbsolutePath = (paths) => path.resolve(cwdAbsolutePath, paths); + +module.exports = { convertToAbsolutePath }; diff --git a/frontend/cypress/e2e/createTrip.cy.ts b/frontend/cypress/e2e/createTrip.cy.ts index f555bc2a7..d3b48188a 100644 --- a/frontend/cypress/e2e/createTrip.cy.ts +++ b/frontend/cypress/e2e/createTrip.cy.ts @@ -43,12 +43,14 @@ describe('여행 생성 페이지', () => { it('방문 기간을 클릭해 달력을 열어 방문기간을 입력할 수 있다.', () => { cy.get('#date').click(); - cy.get('span[aria-label="2023년 07월 1일"]').click(); - cy.get('span[aria-label="2023년 07월 12일"]').click(); + const currentMonth = String(new Date().getMonth() + 1).padStart(2, '0'); + + cy.get(`span[aria-label="2023년 ${currentMonth}월 1일"]`).click(); + cy.get(`span[aria-label="2023년 ${currentMonth}월 12일"]`).click(); cy.get('#date').click(); - cy.get('#date').should('have.value', '2023.07.01 - 2023.07.12'); + cy.get('#date').should('have.value', `2023.${currentMonth}.01 - 2023.${currentMonth}.12`); }); it('도시와 기간이 채워졌을 때만 기록하기 버튼을 누를 수 있다.', () => { @@ -61,8 +63,10 @@ describe('여행 생성 페이지', () => { cy.get('#date').click(); - cy.get('span[aria-label="2023년 07월 1일"]').click(); - cy.get('span[aria-label="2023년 07월 12일"]').click(); + const currentMonth = String(new Date().getMonth() + 1).padStart(2, '0'); + + cy.get(`span[aria-label="2023년 ${currentMonth}월 1일"]`).click(); + cy.get(`span[aria-label="2023년 ${currentMonth}월 12일"]`).click(); cy.get('#date').click(); @@ -80,8 +84,10 @@ describe('여행 생성 페이지', () => { cy.get('#date').click(); - cy.get('span[aria-label="2023년 07월 1일"]').click(); - cy.get('span[aria-label="2023년 07월 12일"]').click(); + const currentMonth = String(new Date().getMonth() + 1).padStart(2, '0'); + + cy.get(`span[aria-label="2023년 ${currentMonth}월 1일"]`).click(); + cy.get(`span[aria-label="2023년 ${currentMonth}월 12일"]`).click(); cy.get('#date').click(); diff --git a/frontend/cypress/e2e/login.cy.ts b/frontend/cypress/e2e/login.cy.ts index dd564cbcd..9164c4263 100644 --- a/frontend/cypress/e2e/login.cy.ts +++ b/frontend/cypress/e2e/login.cy.ts @@ -16,7 +16,6 @@ describe('로그인', () => { it('웹 사이트 처음 방문 시 서비스 소개 페이지를 볼 수 있다.', () => { cy.findByText('로그인'); - cy.findByText('회원가입'); cy.findByText('시작하기'); }); @@ -25,11 +24,6 @@ describe('로그인', () => { cy.location('pathname').should('eq', PATH.LOGIN); }); - it('회원가입 버튼을 클릭하면 회원가입 페이지로 이동한다.', () => { - cy.findByText('회원가입').click(); - cy.location('pathname').should('eq', PATH.SIGN_UP); - }); - it('로그인하면 메인 페이지 화면이 여행 목록 페이지로 변경된다.', () => { cy.findByText('로그인').click(); diff --git a/frontend/cypress/e2e/trip.cy.ts b/frontend/cypress/e2e/trip.cy.ts index 1f93c3ac1..c2c2b1e6f 100644 --- a/frontend/cypress/e2e/trip.cy.ts +++ b/frontend/cypress/e2e/trip.cy.ts @@ -39,10 +39,10 @@ describe('여행 수정 페이지', () => { }); }); - it('여행 수정 페이지에서 "여행 정보 수정" 버튼과 "저장" 버튼을 볼 수 있다.', () => { + it('여행 수정 페이지에서 "여행 정보 수정" 버튼과 "완료" 버튼을 볼 수 있다.', () => { cy.findByText('여행 정보 수정'); - cy.findByText('저장'); + cy.findByText('완료'); }); it('여행 수정 페이지에서 선택 된 날짜 길이 만큼 데이로그 탭이 만들어져 있다.', () => { @@ -62,7 +62,7 @@ describe('여행 수정 페이지', () => { cy.findByPlaceholderText('소제목').should('have.value', title); items.forEach((item: TripItemData) => { - cy.get('h6').contains(item.title); + cy.get('p').contains(item.title); }); }); }); @@ -74,7 +74,7 @@ describe('여행 수정 페이지', () => { const { items } = expectedData.dayLogs[1]; items.forEach((item: TripItemData) => { - cy.get('h6').contains(item.title); + cy.get('p').contains(item.title); }); }); }); @@ -289,7 +289,7 @@ describe('여행 아이템 추가', () => { cy.findByRole('dialog').should('not.exist'); - cy.get('h6').contains('샹젤리제 거리 -> 에펠탑 지하철').should('exist'); + cy.get('p').contains('샹젤리제 거리 -> 에펠탑 지하철').should('exist'); }); it('여행 아이템 추가 모달에서 필수 정보 외에도 입력하고 아이템을 추가한 후에 여행 아이템 목록에서 볼 수 있다.', () => { @@ -310,7 +310,7 @@ describe('여행 아이템 추가', () => { cy.findByRole('dialog').should('not.exist'); - cy.get('h6').contains('샹젤리제 거리 -> 에펠탑 지하철').should('exist'); + cy.get('p').contains('샹젤리제 거리 -> 에펠탑 지하철').should('exist'); cy.findByText('샹젤리제 거리 -> 에펠탑 지하철').parent().find('svg').should('have.length', 6); cy.findByText('샹젤리제 거리 -> 에펠탑 지하철') @@ -339,16 +339,14 @@ describe('여행 아이템 수정', () => { cy.wait(4000); }); - it('여행 아이템의 더 보기 버튼을 클릭해서 여행 아이템을 수정할 수 있다.', () => { - cy.get('button[aria-label="더 보기 메뉴"]').first().click({ force: true }); - cy.findByText('수정').click(); + it('여행 아이템의 수정 버튼을 클릭해서 여행 아이템을 수정할 수 있다.', () => { + cy.get('svg[aria-label="수정"]').first().click({ force: true }); cy.findByRole('dialog').should('be.visible'); }); it.skip('여행 아이템 수정 모달을 열면 여행 아이템 정보가 입력되어 있다.', () => { - cy.get('button[aria-label="더 보기 메뉴"]').first().click({ force: true }); - cy.findByText('수정').click(); + cy.get('svg[aria-label="수정"]').first().click({ force: true }); cy.fixture('trip.json').then((expectedData) => { const { items } = expectedData.dayLogs[0]; @@ -365,8 +363,7 @@ describe('여행 아이템 수정', () => { }); it.skip('여행 아이템 수정 모달에서 여행 아이템 정보를 수정하면 여행 아이템 목록에서 변경된 정보를 볼 수 있다.', () => { - cy.get('button[aria-label="더 보기 메뉴"]').first().click({ force: true }); - cy.findByText('수정').click(); + cy.get('svg[aria-label="수정"]').first().click({ force: true }); cy.findByRole('radio', { name: /기타/ }).click(); cy.get('#title').type(' 택시'); @@ -383,7 +380,7 @@ describe('여행 아이템 수정', () => { const { items } = expectedData.dayLogs[0]; const firstItem: TripItemData = items[0]; - cy.get('h6').contains(`${firstItem.title} 택시`).should('exist'); + cy.get('p').contains(`${firstItem.title} 택시`).should('exist'); cy.findByText(`${firstItem.title} 택시`) .parent() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d42848b2f..c7c1f9f14 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,8 +14,9 @@ "@googlemaps/react-wrapper": "^1.1.35", "@tanstack/react-query": "^4.29.19", "axios": "^1.4.0", + "browser-image-compression": "^2.0.2", "dotenv": "^16.3.1", - "hang-log-design-system": "^1.3.0", + "hang-log-design-system": "^1.3.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.1", @@ -67,8 +68,10 @@ "typescript": "^5.1.6", "url-loader": "^4.1.1", "webpack": "^5.88.1", + "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1" + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.9.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -3528,6 +3531,12 @@ "node": ">= 8" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, "node_modules/@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -8757,6 +8766,14 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browserify-zlib": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", @@ -10677,6 +10694,12 @@ "webpack": "^4 || ^5" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, "node_modules/duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -13073,6 +13096,21 @@ "gunzip-maybe": "bin.js" } }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/hamt_plus": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", @@ -13115,9 +13153,9 @@ } }, "node_modules/hang-log-design-system": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/hang-log-design-system/-/hang-log-design-system-1.3.0.tgz", - "integrity": "sha512-OiTWhbwdVyB+42tQviY9e7CF1Of6DoQ0Qi1VjWMASfiYKCju0dghqU3B3FTTZsdA4pEScFIToXonQVOGObn3Gg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/hang-log-design-system/-/hang-log-design-system-1.3.4.tgz", + "integrity": "sha512-iw65WkQGkQntOJuJnIp4t8yWCY6IusyBaDdZCpDIUYCrFv3FtMhC65b7mN5E71Q7gQOe63ZjjCjCUwblSdN5Ww==", "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", @@ -15285,6 +15323,24 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "node_modules/lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true + }, + "node_modules/lodash.invokemap": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz", + "integrity": "sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -15297,6 +15353,18 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true }, + "node_modules/lodash.pullall": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.pullall/-/lodash.pullall-4.2.0.tgz", + "integrity": "sha512-VhqxBKH0ZxPpLhiu68YD1KnHmbhQJQctcipvmFnqIBDYzcIHzf3Zpu0tpeOKtR4x76p9yohc506eGdOjTmyIBg==", + "dev": true + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -15754,6 +15822,15 @@ "node": ">=4" } }, + "node_modules/mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -16286,6 +16363,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -18404,6 +18490,20 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/sirv": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", + "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -19468,6 +19568,15 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -20188,6 +20297,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==" + }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -20340,6 +20454,88 @@ } } }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.9.1.tgz", + "integrity": "sha512-jnd6EoYrf9yMxCyYDPj8eutJvtjQNp8PHmni/e/ulydHBWhT5J3menXt3HEkScsu9YqMAcG4CfFjs3rj5pVU1w==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "is-plain-object": "^5.0.0", + "lodash.debounce": "^4.0.8", + "lodash.escape": "^4.0.1", + "lodash.flatten": "^4.4.0", + "lodash.invokemap": "^4.6.0", + "lodash.pullall": "^4.2.0", + "lodash.uniqby": "^4.7.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/acorn": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "dev": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/webpack-cli": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", @@ -23421,6 +23617,12 @@ } } }, + "@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, "@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -27142,6 +27344,14 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, + "browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "requires": { + "uzip": "0.20201231.0" + } + }, "browserify-zlib": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", @@ -28566,6 +28776,12 @@ "dotenv-defaults": "^2.0.2" } }, + "duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -30393,6 +30609,15 @@ "through2": "^2.0.3" } }, + "gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "requires": { + "duplexer": "^0.1.2" + } + }, "hamt_plus": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", @@ -30426,9 +30651,9 @@ } }, "hang-log-design-system": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/hang-log-design-system/-/hang-log-design-system-1.3.0.tgz", - "integrity": "sha512-OiTWhbwdVyB+42tQviY9e7CF1Of6DoQ0Qi1VjWMASfiYKCju0dghqU3B3FTTZsdA4pEScFIToXonQVOGObn3Gg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/hang-log-design-system/-/hang-log-design-system-1.3.4.tgz", + "integrity": "sha512-iw65WkQGkQntOJuJnIp4t8yWCY6IusyBaDdZCpDIUYCrFv3FtMhC65b7mN5E71Q7gQOe63ZjjCjCUwblSdN5Ww==", "requires": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", @@ -31993,6 +32218,24 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true + }, + "lodash.invokemap": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz", + "integrity": "sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w==", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -32005,6 +32248,18 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true }, + "lodash.pullall": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.pullall/-/lodash.pullall-4.2.0.tgz", + "integrity": "sha512-VhqxBKH0ZxPpLhiu68YD1KnHmbhQJQctcipvmFnqIBDYzcIHzf3Zpu0tpeOKtR4x76p9yohc506eGdOjTmyIBg==", + "dev": true + }, + "lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true + }, "log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -32341,6 +32596,12 @@ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", "dev": true }, + "mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -32730,6 +32991,12 @@ "is-wsl": "^2.2.0" } }, + "opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true + }, "optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -34317,6 +34584,17 @@ } } }, + "sirv": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", + "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "dev": true, + "requires": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -35155,6 +35433,12 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true }, + "totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true + }, "tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -35667,6 +35951,11 @@ "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", "dev": true }, + "uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==" + }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -35827,6 +36116,58 @@ } } }, + "webpack-bundle-analyzer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.9.1.tgz", + "integrity": "sha512-jnd6EoYrf9yMxCyYDPj8eutJvtjQNp8PHmni/e/ulydHBWhT5J3menXt3HEkScsu9YqMAcG4CfFjs3rj5pVU1w==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "is-plain-object": "^5.0.0", + "lodash.debounce": "^4.0.8", + "lodash.escape": "^4.0.1", + "lodash.flatten": "^4.4.0", + "lodash.invokemap": "^4.6.0", + "lodash.pullall": "^4.2.0", + "lodash.uniqby": "^4.7.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "dependencies": { + "acorn": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dev": true + }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "dev": true, + "requires": {} + } + } + }, "webpack-cli": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 179207559..5ddfe286a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,14 +22,14 @@ ], "main": "index.js", "scripts": { - "start": "cross-env NODE_ENV=development webpack --mode development", - "dev": "cross-env NODE_ENV=development webpack serve --mode development --open --hot", - "prod": "cross-env NODE_ENV=production webpack serve --mode production --open --hot", - "build": "cross-env NODE_ENV=production webpack --mode production", + "serve:dev": "cross-env NODE_ENV=development webpack serve --mode development --open --hot --config config/webpack.dev.js", + "serve:prod": "cross-env NODE_ENV=production webpack serve --mode production --open --hot --config config/webpack.prod.js", + "build:prod": "cross-env NODE_ENV=production webpack --mode production --config config/webpack.prod.js", + "build:sb": "storybook build", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build", "lint": "eslint src", - "cypress": "cypress open" + "cypress": "cypress open", + "analyze": "webpack-bundle-analyzer ./dist/bundle-report.json --default-sizes gzip" }, "keywords": [], "author": "", @@ -40,8 +40,9 @@ "@googlemaps/react-wrapper": "^1.1.35", "@tanstack/react-query": "^4.29.19", "axios": "^1.4.0", + "browser-image-compression": "^2.0.2", "dotenv": "^16.3.1", - "hang-log-design-system": "^1.3.0", + "hang-log-design-system": "^1.3.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.1", @@ -93,10 +94,13 @@ "typescript": "^5.1.6", "url-loader": "^4.1.1", "webpack": "^5.88.1", + "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1" + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.9.0" }, "msw": { "workerDirectory": "public" - } + }, + "sideEffects": false } diff --git a/frontend/src/api/expense/getSharedExpense.ts b/frontend/src/api/expense/getSharedExpense.ts new file mode 100644 index 000000000..4ae44fb72 --- /dev/null +++ b/frontend/src/api/expense/getSharedExpense.ts @@ -0,0 +1,14 @@ +import { axiosInstance } from '@api/axiosInstance'; + +import type { ExpenseData } from '@type/expense'; + +import { END_POINTS } from '@constants/api'; + +export const getSharedExpense = async (tripId: string) => { + const { data } = await axiosInstance.get(END_POINTS.SHARED_EXPENSE(tripId), { + useAuth: false, + withCredentials: false, + }); + + return data; +}; diff --git a/frontend/src/api/interceptors.ts b/frontend/src/api/interceptors.ts index 3b956085a..85394dca4 100644 --- a/frontend/src/api/interceptors.ts +++ b/frontend/src/api/interceptors.ts @@ -51,7 +51,7 @@ export const handleTokenError = async (error: AxiosError) => data.code === ERROR_CODE.EXPIRED_REFRESH_TOKEN || data.code === ERROR_CODE.INVALID_TOKEN_VALIDATE || data.code === ERROR_CODE.NULL_REFRESH_TOKEN || - data.code === ERROR_CODE.UNEXCEPTED_TOKEN_ERROR) + data.code === ERROR_CODE.UNEXPECTED_TOKEN_ERROR) ) { localStorage.removeItem(ACCESS_TOKEN_KEY); diff --git a/frontend/src/api/trip/getSharedTrip.ts b/frontend/src/api/trip/getSharedTrip.ts index fef9b3f29..a4532eefb 100644 --- a/frontend/src/api/trip/getSharedTrip.ts +++ b/frontend/src/api/trip/getSharedTrip.ts @@ -5,7 +5,7 @@ import type { TripData } from '@type/trip'; import { END_POINTS } from '@constants/api'; export const getSharedTrip = async (code: string) => { - const { data } = await axiosInstance.get(END_POINTS.SHARED_PAGE(code), { + const { data } = await axiosInstance.get(END_POINTS.SHARED_TRIP(code), { useAuth: false, withCredentials: false, }); diff --git a/frontend/src/assets/index.d.ts b/frontend/src/assets/index.d.ts index c096f73d8..f9a456f5c 100644 --- a/frontend/src/assets/index.d.ts +++ b/frontend/src/assets/index.d.ts @@ -6,3 +6,5 @@ declare module '*.svg' { } declare module '*.png'; + +declare module '*.jpg'; diff --git a/frontend/src/assets/jpg/sample-trip-image.jpg b/frontend/src/assets/jpg/sample-trip-image.jpg new file mode 100644 index 000000000..527e8da0e Binary files /dev/null and b/frontend/src/assets/jpg/sample-trip-image.jpg differ diff --git a/frontend/src/assets/jpg/sample-trip-image_mobile.jpg b/frontend/src/assets/jpg/sample-trip-image_mobile.jpg new file mode 100644 index 000000000..9ec58426b Binary files /dev/null and b/frontend/src/assets/jpg/sample-trip-image_mobile.jpg differ diff --git a/frontend/src/assets/png/sample-trip-image.png b/frontend/src/assets/png/sample-trip-image.png deleted file mode 100644 index a937bc6f2..000000000 Binary files a/frontend/src/assets/png/sample-trip-image.png and /dev/null differ diff --git a/frontend/src/assets/png/sample-trip-image_mobile.png b/frontend/src/assets/png/sample-trip-image_mobile.png deleted file mode 100644 index e80c70520..000000000 Binary files a/frontend/src/assets/png/sample-trip-image_mobile.png and /dev/null differ diff --git a/frontend/src/assets/svg/bin-icon.svg b/frontend/src/assets/svg/bin-icon.svg new file mode 100644 index 000000000..df454786f --- /dev/null +++ b/frontend/src/assets/svg/bin-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/edit-icon.svg b/frontend/src/assets/svg/edit-icon.svg new file mode 100644 index 000000000..79d87e988 --- /dev/null +++ b/frontend/src/assets/svg/edit-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/common/DayLogItem/DayLogItem.style.ts b/frontend/src/components/common/DayLogItem/DayLogItem.style.ts index 21e5058ec..a9c4eaa58 100644 --- a/frontend/src/components/common/DayLogItem/DayLogItem.style.ts +++ b/frontend/src/components/common/DayLogItem/DayLogItem.style.ts @@ -4,6 +4,7 @@ import { Theme } from 'hang-log-design-system'; export const containerStyling = css({ width: '100%', + minHeight: 'calc(100vh - 48px)', }); export const headerStyling = css({ diff --git a/frontend/src/components/common/DayLogItem/DayLogItem.tsx b/frontend/src/components/common/DayLogItem/DayLogItem.tsx index 759703fb2..0d82c3be5 100644 --- a/frontend/src/components/common/DayLogItem/DayLogItem.tsx +++ b/frontend/src/components/common/DayLogItem/DayLogItem.tsx @@ -1,4 +1,6 @@ -import { Box, Flex, Heading } from 'hang-log-design-system'; +import { useEffect } from 'react'; + +import { Box, Flex, Heading, Toggle, ToggleGroup, useSelect } from 'hang-log-design-system'; import { containerStyling, headerStyling } from '@components/common/DayLogItem/DayLogItem.style'; import TitleInput from '@components/common/DayLogItem/TitleInput/TitleInput'; @@ -6,6 +8,8 @@ import TripItemList from '@components/common/TripItemList/TripItemList'; import type { DayLogData } from '@type/dayLog'; +import { DAY_LOG_ITEM_FILTERS } from '@constants/trip'; + interface DayLogItemProps extends DayLogData { tripId: number; isEditable?: boolean; @@ -20,6 +24,19 @@ const DayLogItem = ({ openAddModal, ...information }: DayLogItemProps) => { + const { selected: selectedFilter, handleSelectClick: handleFilterSelectClick } = useSelect( + DAY_LOG_ITEM_FILTERS.ALL + ); + + const selectedTripItemList = + selectedFilter === DAY_LOG_ITEM_FILTERS.SPOT + ? information.items.filter((item) => item.itemType === true) + : information.items; + + useEffect(() => { + handleFilterSelectClick(DAY_LOG_ITEM_FILTERS.ALL); + }, [handleFilterSelectClick, information.items]); + return ( @@ -28,12 +45,26 @@ const DayLogItem = ({ ) : ( {information.title} )} + {!isEditable && ( + + {[DAY_LOG_ITEM_FILTERS.ALL, DAY_LOG_ITEM_FILTERS.SPOT].map((filter) => ( + + ))} + + )} - {information.items.length > 0 ? ( + {selectedTripItemList.length > 0 ? ( ) : ( diff --git a/frontend/src/components/common/DayLogItem/DayLogItemSkeleton.tsx b/frontend/src/components/common/DayLogItem/DayLogItemSkeleton.tsx index d7ff79752..5d6adbe9b 100644 --- a/frontend/src/components/common/DayLogItem/DayLogItemSkeleton.tsx +++ b/frontend/src/components/common/DayLogItem/DayLogItemSkeleton.tsx @@ -14,6 +14,7 @@ const DayLogItemSkeleton = () => { + diff --git a/frontend/src/components/common/DayLogList/DayLogList.style.ts b/frontend/src/components/common/DayLogList/DayLogList.style.ts index 0e842c4d7..2f9d6fe8d 100644 --- a/frontend/src/components/common/DayLogList/DayLogList.style.ts +++ b/frontend/src/components/common/DayLogList/DayLogList.style.ts @@ -11,7 +11,7 @@ export const containerStyling = css({ padding: `${Theme.spacer.spacing4} 50px`, '@media screen and (max-width: 600px)': { - padding: Theme.spacer.spacing4, + padding: `${Theme.spacer.spacing2} ${Theme.spacer.spacing4}`, }, '& > ul': { diff --git a/frontend/src/components/common/DayLogList/DayLogList.tsx b/frontend/src/components/common/DayLogList/DayLogList.tsx index 90bdbd7af..fce658536 100644 --- a/frontend/src/components/common/DayLogList/DayLogList.tsx +++ b/frontend/src/components/common/DayLogList/DayLogList.tsx @@ -3,7 +3,7 @@ import { Tab, Tabs } from 'hang-log-design-system'; import DayLogItem from '@components/common/DayLogItem/DayLogItem'; import { containerStyling } from '@components/common/DayLogList/DayLogList.style'; -import { useTripDates } from '@hooks/trip/useTripDates'; +import { useTrip } from '@hooks/trip/useTrip'; import { formatMonthDate } from '@utils/formatter'; @@ -26,7 +26,7 @@ const DayLogList = ({ onTabChange, openAddModal, }: DayLogListProps) => { - const { dates } = useTripDates(tripId); + const { dates } = useTrip(tripId); return (
diff --git a/frontend/src/components/common/GoogleMapWrapper/GoogleMapWrapper.tsx b/frontend/src/components/common/GoogleMapWrapper/GoogleMapWrapper.tsx index 0c39cbf82..361693777 100644 --- a/frontend/src/components/common/GoogleMapWrapper/GoogleMapWrapper.tsx +++ b/frontend/src/components/common/GoogleMapWrapper/GoogleMapWrapper.tsx @@ -2,14 +2,25 @@ import { Status, Wrapper } from '@googlemaps/react-wrapper'; import type { PropsWithChildren } from 'react'; -import { Spinner } from 'hang-log-design-system'; +import { Flex, Spinner } from 'hang-log-design-system'; type GoogleMapWrapperProps = PropsWithChildren; const render = (status: Status) => { if (status === Status.FAILURE) throw new Error('오류가 발생했습니다.'); - return ; + return ( + + + + ); }; const GoogleMapWrapper = ({ children }: GoogleMapWrapperProps) => { diff --git a/frontend/src/components/common/TripInformation/TripButtons/TripButtons.style.ts b/frontend/src/components/common/TripInformation/TripButtons/TripButtons.style.ts index efb59adab..71b0f8b9d 100644 --- a/frontend/src/components/common/TripInformation/TripButtons/TripButtons.style.ts +++ b/frontend/src/components/common/TripInformation/TripButtons/TripButtons.style.ts @@ -2,39 +2,44 @@ import { css } from '@emotion/react'; import { Theme } from 'hang-log-design-system'; -export const moreButtonStyling = css({ - border: 'none', - outline: 0, - - backgroundColor: 'transparent', +export const svgButtonStyling = css({ + width: '20px', + height: '20px', + marginLeft: '12px', cursor: 'pointer', +}); - '& svg': { - width: '20px', - height: '20px', +export const binIconStyling = css({ + '& path': { + stroke: Theme.color.white, + }, +}); - '& path': { - stroke: Theme.color.white, - strokeWidth: 1.5, - }, +export const editIconStyling = css({ + '& path': { + fill: Theme.color.white, }, }); -export const moreMenuStyling = css({ - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', +export const modalContentStyling = css({ + width: '350px', + + '& h6': { + marginBottom: Theme.spacer.spacing3, - marginLeft: Theme.spacer.spacing3, + color: Theme.color.red300, + }, }); -export const moreMenuListStyling = css({ - transform: 'translateY(64px)', +export const modalButtonContainerStyling = css({ + gap: Theme.spacer.spacing1, + alignItems: 'stretch', - '& > li': { - padding: `${Theme.spacer.spacing2} ${Theme.spacer.spacing3}`, + width: '100%', + marginTop: Theme.spacer.spacing5, - color: Theme.color.gray800, + '& > *': { + width: '100%', }, }); diff --git a/frontend/src/components/common/TripInformation/TripButtons/TripButtons.tsx b/frontend/src/components/common/TripInformation/TripButtons/TripButtons.tsx index 25bc09b3a..e5e41a674 100644 --- a/frontend/src/components/common/TripInformation/TripButtons/TripButtons.tsx +++ b/frontend/src/components/common/TripInformation/TripButtons/TripButtons.tsx @@ -1,11 +1,13 @@ import { useNavigate } from 'react-router-dom'; -import { Button, Menu, MenuItem, MenuList, useOverlay } from 'hang-log-design-system'; +import { Box, Button, Flex, Heading, Modal, Text, useOverlay } from 'hang-log-design-system'; import { - moreButtonStyling, - moreMenuListStyling, - moreMenuStyling, + binIconStyling, + editIconStyling, + modalButtonContainerStyling, + modalContentStyling, + svgButtonStyling, } from '@components/common/TripInformation/TripButtons/TripButtons.style'; import TripShareButton from '@components/common/TripInformation/TripShareButton/TripShareButton'; @@ -15,24 +17,36 @@ import type { TripData } from '@type/trip'; import { PATH } from '@constants/path'; -import MoreIcon from '@assets/svg/more-icon.svg'; +import BinIcon from '@assets/svg/bin-icon.svg'; +import EditIcon from '@assets/svg/edit-icon.svg'; interface TripButtonsProps { tripId: number; sharedCode: TripData['sharedCode']; + isShared: boolean; } -export const TripButtons = ({ tripId, sharedCode }: TripButtonsProps) => { +export const TripButtons = ({ tripId, sharedCode, isShared }: TripButtonsProps) => { const navigate = useNavigate(); const deleteTripMutation = useDeleteTripMutation(); - const { isOpen: isMenuOpen, open: openMenu, close: closeMenu } = useOverlay(); + + const { + isOpen: isDeleteModalOpen, + close: closeDeleteModal, + open: openDeleteModal, + } = useOverlay(); const goToEditPage = () => { navigate(PATH.EDIT_TRIP(tripId)); }; const goToExpensePage = () => { - navigate(PATH.EXPENSE(tripId)); + if (!isShared) { + navigate(PATH.EXPENSE(tripId)); + return; + } + + navigate(PATH.SHARE_EXPENSE(tripId)); }; const handleDeleteButtonClick = () => { @@ -49,18 +63,27 @@ export const TripButtons = ({ tripId, sharedCode }: TripButtonsProps) => { - - - - {isMenuOpen && ( - - 수정 - 삭제 - - )} - + {!isShared && ( + <> + + + + + + 여행 아이템을 삭제하겠어요? + 여행 아이템을 한번 삭제하면 다시 복구하기는 힘들어요. + + + + + + + + )} ); }; diff --git a/frontend/src/components/common/TripInformation/TripEditButtons/TripEditButtons.tsx b/frontend/src/components/common/TripInformation/TripEditButtons/TripEditButtons.tsx index ec88c2651..ecdd36013 100644 --- a/frontend/src/components/common/TripInformation/TripEditButtons/TripEditButtons.tsx +++ b/frontend/src/components/common/TripInformation/TripEditButtons/TripEditButtons.tsx @@ -18,7 +18,7 @@ const TripEditButtons = ({ tripId, openEditModal }: TripEditButtonsProps) => { 여행 정보 수정 ); diff --git a/frontend/src/components/common/TripInformation/TripInformation.style.ts b/frontend/src/components/common/TripInformation/TripInformation.style.ts index c1c8f28db..ef3b97f06 100644 --- a/frontend/src/components/common/TripInformation/TripInformation.style.ts +++ b/frontend/src/components/common/TripInformation/TripInformation.style.ts @@ -51,15 +51,22 @@ export const titleStyling = css({ export const descriptionStyling = css({ marginTop: Theme.spacer.spacing3, + + wordBreak: 'break-all', + whiteSpace: 'pre-wrap', }); export const buttonContainerStyling = css({ position: 'absolute', top: Theme.spacer.spacing4, - right: Theme.spacer.spacing4, + right: '50px', display: 'flex', alignItems: 'center', gap: Theme.spacer.spacing1, + + '@media screen and (max-width: 600px)': { + right: Theme.spacer.spacing4, + }, }); export const badgeStyling = css({ @@ -85,6 +92,6 @@ export const badgeWrapperStyling = css({ }, '@media screen and (max-width: 600px)': { - width: 'calc(100vw - 220px)', + width: 'calc(100vw - 232px)', }, }); diff --git a/frontend/src/components/common/TripInformation/TripInformation.tsx b/frontend/src/components/common/TripInformation/TripInformation.tsx index 18fdabb12..e3f09f278 100644 --- a/frontend/src/components/common/TripInformation/TripInformation.tsx +++ b/frontend/src/components/common/TripInformation/TripInformation.tsx @@ -17,63 +17,66 @@ import { } from '@components/common/TripInformation/TripInformation.style'; import TripInfoEditModal from '@components/trip/TripInfoEditModal/TripInfoEditModal'; +import { useTrip } from '@hooks/trip/useTrip'; + import { mediaQueryMobileState } from '@store/mediaQuery'; import { formatDate } from '@utils/formatter'; -import type { TripData } from '@type/trip'; - import DefaultThumbnail from '@assets/png/trip-information_default-thumbnail.png'; -interface TripInformationProps extends Omit { +interface TripInformationProps { + tripId: number; isEditable?: boolean; isShared?: boolean; } -const TripInformation = ({ - isEditable = true, - isShared = false, - ...information -}: TripInformationProps) => { +const TripInformation = ({ isEditable = true, isShared = false, tripId }: TripInformationProps) => { const isMobile = useRecoilValue(mediaQueryMobileState); const { isOpen: isEditModalOpen, close: closeEditModal, open: openEditModal } = useOverlay(); + const { tripData } = useTrip(tripId); + return ( <>
- 여행 대표 이미지 + 여행 대표 이미지 - {information.cities.map(({ id, name }) => ( + {tripData.cities.map(({ id, name }) => ( {name} ))} - {information.title} + {tripData.title} - {formatDate(information.startDate)} - {formatDate(information.endDate)} + {formatDate(tripData.startDate)} - {formatDate(tripData.endDate)} - {information.description} + {tripData.description} {isEditable ? ( - + ) : ( - !isShared && + )}
{isEditModalOpen && ( - + )} ); diff --git a/frontend/src/components/common/TripInformation/TripShareButton/TripShareButton.style.ts b/frontend/src/components/common/TripInformation/TripShareButton/TripShareButton.style.ts index 54380021b..b57dc2e9e 100644 --- a/frontend/src/components/common/TripInformation/TripShareButton/TripShareButton.style.ts +++ b/frontend/src/components/common/TripInformation/TripShareButton/TripShareButton.style.ts @@ -3,7 +3,8 @@ import { css } from '@emotion/react'; import { Theme } from 'hang-log-design-system'; export const shareButtonStyling = css({ - marginLeft: Theme.spacer.spacing3, + height: '20px', + marginLeft: '12px', border: 'none', backgroundColor: 'transparent', diff --git a/frontend/src/components/common/TripInformation/TripShareButton/TripShareButton.tsx b/frontend/src/components/common/TripInformation/TripShareButton/TripShareButton.tsx index ac6b880ce..bc8f77252 100644 --- a/frontend/src/components/common/TripInformation/TripShareButton/TripShareButton.tsx +++ b/frontend/src/components/common/TripInformation/TripShareButton/TripShareButton.tsx @@ -30,7 +30,9 @@ interface TripShareButtonProps { const TripShareButton = ({ tripId, sharedCode }: TripShareButtonProps) => { const tripShareStatusMutation = useTripShareStatusMutation(); const [isSharable, setIsSharable] = useState(!!sharedCode); - const [sharedUrl, setShareUrl] = useState(sharedCode ? BASE_URL + PATH.SHARE(sharedCode) : null); + const [sharedUrl, setShareUrl] = useState( + sharedCode ? BASE_URL + PATH.SHARE_TRIP(sharedCode) : null + ); const { isOpen: isShareMenuOpen, open: openShareMenu, close: closeShareMenu } = useOverlay(); const { handleCopyButtonClick } = useTripShare(sharedUrl); @@ -48,7 +50,7 @@ const TripShareButton = ({ tripId, sharedCode }: TripShareButtonProps) => { if (!sharedCode || !!sharedUrl) return; - setShareUrl(BASE_URL + PATH.SHARE(sharedCode)); + setShareUrl(BASE_URL + PATH.SHARE_TRIP(sharedCode)); }, } ); diff --git a/frontend/src/components/common/TripItem/EditMenu/EditMenu.style.ts b/frontend/src/components/common/TripItem/EditMenu/EditMenu.style.ts index f8e09ea0e..0722b4a75 100644 --- a/frontend/src/components/common/TripItem/EditMenu/EditMenu.style.ts +++ b/frontend/src/components/common/TripItem/EditMenu/EditMenu.style.ts @@ -20,25 +20,63 @@ export const moreButtonStyling = css({ }, }); -export const getMoreMenuStyling = (hasImage: boolean, imageHeight: number) => { +export const getEditMenuStyling = (hasImage: boolean, imageHeight: number) => { return css({ position: 'absolute', top: 0, right: 0, + cursor: 'pointer', + '@media screen and (max-width: 600px)': { top: hasImage ? `calc(${imageHeight}px + ${Theme.spacer.spacing3})` : 0, }, + + '& svg': { + width: '18px', + height: '18px', + }, }); }; -export const moreMenuListStyling = css({ - minWidth: 'unset', - width: 'max-content', +export const binIconStyling = css({ + '& path': { + stroke: Theme.color.gray600, + }, + + '&:hover path': { + stroke: Theme.color.red300, + }, +}); + +export const editIconStyling = css({ + '& path': { + fill: Theme.color.gray600, + }, + + '&:hover path': { + fill: Theme.color.blue600, + }, +}); + +export const modalContentStyling = css({ + width: '350px', + + '& h6': { + marginBottom: Theme.spacer.spacing3, + + color: Theme.color.red300, + }, +}); + +export const modalButtonContainerStyling = css({ + gap: Theme.spacer.spacing1, + alignItems: 'stretch', - transform: 'translateY(16px)', + width: '100%', + marginTop: Theme.spacer.spacing5, - '& > li': { - padding: `${Theme.spacer.spacing2} ${Theme.spacer.spacing3}`, + '& > *': { + width: '100%', }, }); diff --git a/frontend/src/components/common/TripItem/EditMenu/EditMenu.tsx b/frontend/src/components/common/TripItem/EditMenu/EditMenu.tsx index 41d9046cf..79f084dbb 100644 --- a/frontend/src/components/common/TripItem/EditMenu/EditMenu.tsx +++ b/frontend/src/components/common/TripItem/EditMenu/EditMenu.tsx @@ -1,9 +1,11 @@ -import { Menu, MenuItem, MenuList, useOverlay } from 'hang-log-design-system'; +import { Box, Button, Flex, Heading, Modal, Text, useOverlay } from 'hang-log-design-system'; import { - getMoreMenuStyling, - moreButtonStyling, - moreMenuListStyling, + binIconStyling, + editIconStyling, + getEditMenuStyling, + modalButtonContainerStyling, + modalContentStyling, } from '@components/common/TripItem/EditMenu/EditMenu.style'; import TripItemAddModal from '@components/trip/TripItemAddModal/TripItemAddModal'; @@ -11,7 +13,8 @@ import { useDeleteTripItemMutation } from '@hooks/api/useDeleteTripItemMutation' import type { TripItemData } from '@type/tripItem'; -import MoreIcon from '@assets/svg/more-icon.svg'; +import BinIcon from '@assets/svg/bin-icon.svg'; +import EditIcon from '@assets/svg/edit-icon.svg'; interface EditMenuProps extends TripItemData { tripId: number; @@ -23,8 +26,12 @@ interface EditMenuProps extends TripItemData { const EditMenu = ({ tripId, dayLogId, hasImage, imageHeight, ...information }: EditMenuProps) => { const deleteTripItemMutation = useDeleteTripItemMutation(); - const { isOpen: isMenuOpen, open: openMenu, close: closeMenu } = useOverlay(); const { isOpen: isEditModalOpen, open: openEditModal, close: closeEditModal } = useOverlay(); + const { + isOpen: isDeleteModalOpen, + close: closeDeleteModal, + open: openDeleteModal, + } = useOverlay(); const handleTripItemDelete = () => { deleteTripItemMutation.mutate({ tripId, itemId: information.id }); @@ -32,17 +39,13 @@ const EditMenu = ({ tripId, dayLogId, hasImage, imageHeight, ...information }: E return ( <> - - - {isMenuOpen && ( - - 수정 - 삭제 - - )} - + + + + {isEditModalOpen && ( )} + + + 여행 기록을 삭제하겠어요? + 여행 기록을 한번 삭제하면 다시 복구하기는 힘들어요. + + + + + + ); }; diff --git a/frontend/src/components/common/TripItem/TripItem.style.ts b/frontend/src/components/common/TripItem/TripItem.style.ts index cddd74ee4..561b98215 100644 --- a/frontend/src/components/common/TripItem/TripItem.style.ts +++ b/frontend/src/components/common/TripItem/TripItem.style.ts @@ -48,13 +48,16 @@ export const starRatingStyling = css({ }); export const titleStyling = css({ + fontWeight: '600', wordBreak: 'break-all', }); export const memoStyling = css({ + width: '95%', marginTop: Theme.spacer.spacing3, wordBreak: 'break-all', + whiteSpace: 'pre-wrap', }); export const expenseStyling = css({ @@ -95,3 +98,38 @@ export const moreMenuListStyling = css({ padding: `${Theme.spacer.spacing2} ${Theme.spacer.spacing3}`, }, }); + +export const imageModalStyling = css({ + padding: 0, + backgroundColor: 'black', + + '& > button': { + zIndex: Theme.zIndex.overlayPeak, + + '& > svg > path': { + stroke: Theme.color.white, + }, + }, + + '& > div': { + borderRadius: 0, + }, +}); + +export const expandedImageContainer = css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minWidth: '800px', + minHeight: '500px', + width: '800px', + height: '500px', + backgroundColor: 'black', +}); + +export const expandedImage = css({ + maxWidth: '100%', + maxHeight: '100%', + objectFit: 'contain', + objectPosition: 'center center', +}); diff --git a/frontend/src/components/common/TripItem/TripItem.tsx b/frontend/src/components/common/TripItem/TripItem.tsx index 1278d6f28..3b276bd3a 100644 --- a/frontend/src/components/common/TripItem/TripItem.tsx +++ b/frontend/src/components/common/TripItem/TripItem.tsx @@ -1,16 +1,19 @@ import type { ForwardedRef } from 'react'; -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useRecoilValue } from 'recoil'; -import { Box, Heading, ImageCarousel, Text } from 'hang-log-design-system'; +import { Box, Carousel, ImageCarousel, Modal, Text, useOverlay } from 'hang-log-design-system'; import StarRating from '@components/common/StarRating/StarRating'; import EditMenu from '@components/common/TripItem/EditMenu/EditMenu'; import { contentContainerStyling, + expandedImage, + expandedImageContainer, expenseStyling, getContainerStyling, + imageModalStyling, informationContainerStyling, memoStyling, starRatingStyling, @@ -19,14 +22,16 @@ import { } from '@components/common/TripItem/TripItem.style'; import { useDraggedItem } from '@hooks/common/useDraggedItem'; +import useResizeImage from '@hooks/trip/useResizeImage'; -import { mediaQueryMobileState, viewportWidthState } from '@store/mediaQuery'; +import { mediaQueryMobileState } from '@store/mediaQuery'; import { formatNumberToMoney } from '@utils/formatter'; import type { TripItemData } from '@type/tripItem'; import { CURRENCY_ICON } from '@constants/trip'; +import { TRIP_ITEM_IMAGE_HEIGHT, TRIP_ITEM_IMAGE_WIDTH } from '@constants/ui'; interface TripListItemProps extends TripItemData { tripId: number; @@ -51,10 +56,9 @@ const TripItem = ({ ...information }: TripListItemProps) => { const isMobile = useRecoilValue(mediaQueryMobileState); - const viewportWidth = useRecoilValue(viewportWidthState); + const { width, height } = useResizeImage(); - const imageWidth = useMemo(() => viewportWidth - 48, [viewportWidth]); - const imageHeight = useMemo(() => (imageWidth / 4.5) * 3, [imageWidth]); + const { isOpen: isImageModalOpen, open: openImageModal, close: closeImageModal } = useOverlay(); const { isDragging, handleDrag, handleDragEnd } = useDraggedItem(onDragEnd); const itemRef = useRef(null); @@ -66,61 +70,92 @@ const TripItem = ({ }, [observer]); return ( -
  • -
    - {information.imageUrls.length > 0 && ( - - )} - - - {information.title} - - {information.place && ( - - {information.place.category.name} - - )} - {information.rating && } - {information.memo && ( - - {information.memo} - + <> +
  • +
    + {information.imageUrls.length > 0 && ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
    { + if (e.key === 'Enter') { + openImageModal(); + } + }} + > + 1} + showDots={information.imageUrls.length > 1} + images={information.imageUrls} + /> +
    )} - {information.expense && ( - - {information.expense.category.name} · {CURRENCY_ICON[information.expense.currency]} - {formatNumberToMoney(information.expense.amount)} + + + {information.title} - )} - -
    - {isEditable ? ( - 0} - imageHeight={imageHeight} - {...information} - /> - ) : null} -
  • + {information.place && ( + + {information.place.category.name} + + )} + {information.rating && } + {information.memo && ( + + {information.memo} + + )} + {information.expense && ( + + {information.expense.category.name} · {CURRENCY_ICON[information.expense.currency]} + {formatNumberToMoney(information.expense.amount)} + + )} + + + {isEditable ? ( + 0} + imageHeight={height} + {...information} + /> + ) : null} + + {!isMobile && ( + + 1} + showDots={information.imageUrls.length > 1} + images={information.imageUrls} + > + {information.imageUrls.map((imageUrl) => ( +
    + 이미지 +
    + ))} +
    +
    + )} + ); }; diff --git a/frontend/src/components/common/TripItemList/TripItemList.tsx b/frontend/src/components/common/TripItemList/TripItemList.tsx index 62ac075ba..d084a690f 100644 --- a/frontend/src/components/common/TripItemList/TripItemList.tsx +++ b/frontend/src/components/common/TripItemList/TripItemList.tsx @@ -32,10 +32,13 @@ interface TripItemListProps { const TripItemList = ({ tripId, dayLogId, tripItems, isEditable = true }: TripItemListProps) => { const dayLogOrderMutation = useDayLogOrderMutation(); - const { observer } = useScrollFocus(); + const listRef = useRef(null); const itemRef = useRef(null); + + const { observer } = useScrollFocus(); const { scrollToCenter } = useAutoScroll(listRef, itemRef); + const clickedMarkerId = useRecoilValue(clickedMarkerIdState); useEffect(() => { diff --git a/frontend/src/components/common/TripMap/TripMap.tsx b/frontend/src/components/common/TripMap/TripMap.tsx index 47b51d089..1efe6f4a9 100644 --- a/frontend/src/components/common/TripMap/TripMap.tsx +++ b/frontend/src/components/common/TripMap/TripMap.tsx @@ -31,6 +31,8 @@ const TripMap = ({ centerLat, centerLng, places }: TripMapProps) => { maxZoom: MAP_MAX_ZOOM_SIZE, disableDefaultUI: true, mapId: process.env.GOOGLE_MAP_ID, + gestureHandling: 'greedy', + clickableIcons: false, }); setMap(initialMap); diff --git a/frontend/src/components/expense/ExpenseCategories/ExpenseCategories.tsx b/frontend/src/components/expense/ExpenseCategories/ExpenseCategories.tsx index 2b31cb45b..52edfe019 100644 --- a/frontend/src/components/expense/ExpenseCategories/ExpenseCategories.tsx +++ b/frontend/src/components/expense/ExpenseCategories/ExpenseCategories.tsx @@ -15,6 +15,7 @@ interface ExpenseCategoriesProps { const ExpenseCategories = ({ tripId }: ExpenseCategoriesProps) => { const { categoryExpenseData } = useExpense(tripId); + const { selected: selectedCategoryId, handleSelectClick: handleCategoryIdSelectClick } = useSelect(categoryExpenseData.categoryItems[0].category.id); const selectedCategory = categoryExpenseData.categoryItems.find( diff --git a/frontend/src/components/expense/ExpenseInformation/ExpenseInformation.tsx b/frontend/src/components/expense/ExpenseInformation/ExpenseInformation.tsx index e8b5ca9c4..c7ca6f17e 100644 --- a/frontend/src/components/expense/ExpenseInformation/ExpenseInformation.tsx +++ b/frontend/src/components/expense/ExpenseInformation/ExpenseInformation.tsx @@ -11,45 +11,52 @@ import { titleStyling, } from '@components/expense/ExpenseInformation/ExpenseInformation.style'; +import { useExpense } from '@hooks/expense/useExpense'; + import { mediaQueryMobileState } from '@store/mediaQuery'; import { formatDate } from '@utils/formatter'; -import type { ExpenseData } from '@type/expense'; - import { PATH } from '@constants/path'; -interface ExpenseInformationProps - extends Pick { +interface ExpenseInformationProps { tripId: number; + isShared: boolean; } -const ExpenseInformation = ({ ...information }: ExpenseInformationProps) => { +const ExpenseInformation = ({ tripId, isShared }: ExpenseInformationProps) => { const navigate = useNavigate(); const isMobile = useRecoilValue(mediaQueryMobileState); + const { expenseData } = useExpense(tripId); + + const goToTripPage = () => { + if (!isShared) { + navigate(PATH.TRIP(tripId)); + return; + } + + navigate(PATH.SHARE_TRIP(tripId)); + }; + return (
    - {information.cities.map(({ id, name }) => ( + {expenseData.cities.map(({ id, name }) => ( {name} ))} - {information.title} + {expenseData.title} - {formatDate(information.startDate)} - {formatDate(information.endDate)} + {formatDate(expenseData.startDate)} - {formatDate(expenseData.endDate)} - diff --git a/frontend/src/components/expense/ExpenseItem/ExpenseItem.style.ts b/frontend/src/components/expense/ExpenseItem/ExpenseItem.style.ts index cafef2b2d..d20b25de1 100644 --- a/frontend/src/components/expense/ExpenseItem/ExpenseItem.style.ts +++ b/frontend/src/components/expense/ExpenseItem/ExpenseItem.style.ts @@ -3,7 +3,7 @@ import { css } from '@emotion/react'; import { Theme } from 'hang-log-design-system'; export const containerStyling = css({ - width: '80%', + width: '100%', '& p': { color: Theme.color.gray600, diff --git a/frontend/src/components/expense/ExpenseItem/ExpenseItem.tsx b/frontend/src/components/expense/ExpenseItem/ExpenseItem.tsx index dc0dd0082..2822b3437 100644 --- a/frontend/src/components/expense/ExpenseItem/ExpenseItem.tsx +++ b/frontend/src/components/expense/ExpenseItem/ExpenseItem.tsx @@ -13,7 +13,7 @@ type ExpenseItemProps = ExpenseItemData; const ExpenseItem = ({ ...information }: ExpenseItemProps) => { return ( - + {information.title} diff --git a/frontend/src/components/expense/TotalExpenseSection/TotalExpenseSection.tsx b/frontend/src/components/expense/TotalExpenseSection/TotalExpenseSection.tsx index 12f88e7d1..c12b5a449 100644 --- a/frontend/src/components/expense/TotalExpenseSection/TotalExpenseSection.tsx +++ b/frontend/src/components/expense/TotalExpenseSection/TotalExpenseSection.tsx @@ -2,7 +2,6 @@ import { useRecoilValue } from 'recoil'; import { Box, Heading } from 'hang-log-design-system'; -import type { Segment } from '@components/common/DonutChart/DonutChart'; import DonutChart from '@components/common/DonutChart/DonutChart'; import ExpenseCategoryInformation from '@components/expense/ExpenseCategoryInformation/ExpenseCategoryInformation'; import ExpenseInformation from '@components/expense/ExpenseInformation/ExpenseInformation'; @@ -17,41 +16,22 @@ import { mediaQueryMobileState } from '@store/mediaQuery'; import { formatNumberToMoney } from '@utils/formatter'; -import { EXPENSE_CHART_COLORS } from '@constants/expense'; import { CURRENCY_ICON, DEFAULT_CURRENCY } from '@constants/trip'; +import { EXPENSE_CATEGORY_CHART_SIZE, EXPENSE_CATEGORY_CHART_STROKE_WIDTH } from '@constants/ui'; interface TotalExpenseSectionProps { tripId: number; + isShared: boolean; } -const TotalExpenseSection = ({ tripId }: TotalExpenseSectionProps) => { +const TotalExpenseSection = ({ tripId, isShared }: TotalExpenseSectionProps) => { const isMobile = useRecoilValue(mediaQueryMobileState); - const { expenseData } = useExpense(tripId); - - const chartData = expenseData.categories.reduce((acc, curr) => { - if (curr.percentage !== 0) { - const data = { - id: curr.category.id, - percentage: curr.percentage, - color: EXPENSE_CHART_COLORS[curr.category.name], - }; - - acc.push(data); - } - - return acc; - }, []); + const { expenseData, categoryChartData } = useExpense(tripId); return (
    - + 총 경비 :{' '} @@ -62,10 +42,14 @@ const TotalExpenseSection = ({ tripId }: TotalExpenseSectionProps) => { - +
    diff --git a/frontend/src/components/layout/Footer/Footer.tsx b/frontend/src/components/layout/Footer/Footer.tsx index 4c85168c3..47e405f2d 100644 --- a/frontend/src/components/layout/Footer/Footer.tsx +++ b/frontend/src/components/layout/Footer/Footer.tsx @@ -5,7 +5,9 @@ import { containerStyling } from '@components/layout/Footer/Footer.style'; const Footer = () => { return ( ); diff --git a/frontend/src/components/layout/Header/Header.style.ts b/frontend/src/components/layout/Header/Header.style.ts index 8fd3635a2..b2165d8e6 100644 --- a/frontend/src/components/layout/Header/Header.style.ts +++ b/frontend/src/components/layout/Header/Header.style.ts @@ -17,37 +17,13 @@ export const headerStyling = css({ height: '65px', padding: `${Theme.spacer.spacing3} ${Theme.spacer.spacing4}`, }, - - '& > *': { - cursor: 'pointer', - }, -}); - -export const imageStyling = css({ - minWidth: '32px', - minHeight: '32px', - maxWidth: '32px', - maxHeight: '32px', - border: 'none', - outline: 0, - borderRadius: '50%', - - backgroundColor: Theme.color.gray200, - - objectFit: 'cover', -}); - -export const menuListStyling = css({ - transform: 'translateY(36px)', - - '& > li': { - padding: `${Theme.spacer.spacing2} ${Theme.spacer.spacing3}`, - }, }); export const getItemStyling = (isLoggedIn: boolean) => { return css({ position: 'relative', top: !isLoggedIn ? '-2px' : 'undefined', + + cursor: 'pointer', }); }; diff --git a/frontend/src/components/layout/Header/LoggedInMenu/LoggedInMenu.style.ts b/frontend/src/components/layout/Header/LoggedInMenu/LoggedInMenu.style.ts index 9c3fe6229..74770eb90 100644 --- a/frontend/src/components/layout/Header/LoggedInMenu/LoggedInMenu.style.ts +++ b/frontend/src/components/layout/Header/LoggedInMenu/LoggedInMenu.style.ts @@ -7,6 +7,8 @@ export const imageStyling = css({ minHeight: '32px', maxWidth: '32px', maxHeight: '32px', + width: '32px', + height: '32px', border: 'none', outline: 0, borderRadius: '50%', @@ -14,6 +16,8 @@ export const imageStyling = css({ backgroundColor: Theme.color.gray200, objectFit: 'cover', + + cursor: 'pointer', }); export const menuListStyling = css({ diff --git a/frontend/src/components/layout/Header/LoggedOutMenu/LoggedOutMenu.tsx b/frontend/src/components/layout/Header/LoggedOutMenu/LoggedOutMenu.tsx index 5169c8e3d..9211a5a5f 100644 --- a/frontend/src/components/layout/Header/LoggedOutMenu/LoggedOutMenu.tsx +++ b/frontend/src/components/layout/Header/LoggedOutMenu/LoggedOutMenu.tsx @@ -1,8 +1,6 @@ import { useNavigate } from 'react-router-dom'; -import { Button, Flex, Theme } from 'hang-log-design-system'; - -import { getItemStyling } from '@components/layout/Header/Header.style'; +import { Button } from 'hang-log-design-system'; import { PATH } from '@constants/path'; @@ -10,14 +8,9 @@ const LoggedOutMenu = () => { const navigate = useNavigate(); return ( - - - - + ); }; diff --git a/frontend/src/components/myPage/EditUserProfileForm/EditUserProfileForm.style.ts b/frontend/src/components/myPage/EditUserProfileForm/EditUserProfileForm.style.ts index e03b6b254..16c83f304 100644 --- a/frontend/src/components/myPage/EditUserProfileForm/EditUserProfileForm.style.ts +++ b/frontend/src/components/myPage/EditUserProfileForm/EditUserProfileForm.style.ts @@ -25,11 +25,41 @@ export const buttonStyling = css({ }); export const deleteButtonStyling = css({ - marginTop: Theme.spacer.spacing1, + marginTop: Theme.spacer.spacing3, + padding: 0, color: Theme.color.gray600, + fontWeight: 'normal', '&:hover': { - color: Theme.color.gray800, + backgroundColor: 'transparent !important', + + color: Theme.color.gray700, + }, + + '&:focus': { + boxShadow: 'none', + }, +}); + +export const modalContentStyling = css({ + width: '350px', + + '& h6': { + marginBottom: Theme.spacer.spacing3, + + color: Theme.color.red300, + }, +}); + +export const modalButtonContainerStyling = css({ + gap: Theme.spacer.spacing1, + alignItems: 'stretch', + + width: '100%', + marginTop: Theme.spacer.spacing5, + + '& > *': { + width: '100%', }, }); diff --git a/frontend/src/components/myPage/EditUserProfileForm/EditUserProfileForm.tsx b/frontend/src/components/myPage/EditUserProfileForm/EditUserProfileForm.tsx index 888c70352..32f23f2be 100644 --- a/frontend/src/components/myPage/EditUserProfileForm/EditUserProfileForm.tsx +++ b/frontend/src/components/myPage/EditUserProfileForm/EditUserProfileForm.tsx @@ -1,10 +1,12 @@ -import { Button } from 'hang-log-design-system'; +import { Box, Button, Flex, Heading, Modal, Text, useOverlay } from 'hang-log-design-system'; import { buttonStyling, deleteButtonStyling, formStyling, imageInputStyling, + modalButtonContainerStyling, + modalContentStyling, } from '@components/myPage/EditUserProfileForm/EditUserProfileForm.style'; import NicknameInput from '@components/myPage/EditUserProfileForm/NicknameInput/NicknameInput'; import ProfileImageInput from '@components/myPage/EditUserProfileForm/ProfileImageInput/ProfileImageInput'; @@ -22,6 +24,12 @@ const EditUserProfileForm = ({ initialData }: EditUserProfileForm) => { const { userInfo, isNicknameError, updateInputValue, disableNicknameError, handleSubmit } = useEditUserProfileForm(initialData); + const { + isOpen: isDeleteModalOpen, + close: closeDeleteModal, + open: openDeleteModal, + } = useOverlay(); + const deleteAccountMutation = useDeleteAccountMutation(); const handleAccountDelete = () => { @@ -29,30 +37,49 @@ const EditUserProfileForm = ({ initialData }: EditUserProfileForm) => { }; return ( -
    - - - - - + <> +
    + + + + + + + + 계정 탈퇴를 하겠어요? + + 행록을 떠나보내야 한다니 아쉬워요. 행록을 탈퇴하고 싶다면 탈퇴 버튼을 눌러주세요. + + + + + + + + ); }; diff --git a/frontend/src/components/trip/TripCreateForm/TripCreateForm.style.ts b/frontend/src/components/trip/TripCreateForm/TripCreateForm.style.ts index 7501835ee..f86566217 100644 --- a/frontend/src/components/trip/TripCreateForm/TripCreateForm.style.ts +++ b/frontend/src/components/trip/TripCreateForm/TripCreateForm.style.ts @@ -16,12 +16,6 @@ export const formStyling = css({ '@media screen and (max-width: 600px)': { minHeight: 'calc(100vh - 124px)', marginTop: Theme.spacer.spacing4, - - '> button': { - position: 'absolute', - width: 'calc(100% - 48px)', - bottom: '100px', - }, }, }); diff --git a/frontend/src/components/trip/TripInfoEditModal/ImageInput/ImageInput.tsx b/frontend/src/components/trip/TripInfoEditModal/ImageInput/ImageInput.tsx index 98066fffa..e83ee07c1 100644 --- a/frontend/src/components/trip/TripInfoEditModal/ImageInput/ImageInput.tsx +++ b/frontend/src/components/trip/TripInfoEditModal/ImageInput/ImageInput.tsx @@ -25,7 +25,6 @@ const ImageInput = ({ initialImage, updateCoverImage }: ImageInputProps) => { initialImageUrls: initialImage === null ? [] : [initialImage], onSuccess: handleImageUrlsChange, }); - return ( { - const { dates } = useTripDates(tripId); + const { dates } = useTrip(tripId); useEffect(() => { const indexOfDayLogId = dates.findIndex((date) => date.id === dayLogId); diff --git a/frontend/src/components/trip/TripItemAddModal/StarRatingInput/StarRatingInput.tsx b/frontend/src/components/trip/TripItemAddModal/StarRatingInput/StarRatingInput.tsx index e1f44f3aa..56959fdcb 100644 --- a/frontend/src/components/trip/TripItemAddModal/StarRatingInput/StarRatingInput.tsx +++ b/frontend/src/components/trip/TripItemAddModal/StarRatingInput/StarRatingInput.tsx @@ -6,10 +6,11 @@ import type { StarRatingData, TripItemFormData } from '@type/tripItem'; interface StarRatingInputProps { rating: StarRatingData | null; + isMobile: boolean; updateInputValue: (key: K, value: TripItemFormData[K]) => void; } -const StarRatingInput = ({ rating, updateInputValue }: StarRatingInputProps) => { +const StarRatingInput = ({ rating, isMobile, updateInputValue }: StarRatingInputProps) => { const handleRatingChange = (rate: StarRatingData) => { const newRate = rate || null; updateInputValue('rating', newRate); @@ -22,6 +23,7 @@ const StarRatingInput = ({ rating, updateInputValue }: StarRatingInputProps) => return ( {duration} - + {description}
    diff --git a/frontend/src/components/trips/TutorialModal/TutorialModal.tsx b/frontend/src/components/trips/TutorialModal/TutorialModal.tsx index 30bc667fe..2b73c5dde 100644 --- a/frontend/src/components/trips/TutorialModal/TutorialModal.tsx +++ b/frontend/src/components/trips/TutorialModal/TutorialModal.tsx @@ -2,20 +2,8 @@ import { useEffect } from 'react'; import { useRecoilValue } from 'recoil'; -import { - // Button, - // Flex, - // Modal, - // SVGCarousel, - SVGCarouselModal, - useOverlay, -} from 'hang-log-design-system'; - -// import { -// boxStyling, -// buttonStyling, -// modalStyling, -// } from '@components/trips/TutorialModal/TutorialModal.style'; +import { SVGCarouselModal, useOverlay } from 'hang-log-design-system'; + import { mediaQueryMobileState } from '@store/mediaQuery'; import Tutorial1SVG from '@assets/svg/tutorial1.svg'; diff --git a/frontend/src/constants/api.ts b/frontend/src/constants/api.ts index e90b58985..9666e57a4 100644 --- a/frontend/src/constants/api.ts +++ b/frontend/src/constants/api.ts @@ -5,7 +5,7 @@ export const BASE_URL = PROD : 'http://localhost:3000'; export const AXIOS_BASE_URL = PROD - ? `${window.location.protocol}//${process.env.PROD_BASE_URL}/api` + ? `${window.location.protocol}//${process.env.AXIOS_PROD_BASE_URL}` : '/'; export const END_POINTS = { @@ -25,11 +25,12 @@ export const END_POINTS = { LOGOUT: '/logout', MY_PAGE: '/mypage', ACCOUNT: '/account', - SHARED_PAGE: (code: string) => `/shared-trips/${code}`, + SHARED_TRIP: (code: string | number) => `/shared-trips/${code}`, + SHARED_EXPENSE: (tripId: string | number) => `/shared-trips/${tripId}/expense`, } as const; export const NETWORK = { - RETRY_COUNT: 3, + RETRY_COUNT: 2, TIMEOUT: 10000, } as const; @@ -40,10 +41,12 @@ export const HTTP_STATUS_CODE = { BAD_REQUEST: 400, UNAUTHORIZED: 401, NOT_FOUND: 404, + CONTENT_TOO_LARGE: 413, INTERNAL_SERVER_ERROR: 500, } as const; export const ERROR_CODE = { + LARGE_IMAGE_FILE: 5001, TOKEN_ERROR_RANGE: 9000, INVALID_REFRESH_TOKEN: 9101, INVALID_ACCESS_TOKEN: 9102, @@ -51,7 +54,7 @@ export const ERROR_CODE = { EXPIRED_ACCESS_TOKEN: 9104, INVALID_TOKEN_VALIDATE: 9105, NULL_REFRESH_TOKEN: 9106, - UNEXCEPTED_TOKEN_ERROR: 9999, + UNEXPECTED_TOKEN_ERROR: 9999, } as const; export const HTTP_ERROR_MESSAGE = { diff --git a/frontend/src/constants/image.ts b/frontend/src/constants/image.ts new file mode 100644 index 000000000..2e0ba64e3 --- /dev/null +++ b/frontend/src/constants/image.ts @@ -0,0 +1,5 @@ +import type { Options } from 'browser-image-compression'; + +export const IMAGE_COMPRESSION_OPTIONS: Options = { + maxSizeMB: 1.5, +}; diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 3710ed88d..3ed5807d6 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -4,9 +4,9 @@ export const PATH = { EDIT_TRIP: (tripId: number | string) => `/trip/${tripId}/edit`, TRIP: (tripId: number | string) => `/trip/${tripId}`, EXPENSE: (tripId: number | string) => `/trip/${tripId}/expense`, - SHARE: (shareCode: string) => `/trip/share/${shareCode}`, + SHARE_TRIP: (shareCode: string | number) => `/trip/share/${shareCode}`, + SHARE_EXPENSE: (tripId: number | string) => `/trip/share/expense/${tripId}`, LOGIN: '/login', - SIGN_UP: '/sign-up', MY_PAGE: '/my-page', REDIRECT: '/auth/:provider', RELOAD: 0, diff --git a/frontend/src/constants/ui.ts b/frontend/src/constants/ui.ts index 40e874099..0f86c9a93 100644 --- a/frontend/src/constants/ui.ts +++ b/frontend/src/constants/ui.ts @@ -10,13 +10,13 @@ export const EXPENSE_CATEGORY_INFORMATION_SKELETON_LENGTH = 6; export const EXPENSE_LIST_SKELETON_LENGTH = 5; -export const TRIP_TITLE_MAX_LENGTH = 14; +export const TRIP_TITLE_MAX_LENGTH = 24; export const TRIP_DESCRIPTION_MAX_LENGTH = 124; export const DAYLOG_TITLE_MAX_LENGTH = 24; -export const TRIP_ITEM_TITLE_MAX_LENGTH = 20; +export const TRIP_ITEM_TITLE_MAX_LENGTH = 24; export const TRIP_ITEM_MEMO_MAX_LENGTH = 254; @@ -25,3 +25,11 @@ export const NICKNAME_MAX_LENGTH = 14; export const MOBILE_MEDIA_QUERY_SIZE = '(max-width: 600px)'; export const AMOUNT_MAX_LIMIT = 100_000_000; + +export const EXPENSE_CATEGORY_CHART_SIZE = 300; + +export const EXPENSE_CATEGORY_CHART_STROKE_WIDTH = 60; + +export const TRIP_ITEM_IMAGE_WIDTH = 250; + +export const TRIP_ITEM_IMAGE_HEIGHT = 167; diff --git a/frontend/src/hooks/api/queryClient.ts b/frontend/src/hooks/api/queryClient.ts new file mode 100644 index 000000000..a08c0201d --- /dev/null +++ b/frontend/src/hooks/api/queryClient.ts @@ -0,0 +1,13 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { NETWORK } from '@constants/api'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: NETWORK.RETRY_COUNT, + suspense: true, + useErrorBoundary: true, + }, + }, +}); diff --git a/frontend/src/hooks/api/useCityQuery.ts b/frontend/src/hooks/api/useCityQuery.ts index a3d29a631..40f320863 100644 --- a/frontend/src/hooks/api/useCityQuery.ts +++ b/frontend/src/hooks/api/useCityQuery.ts @@ -6,14 +6,8 @@ import { getCity } from '@api/city/getCity'; import type { CityData } from '@type/city'; -import { NETWORK } from '@constants/api'; - export const useCityQuery = () => { - const { data } = useQuery(['city'], getCity, { - retry: NETWORK.RETRY_COUNT, - suspense: true, - useErrorBoundary: true, - }); + const { data } = useQuery(['city'], getCity); return { cityData: data! }; }; diff --git a/frontend/src/hooks/api/useExpenseCategoryQuery.ts b/frontend/src/hooks/api/useExpenseCategoryQuery.ts index 3e3aecffb..787a86eca 100644 --- a/frontend/src/hooks/api/useExpenseCategoryQuery.ts +++ b/frontend/src/hooks/api/useExpenseCategoryQuery.ts @@ -6,18 +6,10 @@ import { getExpenseCategory } from '@api/expense/getExpenseCategory'; import type { ExpenseCategoryData } from '@type/expense'; -import { NETWORK } from '@constants/api'; - export const useExpenseCategoryQuery = () => { const { data } = useQuery( ['expenseCategory'], - getExpenseCategory, - { - retry: NETWORK.RETRY_COUNT, - suspense: true, - useErrorBoundary: true, - cacheTime: Infinity, - } + getExpenseCategory ); return { expenseCategoryData: data! }; diff --git a/frontend/src/hooks/api/useExpenseQuery.ts b/frontend/src/hooks/api/useExpenseQuery.ts index c2e2d5e0d..d0cdd620a 100644 --- a/frontend/src/hooks/api/useExpenseQuery.ts +++ b/frontend/src/hooks/api/useExpenseQuery.ts @@ -1,3 +1,5 @@ +import { getSharedExpense } from '@/api/expense/getSharedExpense'; + import { useQuery } from '@tanstack/react-query'; import type { AxiosError } from 'axios'; @@ -6,17 +8,10 @@ import { getExpense } from '@api/expense/getExpense'; import type { ExpenseData } from '@type/expense'; -import { NETWORK } from '@constants/api'; - -export const useExpenseQuery = (tripId: number) => { +export const useExpenseQuery = (tripId: number, isShared = false) => { const { data } = useQuery( ['expense', tripId], - () => getExpense(tripId), - { - retry: NETWORK.RETRY_COUNT, - suspense: true, - useErrorBoundary: true, - } + !isShared ? () => getExpense(tripId) : () => getSharedExpense(String(tripId)) ); return { expenseData: data! }; diff --git a/frontend/src/hooks/api/useImageMutation.ts b/frontend/src/hooks/api/useImageMutation.ts index 08c5154e4..3296075ba 100644 --- a/frontend/src/hooks/api/useImageMutation.ts +++ b/frontend/src/hooks/api/useImageMutation.ts @@ -6,7 +6,7 @@ import { useTokenError } from '@hooks/member/useTokenError'; import { postImage } from '@api/image/postImage'; import type { ErrorResponseData } from '@api/interceptors'; -import { ERROR_CODE } from '@constants/api'; +import { ERROR_CODE, HTTP_STATUS_CODE } from '@constants/api'; export const useImageMutation = () => { const { createToast } = useToast(); @@ -21,6 +21,17 @@ export const useImageMutation = () => { return; } + if ( + (error.code && error.code === ERROR_CODE.LARGE_IMAGE_FILE) || + error.statusCode === HTTP_STATUS_CODE.CONTENT_TOO_LARGE + ) { + createToast( + '이미지 용량이 커서 업로드에 실패했습니다. 10mb 이하의 파일을 업로드해 주세요.' + ); + + return; + } + createToast('이미지 업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.'); }, }); diff --git a/frontend/src/hooks/api/useLogInMutation.ts b/frontend/src/hooks/api/useLogInMutation.ts index ffd6792e1..d83bb7e1b 100644 --- a/frontend/src/hooks/api/useLogInMutation.ts +++ b/frontend/src/hooks/api/useLogInMutation.ts @@ -27,13 +27,12 @@ export const useLogInMutation = () => { axiosInstance.defaults.headers.Authorization = `Bearer ${accessToken}`; setIsLoggedIn(true); + navigate(-2); }, onError: () => { setIsLoggedIn(false); createToast('오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); - }, - onSettled: () => { navigate(PATH.ROOT); }, }); diff --git a/frontend/src/hooks/api/useSharedTripQuery.ts b/frontend/src/hooks/api/useSharedTripQuery.ts index cac24607c..fd2f69053 100644 --- a/frontend/src/hooks/api/useSharedTripQuery.ts +++ b/frontend/src/hooks/api/useSharedTripQuery.ts @@ -6,17 +6,9 @@ import { getSharedTrip } from '@api/trip/getSharedTrip'; import type { TripData } from '@type/trip'; -import { NETWORK } from '@constants/api'; - export const useSharedQuery = (code: string) => { - const { data } = useQuery( - ['trip', Number(code)], - () => getSharedTrip(code), - { - retry: NETWORK.RETRY_COUNT, - suspense: true, - useErrorBoundary: true, - } + const { data } = useQuery(['trip', Number(code)], () => + getSharedTrip(code) ); return { tripData: data! }; diff --git a/frontend/src/hooks/api/useTripEditPageQueries.ts b/frontend/src/hooks/api/useTripEditPageQueries.ts new file mode 100644 index 000000000..cd5881f0a --- /dev/null +++ b/frontend/src/hooks/api/useTripEditPageQueries.ts @@ -0,0 +1,15 @@ +import { useQueries } from '@tanstack/react-query'; + +import { getExpenseCategory } from '@api/expense/getExpenseCategory'; +import { getTrip } from '@api/trip/getTrip'; + +export const useTripEditPageQueries = (tripId: number) => { + const [tripQuery, expenseCategoryQuery] = useQueries({ + queries: [ + { queryKey: ['trip', tripId], queryFn: () => getTrip(tripId) }, + { queryKey: ['expenseCategory'], queryFn: getExpenseCategory, staleTime: Infinity }, + ], + }); + + return { tripData: tripQuery.data!, expenseCategoryData: expenseCategoryQuery.data! }; +}; diff --git a/frontend/src/hooks/api/useTripQuery.ts b/frontend/src/hooks/api/useTripQuery.ts index 3e629f037..d1412faa5 100644 --- a/frontend/src/hooks/api/useTripQuery.ts +++ b/frontend/src/hooks/api/useTripQuery.ts @@ -6,14 +6,8 @@ import { getTrip } from '@api/trip/getTrip'; import type { TripData } from '@type/trip'; -import { NETWORK } from '@constants/api'; - export const useTripQuery = (tripId: number) => { - const { data } = useQuery(['trip', tripId], () => getTrip(tripId), { - retry: NETWORK.RETRY_COUNT, - suspense: true, - useErrorBoundary: true, - }); + const { data } = useQuery(['trip', tripId], () => getTrip(tripId)); return { tripData: data! }; }; diff --git a/frontend/src/hooks/api/useTripsQuery.ts b/frontend/src/hooks/api/useTripsQuery.ts index ffcbaba91..be9a3a867 100644 --- a/frontend/src/hooks/api/useTripsQuery.ts +++ b/frontend/src/hooks/api/useTripsQuery.ts @@ -6,14 +6,8 @@ import { getTrips } from '@api/trips/getTrips'; import type { TripsData } from '@type/trips'; -import { NETWORK } from '@constants/api'; - export const useTripsQuery = () => { - const { data } = useQuery(['trips'], getTrips, { - retry: NETWORK.RETRY_COUNT, - suspense: true, - useErrorBoundary: true, - }); + const { data } = useQuery(['trips'], getTrips); return { tripsData: data! }; }; diff --git a/frontend/src/hooks/api/useUserInfoQuery.ts b/frontend/src/hooks/api/useUserInfoQuery.ts index ab3ac2c7a..791e648d0 100644 --- a/frontend/src/hooks/api/useUserInfoQuery.ts +++ b/frontend/src/hooks/api/useUserInfoQuery.ts @@ -6,14 +6,8 @@ import { getUserInfo } from '@api/member/getUserInfo'; import type { UserData } from '@type/member'; -import { NETWORK } from '@constants/api'; - export const useUserInfoQuery = () => { - const { data } = useQuery(['userInfo'], getUserInfo, { - retry: NETWORK.RETRY_COUNT, - suspense: true, - useErrorBoundary: true, - }); + const { data } = useQuery(['userInfo'], getUserInfo); return { userInfoData: data! }; }; diff --git a/frontend/src/hooks/common/useMediaQuery.ts b/frontend/src/hooks/common/useMediaQuery.ts index efdd3e0ad..d921599f1 100644 --- a/frontend/src/hooks/common/useMediaQuery.ts +++ b/frontend/src/hooks/common/useMediaQuery.ts @@ -2,39 +2,43 @@ import { useCallback, useEffect, useRef } from 'react'; import { useSetRecoilState } from 'recoil'; -import { mediaQueryMobileState, viewportWidthState } from '@store/mediaQuery'; +import { mediaQueryMobileState, viewportHeightState, viewportWidthState } from '@store/mediaQuery'; import { MOBILE_MEDIA_QUERY_SIZE } from '@constants/ui'; export const useMediaQuery = () => { const setViewportWidth = useSetRecoilState(viewportWidthState); + const setViewportHeight = useSetRecoilState(viewportHeightState); const setIsMobile = useSetRecoilState(mediaQueryMobileState); const mediaQueryRef = useRef(null); const handleWindowResize = useCallback(() => { setIsMobile(window.matchMedia(MOBILE_MEDIA_QUERY_SIZE).matches); setViewportWidth(window.innerWidth); - }, [setIsMobile, setViewportWidth]); + setViewportHeight(window.innerHeight); + }, [setIsMobile, setViewportWidth, setViewportHeight]); - const handleViewportWidthChange = useCallback(() => { + const handleViewportDimensionChange = useCallback(() => { setViewportWidth(window.innerWidth); - }, [setViewportWidth]); + setViewportHeight(window.innerHeight); + }, [setViewportWidth, setViewportHeight]); useEffect(() => { setIsMobile(window.matchMedia(MOBILE_MEDIA_QUERY_SIZE).matches); setViewportWidth(window.innerWidth); - }, [setIsMobile, setViewportWidth]); + setViewportHeight(window.innerHeight); + }, [setIsMobile, setViewportWidth, setViewportHeight]); useEffect(() => { const mediaQueryList = window.matchMedia(MOBILE_MEDIA_QUERY_SIZE); mediaQueryRef.current = mediaQueryList; mediaQueryRef.current.addEventListener('change', handleWindowResize); - window.addEventListener('resize', handleViewportWidthChange); + window.addEventListener('resize', handleViewportDimensionChange); return () => { mediaQueryRef.current?.removeEventListener('change', handleWindowResize); - window.removeEventListener('resize', handleViewportWidthChange); + window.removeEventListener('resize', handleViewportDimensionChange); }; - }, [handleWindowResize, handleViewportWidthChange]); + }, [handleWindowResize, handleViewportDimensionChange]); }; diff --git a/frontend/src/hooks/common/useMultipleImageUpload.ts b/frontend/src/hooks/common/useMultipleImageUpload.ts index a6f917a33..026ff218d 100644 --- a/frontend/src/hooks/common/useMultipleImageUpload.ts +++ b/frontend/src/hooks/common/useMultipleImageUpload.ts @@ -1,8 +1,12 @@ import type { ChangeEvent } from 'react'; import { useCallback, useState } from 'react'; +import imageCompression from 'browser-image-compression'; + import { useImageMutation } from '@hooks/api/useImageMutation'; +import { useToast } from '@hooks/common/useToast'; +import { IMAGE_COMPRESSION_OPTIONS } from '@constants/image'; import { TRIP_ITEM_ADD_MAX_IMAGE_UPLOAD_COUNT } from '@constants/ui'; interface UseMultipleImageUploadParams { @@ -20,20 +24,43 @@ export const useMultipleImageUpload = ({ }: UseMultipleImageUploadParams) => { const imageMutation = useImageMutation(); + const { createToast } = useToast(); + const [uploadedImageUrls, setUploadedImageUrls] = useState(initialImageUrls); const handleImageUpload = useCallback( async (event: ChangeEvent) => { - const imageFiles = event.target.files; + const originalImageFiles = event.target.files; - if (!imageFiles) return; + if (!originalImageFiles) return; - if (imageFiles.length + uploadedImageUrls.length > maxUploadCount) { + if (originalImageFiles.length + uploadedImageUrls.length > maxUploadCount) { onError?.(); return; } + const prevImageUrls = uploadedImageUrls; + + setUploadedImageUrls((prevImageUrls) => { + const newImageUrls = [...originalImageFiles].map((file) => URL.createObjectURL(file)); + + return [...prevImageUrls, ...newImageUrls]; + }); + + const imageFiles: File[] = []; + + try { + await Promise.all( + [...originalImageFiles].map(async (file) => { + const compressedImageFile = await imageCompression(file, IMAGE_COMPRESSION_OPTIONS); + imageFiles.push(compressedImageFile); + }) + ); + } catch (e) { + imageFiles.push(...originalImageFiles); + } + const imageUploadFormData = new FormData(); [...imageFiles].forEach((file) => { @@ -44,12 +71,11 @@ export const useMultipleImageUpload = ({ { images: imageUploadFormData }, { onSuccess: ({ imageUrls }) => { - setUploadedImageUrls((prevImageUrls) => { - const updatedImageUrls = [...prevImageUrls, ...imageUrls]; - onSuccess?.(updatedImageUrls); - - return updatedImageUrls; - }); + onSuccess?.([...prevImageUrls, ...imageUrls]); + createToast('이미지 업로드에 성공했습니다', 'success'); + }, + onError: () => { + setUploadedImageUrls(prevImageUrls); }, } ); @@ -57,7 +83,7 @@ export const useMultipleImageUpload = ({ // eslint-disable-next-line no-param-reassign event.target.value = ''; }, - [imageMutation, maxUploadCount, onError, onSuccess, uploadedImageUrls.length] + [createToast, imageMutation, maxUploadCount, onError, onSuccess, uploadedImageUrls] ); const handleImageRemoval = useCallback( diff --git a/frontend/src/hooks/common/useSingleImageUpload.ts b/frontend/src/hooks/common/useSingleImageUpload.ts index 753efd47a..d29871f9a 100644 --- a/frontend/src/hooks/common/useSingleImageUpload.ts +++ b/frontend/src/hooks/common/useSingleImageUpload.ts @@ -1,7 +1,12 @@ import type { ChangeEvent } from 'react'; import { useCallback, useState } from 'react'; +import imageCompression from 'browser-image-compression'; + import { useImageMutation } from '@hooks/api/useImageMutation'; +import { useToast } from '@hooks/common/useToast'; + +import { IMAGE_COMPRESSION_OPTIONS } from '@constants/image'; interface UseSingleImageUploadParams { initialImageUrl: string | null; @@ -14,26 +19,40 @@ export const useSingleImageUpload = ({ }: UseSingleImageUploadParams) => { const imageMutation = useImageMutation(); + const { createToast } = useToast(); + const [uploadedImageUrl, setUploadedImageUrl] = useState(initialImageUrl); const handleImageUpload = useCallback( async (event: ChangeEvent) => { - const imageFiles = event.target.files; + const originalImageFile = event.target.files?.[0]; - if (!imageFiles) return; + if (!originalImageFile) return; - const imageUploadFormData = new FormData(); + const prevImageUrl = uploadedImageUrl; + + setUploadedImageUrl(URL.createObjectURL(originalImageFile)); - [...imageFiles].forEach((file) => { - imageUploadFormData.append('images', file); - }); + let imageFile: File; + + try { + imageFile = await imageCompression(originalImageFile, IMAGE_COMPRESSION_OPTIONS); + } catch (e) { + imageFile = originalImageFile; + } + + const imageUploadFormData = new FormData(); + imageUploadFormData.append('images', imageFile); imageMutation.mutate( { images: imageUploadFormData }, { onSuccess: ({ imageUrls }) => { - setUploadedImageUrl(imageUrls[0]); onSuccess?.(imageUrls[0]); + createToast('이미지 업로드에 성공했습니다', 'success'); + }, + onError: () => { + setUploadedImageUrl(prevImageUrl); }, } ); @@ -41,7 +60,7 @@ export const useSingleImageUpload = ({ // eslint-disable-next-line no-param-reassign event.target.value = ''; }, - [imageMutation, onSuccess] + [createToast, imageMutation, onSuccess, uploadedImageUrl] ); const handleImageRemoval = useCallback(() => { diff --git a/frontend/src/hooks/expense/useExpense.ts b/frontend/src/hooks/expense/useExpense.ts index 6edf69498..636a8bd2e 100644 --- a/frontend/src/hooks/expense/useExpense.ts +++ b/frontend/src/hooks/expense/useExpense.ts @@ -2,8 +2,12 @@ import { useCallback, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import type { Segment } from '@components/common/DonutChart/DonutChart'; + import type { ExpenseData, ExpenseItemData } from '@type/expense'; +import { EXPENSE_CHART_COLORS } from '@constants/expense'; + export const useExpense = (tripId: number) => { const queryClient = useQueryClient(); @@ -37,9 +41,27 @@ export const useExpense = (tripId: number) => { }; }), }; - }, [expenseData]); + }, [expenseData.categories, expenseData.dayLogs, expenseData.exchangeRate.date]); + + const getCategoryChartData = useCallback(() => { + return expenseData.categories.reduce((acc, curr) => { + if (curr.percentage !== 0) { + const data = { + id: curr.category.id, + percentage: curr.percentage, + color: EXPENSE_CHART_COLORS[curr.category.name], + }; + + acc.push(data); + } + + return acc; + }, []); + }, [expenseData.categories]); const categoryExpenseData = useMemo(() => getItemsByCategory(), [getItemsByCategory]); - return { expenseData, dates, categoryExpenseData }; + const categoryChartData = useMemo(() => getCategoryChartData(), [getCategoryChartData]); + + return { expenseData, dates, categoryExpenseData, categoryChartData }; }; diff --git a/frontend/src/hooks/trip/useAddTripItemForm.ts b/frontend/src/hooks/trip/useAddTripItemForm.ts index 0fbf26f8a..434cb3f33 100644 --- a/frontend/src/hooks/trip/useAddTripItemForm.ts +++ b/frontend/src/hooks/trip/useAddTripItemForm.ts @@ -3,6 +3,7 @@ import { useCallback, useState } from 'react'; import { useAddTripItemMutation } from '@hooks/api/useAddTripItemMutation'; import { useUpdateTripItemMutation } from '@hooks/api/useUpdateTripItemMutation'; +import { useTrip } from '@hooks/trip/useTrip'; import { isEmptyString } from '@utils/validator'; @@ -25,11 +26,15 @@ export const useAddTripItemForm = ({ onSuccess, onError, }: UseAddTripItemFormParams) => { + const { dates } = useTrip(tripId); + + const dayLogIndex = dates.findIndex((date) => date.id === initialDayLogId)!; + const addTripItemMutation = useAddTripItemMutation(); const updateTripItemMutation = useUpdateTripItemMutation(); const [tripItemInformation, setTripItemInformation] = useState( initialData ?? { - itemType: true, + itemType: dayLogIndex !== dates.length - 1, dayLogId: initialDayLogId, title: '', place: null, @@ -56,7 +61,7 @@ export const useAddTripItemForm = ({ ); const isFormError = () => { - if (isEmptyString(tripItemInformation.title)) { + if (isEmptyString(tripItemInformation.title.trim())) { return true; } diff --git a/frontend/src/hooks/trip/useExpandImage.ts b/frontend/src/hooks/trip/useExpandImage.ts new file mode 100644 index 000000000..a6ec49c40 --- /dev/null +++ b/frontend/src/hooks/trip/useExpandImage.ts @@ -0,0 +1,28 @@ +export const useExpandImage = (imageUrl: string) => { + const getImageUrlWidthHeight = () => { + const img = new Image(); + img.src = imageUrl; + + return { originalWidth: img.width, originalHeight: img.height }; + }; + + const getExpandedImageWithHeight = () => { + const { originalWidth, originalHeight } = getImageUrlWidthHeight(); + + const orientation = originalHeight > originalWidth ? 'portrait' : 'landscape'; + + if (orientation === 'portrait') { + const height = 720; + const width = Math.floor((originalWidth * height) / originalHeight); + + return { width, height }; + } + + const width = 600; + const height = Math.floor((originalHeight * width) / originalWidth); + + return { width, height }; + }; + + return { getExpandedImageWithHeight }; +}; diff --git a/frontend/src/hooks/trip/useResizeImage.ts b/frontend/src/hooks/trip/useResizeImage.ts new file mode 100644 index 000000000..61968f6e1 --- /dev/null +++ b/frontend/src/hooks/trip/useResizeImage.ts @@ -0,0 +1,16 @@ +import { viewportWidthState } from '@/store/mediaQuery'; + +import { useMemo } from 'react'; + +import { useRecoilValue } from 'recoil'; + +const useResizeImage = () => { + const viewportWidth = useRecoilValue(viewportWidthState); + + const width = useMemo(() => viewportWidth - 48, [viewportWidth]); + const height = useMemo(() => (width / 4.5) * 3, [width]); + + return { width, height }; +}; + +export default useResizeImage; diff --git a/frontend/src/hooks/trip/useTripDates.ts b/frontend/src/hooks/trip/useTrip.ts similarity index 80% rename from frontend/src/hooks/trip/useTripDates.ts rename to frontend/src/hooks/trip/useTrip.ts index 5a73d49ad..b99522174 100644 --- a/frontend/src/hooks/trip/useTripDates.ts +++ b/frontend/src/hooks/trip/useTrip.ts @@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query'; import type { TripData } from '@type/trip'; -export const useTripDates = (tripId: number) => { +export const useTrip = (tripId: number) => { const queryClient = useQueryClient(); const tripData = queryClient.getQueryData(['trip', tripId])!; @@ -12,5 +12,5 @@ export const useTripDates = (tripId: number) => { date: data.date, })); - return { dates }; + return { tripData, dates }; }; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index c51df6396..b5ed18ae5 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -3,7 +3,7 @@ import { Global } from '@emotion/react'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { RecoilRoot } from 'recoil'; @@ -12,6 +12,8 @@ import { HangLogProvider } from 'hang-log-design-system'; import HttpsRedirect from '@components/utils/HttpsRedirect'; +import { queryClient } from '@hooks/api/queryClient'; + import AppRouter from '@router/AppRouter'; import { GlobalStyle } from '@styles/index'; @@ -30,8 +32,6 @@ const main = async () => { const root = createRoot(document.querySelector('#root') as Element); - const queryClient = new QueryClient(); - root.render( diff --git a/frontend/src/mocks/handlers/expense.ts b/frontend/src/mocks/handlers/expense.ts index e47b8109e..df26e9919 100644 --- a/frontend/src/mocks/handlers/expense.ts +++ b/frontend/src/mocks/handlers/expense.ts @@ -12,4 +12,8 @@ export const expenseHandlers = [ rest.get(END_POINTS.EXPENSE_CATEGORY, (_, res, ctx) => { return res(ctx.status(HTTP_STATUS_CODE.SUCCESS), ctx.json(expenseCategories)); }), + + rest.get(`${END_POINTS.SHARED_EXPENSE(':tripId')}`, async (_, res, ctx) => { + return res(ctx.delay(1000), ctx.status(HTTP_STATUS_CODE.SUCCESS), ctx.json(expense)); + }), ]; diff --git a/frontend/src/mocks/handlers/trips.ts b/frontend/src/mocks/handlers/trips.ts index b65a319af..a008449bd 100644 --- a/frontend/src/mocks/handlers/trips.ts +++ b/frontend/src/mocks/handlers/trips.ts @@ -49,7 +49,7 @@ export const tripsHandlers = [ return res(ctx.status(HTTP_STATUS_CODE.SUCCESS), ctx.json({ sharedCode })); }), - rest.get(`${END_POINTS.SHARED_PAGE(':code')}`, async (req, res, ctx) => { + rest.get(`${END_POINTS.SHARED_TRIP(':code')}`, async (req, res, ctx) => { return res(ctx.delay(1000), ctx.status(HTTP_STATUS_CODE.SUCCESS), ctx.json(trip)); }), ]; diff --git a/frontend/src/pages/ExpensePage/ExpensePage.tsx b/frontend/src/pages/ExpensePage/ExpensePage.tsx index 5b8066a63..6c2afab3d 100644 --- a/frontend/src/pages/ExpensePage/ExpensePage.tsx +++ b/frontend/src/pages/ExpensePage/ExpensePage.tsx @@ -13,18 +13,22 @@ import { useExpenseQuery } from '@hooks/api/useExpenseQuery'; import { mediaQueryMobileState } from '@store/mediaQuery'; -const ExpensePage = () => { +interface ExpensePageProps { + isShared?: boolean; +} + +const ExpensePage = ({ isShared = false }: ExpensePageProps) => { const { tripId } = useParams(); if (!tripId) throw new Error('존재하지 않는 tripId 입니다.'); const isMobile = useRecoilValue(mediaQueryMobileState); - const { expenseData } = useExpenseQuery(Number(tripId)); + const { expenseData } = useExpenseQuery(Number(tripId), isShared); return ( - + {isMobile && } diff --git a/frontend/src/pages/IntroPage/IntroPage.tsx b/frontend/src/pages/IntroPage/IntroPage.tsx index 6e3ab94f6..34034b825 100644 --- a/frontend/src/pages/IntroPage/IntroPage.tsx +++ b/frontend/src/pages/IntroPage/IntroPage.tsx @@ -15,8 +15,8 @@ import { mediaQueryMobileState } from '@store/mediaQuery'; import { PATH } from '@constants/path'; -import SampleTripImage from '@assets/png/sample-trip-image.png'; -import SampleTripImageMobile from '@assets/png/sample-trip-image_mobile.png'; +import SampleTripImage from '@assets/jpg/sample-trip-image.jpg'; +import SampleTripImageMobile from '@assets/jpg/sample-trip-image_mobile.jpg'; const IntroPage = () => { const navigate = useNavigate(); @@ -35,7 +35,7 @@ const IntroPage = () => { 여행과 관련된 모든 정보를
    한곳에서 기록해 보세요. - { +const SharedTripPage = () => { const { code } = useParams(); if (!code) throw new Error('존재하지 않는 공유코드입니다.'); @@ -45,7 +45,7 @@ const SharedPage = () => { return (
    - + { ); }; -export default SharedPage; +export default SharedTripPage; diff --git a/frontend/src/pages/SignUpPage/SignUpPage.tsx b/frontend/src/pages/SignUpPage/SignUpPage.tsx deleted file mode 100644 index bdbf86368..000000000 --- a/frontend/src/pages/SignUpPage/SignUpPage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Navigate } from 'react-router-dom'; - -import { useRecoilValue } from 'recoil'; - -import { Flex, Heading, Theme } from 'hang-log-design-system'; - -import { - backgroundImageStyling, - buttonContainerStyling, - containerStyling, - headingStyling, -} from '@pages/SignUpPage/SignUpPage.style'; - -import GoogleButton from '@components/common/GoogleButton/GoogleButton'; -import KakaoButton from '@components/common/KakaoButton/KakaoButton'; - -import { isLoggedInState } from '@store/auth'; - -import { PATH } from '@constants/path'; - -import AuthImage from '@assets/svg/auth-image.svg'; -import LogoVertical from '@assets/svg/logo-vertical.svg'; - -const SignUpPage = () => { - const isLoggedIn = useRecoilValue(isLoggedInState); - - if (isLoggedIn) return ; - - return ( - - - - 나만의 여행을 기록해 보세요 - - - 카카오로 바로 시작하기 - 구글로 바로 시작하기 - - - - ); -}; - -export default SignUpPage; diff --git a/frontend/src/pages/TripEditPage/TripEditPage.tsx b/frontend/src/pages/TripEditPage/TripEditPage.tsx index 8dcc9aa5d..a88881d64 100644 --- a/frontend/src/pages/TripEditPage/TripEditPage.tsx +++ b/frontend/src/pages/TripEditPage/TripEditPage.tsx @@ -17,8 +17,7 @@ import TripInformation from '@components/common/TripInformation/TripInformation' import TripMap from '@components/common/TripMap/TripMap'; import TripItemAddModal from '@components/trip/TripItemAddModal/TripItemAddModal'; -import { useExpenseCategoryQuery } from '@hooks/api/useExpenseCategoryQuery'; -import { useTripQuery } from '@hooks/api/useTripQuery'; +import { useTripEditPageQueries } from '@hooks/api/useTripEditPageQueries'; import { mediaQueryMobileState } from '@store/mediaQuery'; @@ -29,8 +28,7 @@ const TripEditPage = () => { const isMobile = useRecoilValue(mediaQueryMobileState); - const { tripData } = useTripQuery(Number(tripId)); - useExpenseCategoryQuery(); + const { tripData } = useTripEditPageQueries(Number(tripId)); const { isOpen: isAddModalOpen, open: openAddModal, close: closeAddModal } = useOverlay(); const { selected: selectedDayLogId, handleSelectClick: handleDayLogIdSelectClick } = useSelect( @@ -53,7 +51,7 @@ const TripEditPage = () => { return (
    - + { + const [isDaylogShown, setIsDaylogShown] = useState(true); + const { tripId } = useParams(); + + const { tripData } = useTripQuery(Number(tripId)); + + const { selected: selectedDayLogId, handleSelectClick: handleDayLogIdSelectClick } = useSelect( + tripData.dayLogs[0].id + ); + const selectedDayLog = tripData.dayLogs.find((log) => log.id === selectedDayLogId)!; + const { dates } = useTrip(Number(tripId)); + + const places = useMemo( + () => + selectedDayLog.items + .filter((item) => item.itemType) + .map((item) => ({ + id: item.id, + name: item.title, + coordinate: { lat: item.place!.latitude, lng: item.place!.longitude }, + })), + [selectedDayLog.items] + ); + + return ( + +
    + +
    + + {dates.map((date, index) => { + const isLast = index === dates.length - 1; + const tabText = + date.id === selectedDayLog.id + ? `Day ${index + 1} - ${formatMonthDate(date.date)} ` + : `Day ${index + 1}`; + + return ( + + ); + })} + + {isDaylogShown && ( + + )} +
    +
    + {!isDaylogShown && ( +
    + + + +
    + )} +
    + +
    +
    + ); +}; + +export default TripMobilePage; diff --git a/frontend/src/pages/TripPage/TripPage.style.ts b/frontend/src/pages/TripPage/TripPage.style.ts index 6a271788f..e55161f08 100644 --- a/frontend/src/pages/TripPage/TripPage.style.ts +++ b/frontend/src/pages/TripPage/TripPage.style.ts @@ -1,5 +1,7 @@ import { css } from '@emotion/react'; +import { Theme } from 'hang-log-design-system'; + export const containerStyling = css({ position: 'relative', @@ -13,6 +15,7 @@ export const containerStyling = css({ '@media screen and (max-width: 600px)': { width: '100vw', + paddingBottom: '0', }, }); @@ -23,10 +26,13 @@ export const mapContainerStyling = css({ width: '50vw', height: 'calc(100vh - 81px)', +}); - '@media screen and (max-width: 600px)': { - height: 'calc(100vh - 65px)', - }, +export const mapMobileContainerStyling = css({ + position: 'sticky', + + width: '100vw', + height: 'calc(100vh - 65px)', }); export const skeletonContainerStyling = css({ @@ -34,3 +40,34 @@ export const skeletonContainerStyling = css({ borderRadius: 0, }, }); + +export const buttonContainerStyling = css({ + position: 'fixed', + bottom: '30px', + display: 'flex', + width: '100%', + alignItems: 'center', + justifyContent: 'center', +}); + +export const contentStyling = css({ + display: 'flex', + flexDirection: 'column', + gap: Theme.spacer.spacing4, + + width: '100%', + padding: `${Theme.spacer.spacing4} 50px`, + + '@media screen and (max-width: 600px)': { + padding: `${Theme.spacer.spacing3} ${Theme.spacer.spacing4} 0 ${Theme.spacer.spacing4}`, + }, + + '& > ul': { + width: '100%', + }, +}); + +export const buttonStyling = css({ + borderRadius: '40px', + boxShadow: Theme.boxShadow.shadow8, +}); diff --git a/frontend/src/pages/TripPage/TripPage.tsx b/frontend/src/pages/TripPage/TripPage.tsx index afda548a3..3db990c4b 100644 --- a/frontend/src/pages/TripPage/TripPage.tsx +++ b/frontend/src/pages/TripPage/TripPage.tsx @@ -1,8 +1,6 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; - import { Flex, useSelect } from 'hang-log-design-system'; import { containerStyling, mapContainerStyling } from '@pages/TripPage/TripPage.style'; @@ -14,15 +12,11 @@ import TripMap from '@components/common/TripMap/TripMap'; import { useTripQuery } from '@hooks/api/useTripQuery'; -import { mediaQueryMobileState } from '@store/mediaQuery'; - const TripPage = () => { const { tripId } = useParams(); if (!tripId) throw new Error('존재하지 않는 tripId 입니다.'); - const isMobile = useRecoilValue(mediaQueryMobileState); - const { tripData } = useTripQuery(Number(tripId)); const { selected: selectedDayLogId, handleSelectClick: handleDayLogIdSelectClick } = useSelect( @@ -45,7 +39,7 @@ const TripPage = () => { return (
    - + { onTabChange={handleDayLogIdSelectClick} />
    - {!isMobile && ( -
    - - - -
    - )} +
    + + + +
    ); }; diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 400074513..d32eadfec 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -5,28 +5,22 @@ import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; -import ExpensePage from '@pages/ExpensePage/ExpensePage'; import ExpensePageSkeleton from '@pages/ExpensePage/ExpensePageSkeleton'; -import IntroPage from '@pages/IntroPage/IntroPage'; -import LogInPage from '@pages/LogInPage/LogInPage'; -import MyPage from '@pages/MyPage/MyPage'; import NotFoundPage from '@pages/NotFoundPage/NotFoundPage'; import RedirectPage from '@pages/RedirectPage/RedirectPage'; -import SharedPage from '@pages/SharedPage/SharedPage'; -import SignUpPage from '@pages/SignUpPage/SignUpPage'; -import TripCreatePage from '@pages/TripCreatePage/TripCreatePage'; -import TripEditPage from '@pages/TripEditPage/TripEditPage'; -import TripPage from '@pages/TripPage/TripPage'; import TripPageSkeleton from '@pages/TripPage/TripPageSkeleton'; -import TripsPage from '@pages/TripsPage/TripsPage'; import TripsPageSkeleton from '@pages/TripsPage/TripsPageSkeleton'; import { isLoggedInState } from '@store/auth'; +import { mediaQueryMobileState } from '@store/mediaQuery'; + +import * as Lazy from '@router/lazy'; import { PATH } from '@constants/path'; const AppRouter = () => { const isLoggedIn = useRecoilValue(isLoggedInState); + const isMobile = useRecoilValue(mediaQueryMobileState); const router = createBrowserRouter([ { @@ -38,17 +32,17 @@ const AppRouter = () => { path: '', element: isLoggedIn ? ( }> - + ) : ( - + ), }, { path: PATH.TRIP(':tripId'), element: ( }> - + {isMobile ? : } ), }, @@ -56,19 +50,23 @@ const AppRouter = () => { path: PATH.EDIT_TRIP(':tripId'), element: ( }> - + ), }, { path: PATH.CREATE_TRIP, - element: , + element: ( + + + + ), }, { path: PATH.EXPENSE(':tripId'), element: ( }> - + ), }, @@ -76,27 +74,35 @@ const AppRouter = () => { path: PATH.REDIRECT, element: , }, - { - path: PATH.SIGN_UP, - element: , - }, { path: PATH.LOGIN, - element: , + element: ( + + + + ), }, { path: PATH.MY_PAGE, element: ( LOADING}> - + ), }, { - path: PATH.SHARE(':code'), + path: PATH.SHARE_TRIP(':code'), element: ( }> - + {isMobile ? : } + + ), + }, + { + path: PATH.SHARE_EXPENSE(':tripId'), + element: ( + }> + ), }, diff --git a/frontend/src/router/lazy.ts b/frontend/src/router/lazy.ts new file mode 100644 index 000000000..7ee976e89 --- /dev/null +++ b/frontend/src/router/lazy.ts @@ -0,0 +1,39 @@ +import { lazy } from 'react'; + +export const ExpensePage = lazy( + () => import(/* webpackChunkName: "ExpensePage" */ '@pages/ExpensePage/ExpensePage') +); + +export const IntroPage = lazy( + () => import(/* webpackChunkName: "IntroPage" */ '@pages/IntroPage/IntroPage') +); + +export const LogInPage = lazy( + () => import(/* webpackChunkName: "LogInPage" */ '@pages/LogInPage/LogInPage') +); + +export const MyPage = lazy(() => import(/* webpackChunkName: "MyPage" */ '@pages/MyPage/MyPage')); + +export const SharedTripPage = lazy( + () => import(/* webpackChunkName: "SharedPage" */ '@/pages/SharedPage/SharedTripPage') +); + +export const TripCreatePage = lazy( + () => import(/* webpackChunkName: "TripCreatePage" */ '@pages/TripCreatePage/TripCreatePage') +); + +export const TripEditPage = lazy( + () => import(/* webpackChunkName: "TripEditPage" */ '@pages/TripEditPage/TripEditPage') +); + +export const TripPage = lazy( + () => import(/* webpackChunkName: "TripPage" */ '@pages/TripPage/TripPage') +); + +export const TripMobilePage = lazy( + () => import(/* webpackChunkName: "TripPage" */ '@pages/TripPage/TripMobilePage') +); + +export const TripsPage = lazy( + () => import(/* webpackChunkName: "TripsPage" */ '@pages/TripsPage/TripsPage') +); diff --git a/frontend/src/store/mediaQuery.ts b/frontend/src/store/mediaQuery.ts index 5a4b46cba..e54b02d77 100644 --- a/frontend/src/store/mediaQuery.ts +++ b/frontend/src/store/mediaQuery.ts @@ -9,3 +9,8 @@ export const viewportWidthState = atom({ key: 'viewportWidth', default: 0, }); + +export const viewportHeightState = atom({ + key: 'viewportHeight', + default: 0, +}); diff --git a/frontend/src/stories/common/TripInformation.stories.tsx b/frontend/src/stories/common/TripInformation.stories.tsx index 9e2b2e79c..e6666adb0 100644 --- a/frontend/src/stories/common/TripInformation.stories.tsx +++ b/frontend/src/stories/common/TripInformation.stories.tsx @@ -2,12 +2,19 @@ import type { Meta, StoryObj } from '@storybook/react'; import TripInformation from '@components/common/TripInformation/TripInformation'; -import { trip } from '@mocks/data/trip'; +import { useTripQuery } from '@hooks/api/useTripQuery'; const meta = { title: 'common/TripInformation', component: TripInformation, - args: { ...trip }, + args: { tripId: 1 }, + decorators: [ + (Story) => { + useTripQuery(1); + + return ; + }, + ], } satisfies Meta; export default meta; diff --git a/frontend/src/stories/expense/ExpenseInformation.stories.tsx b/frontend/src/stories/expense/ExpenseInformation.stories.tsx index 746d35d91..d6a482387 100644 --- a/frontend/src/stories/expense/ExpenseInformation.stories.tsx +++ b/frontend/src/stories/expense/ExpenseInformation.stories.tsx @@ -2,18 +2,22 @@ import type { Meta, StoryObj } from '@storybook/react'; import ExpenseInformation from '@components/expense/ExpenseInformation/ExpenseInformation'; -import { expense } from '@mocks/data/expense'; +import { useExpenseQuery } from '@hooks/api/useExpenseQuery'; const meta = { title: 'expense/ExpenseInformation', component: ExpenseInformation, args: { tripId: 1, - title: expense.title, - startDate: expense.startDate, - endDate: expense.endDate, - cities: expense.cities, + isShared: false, }, + decorators: [ + (Story) => { + useExpenseQuery(1); + + return ; + }, + ], } satisfies Meta; export default meta; diff --git a/frontend/src/stories/expense/TotalExpenseSection.stories.tsx b/frontend/src/stories/expense/TotalExpenseSection.stories.tsx index d4fdbf540..2a8e43323 100644 --- a/frontend/src/stories/expense/TotalExpenseSection.stories.tsx +++ b/frontend/src/stories/expense/TotalExpenseSection.stories.tsx @@ -12,6 +12,7 @@ const meta = { }, args: { tripId: 1, + isShared: false, }, decorators: [ (Story) => { diff --git a/frontend/src/stories/trip/StarRatingInput.stories.tsx b/frontend/src/stories/trip/StarRatingInput.stories.tsx index 750ae01ec..886e1bbec 100644 --- a/frontend/src/stories/trip/StarRatingInput.stories.tsx +++ b/frontend/src/stories/trip/StarRatingInput.stories.tsx @@ -11,6 +11,7 @@ const meta = { component: StarRatingInput, args: { rating: 0, + isMobile: false, }, } satisfies Meta; @@ -18,7 +19,7 @@ export default meta; type Story = StoryObj; export const Default: Story = { - render: () => { + render: ({ ...args }) => { const [value, setValue] = useState(0); const updateInputValue = ( @@ -28,6 +29,12 @@ export const Default: Story = { setValue(value as StarRatingData); }; - return ; + return ( + + ); }, }; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 034f18491..150747da7 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -3,7 +3,8 @@ "outDir": "./dist", "target": "ES2015", "skipLibCheck": true, - "module": "commonjs", + "module": "esnext", + "moduleResolution": "node", "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js deleted file mode 100644 index 72a8ab856..000000000 --- a/frontend/webpack.config.js +++ /dev/null @@ -1,93 +0,0 @@ -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const Dotenv = require('dotenv-webpack'); -const path = require('path'); -const webpack = require('webpack'); - -const prod = process.env.NODE_ENV === 'production'; - -const plugins = [ - new webpack.ProvidePlugin({ - React: 'react', - }), - new HtmlWebpackPlugin({ - template: './public/index.html', - favicon: './public/favicon.ico', - }), - new webpack.HotModuleReplacementPlugin(), - new Dotenv(), -]; - -if (!prod) { - const CopyPlugin = require('copy-webpack-plugin'); - plugins.push( - new CopyPlugin({ - patterns: [{ from: 'public/mockServiceWorker.js', to: '' }], - }) - ); -} - -module.exports = { - mode: prod ? 'production' : 'development', - devtool: prod ? 'hidden-source-map' : 'eval', - entry: './src/index.tsx', - resolve: { - extensions: ['.js', '.jsx', '.ts', '.tsx'], - }, - module: { - rules: [ - { - test: /\.(js|jsx|ts|tsx)$/, - exclude: /node_modules/, - use: ['ts-loader'], - }, - { - test: /\.svg$/i, - issuer: /\.[jt]sx?$/, - use: ['@svgr/webpack'], - }, - { - test: /\.svg$/i, - issuer: /\.(style.js|style.ts)$/, - use: ['url-loader'], - }, - { - test: /\.png$/i, - issuer: /\.[jt]sx?$/, - use: ['url-loader'], - }, - ], - }, - output: { - path: path.join(__dirname, '/dist'), - filename: 'bundle.js', - publicPath: '/', - }, - - devServer: { - historyApiFallback: true, - port: 3000, - hot: true, - static: path.resolve(__dirname, 'dist'), - }, - - resolve: { - extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'], - alias: { - '@': path.resolve(__dirname, './src'), - '@components': path.resolve(__dirname, './src/components'), - '@type': path.resolve(__dirname, './src/types'), - '@hooks': path.resolve(__dirname, './src/hooks'), - '@pages': path.resolve(__dirname, './src/pages'), - '@styles': path.resolve(__dirname, './src/styles'), - '@constants': path.resolve(__dirname, './src/constants'), - '@assets': path.resolve(__dirname, './src/assets'), - '@api': path.resolve(__dirname, './src/api'), - '@mocks': path.resolve(__dirname, './src/mocks'), - '@stories': path.resolve(__dirname, './src/stories'), - '@router': path.resolve(__dirname, './src/router'), - '@store': path.resolve(__dirname, './src/store'), - '@utils': path.resolve(__dirname, './src/utils'), - }, - }, - plugins, -};