diff --git a/backend/build.gradle b/backend/build.gradle index b1c5409d3..277b3e737 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -35,6 +35,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'org.springframework.security:spring-security-crypto' + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/backend/src/main/java/kr/momo/config/DataSourceConfig.java b/backend/src/main/java/kr/momo/config/DataSourceConfig.java new file mode 100644 index 000000000..5bd61fe39 --- /dev/null +++ b/backend/src/main/java/kr/momo/config/DataSourceConfig.java @@ -0,0 +1,56 @@ +package kr.momo.config; + +import java.util.Map; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; + +@Configuration +@Profile("prod") +public class DataSourceConfig { + + public static final String SOURCE_SERVER = "source"; + public static final String REPLICA_SERVER = "replica"; + + @Bean(SOURCE_SERVER) + @ConfigurationProperties(prefix = "spring.datasource.source") + public DataSource sourceDataSource() { + return DataSourceBuilder.create() + .build(); + } + + @Bean(REPLICA_SERVER) + @ConfigurationProperties(prefix = "spring.datasource.replica") + public DataSource replicaDataSource() { + return DataSourceBuilder.create() + .build(); + } + + @Bean + public DataSource routingDataSource( + @Qualifier(SOURCE_SERVER) DataSource source, + @Qualifier(REPLICA_SERVER) DataSource replica + ) { + RoutingDataSource routingDataSource = new RoutingDataSource(); + Map dataSourceMap = Map.of( + SOURCE_SERVER, source, + REPLICA_SERVER, replica + ); + routingDataSource.setTargetDataSources(dataSourceMap); + routingDataSource.setDefaultTargetDataSource(source); + return routingDataSource; + } + + @Bean + @Primary + public DataSource dataSource() { + DataSource dataSource = routingDataSource(sourceDataSource(), replicaDataSource()); + return new LazyConnectionDataSourceProxy(dataSource); + } +} diff --git a/backend/src/main/java/kr/momo/config/PasswordEncoderConfig.java b/backend/src/main/java/kr/momo/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..deddb540a --- /dev/null +++ b/backend/src/main/java/kr/momo/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package kr.momo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); + } +} diff --git a/backend/src/main/java/kr/momo/config/RoutingDataSource.java b/backend/src/main/java/kr/momo/config/RoutingDataSource.java new file mode 100644 index 000000000..27952faa7 --- /dev/null +++ b/backend/src/main/java/kr/momo/config/RoutingDataSource.java @@ -0,0 +1,15 @@ +package kr.momo.config; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +public class RoutingDataSource extends AbstractRoutingDataSource { + + @Override + protected Object determineCurrentLookupKey() { + if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + return DataSourceConfig.REPLICA_SERVER; + } + return DataSourceConfig.SOURCE_SERVER; + } +} diff --git a/backend/src/main/java/kr/momo/controller/attendee/AttendeeController.java b/backend/src/main/java/kr/momo/controller/attendee/AttendeeController.java index ae76baf74..e7a496ea2 100644 --- a/backend/src/main/java/kr/momo/controller/attendee/AttendeeController.java +++ b/backend/src/main/java/kr/momo/controller/attendee/AttendeeController.java @@ -50,4 +50,12 @@ public ResponseEntity logout(@PathVariable String uuid) { public MomoApiResponse> findAttendeesOfMeeting(@PathVariable String uuid) { return new MomoApiResponse<>(attendeeService.findAll(uuid)); } + + /** + * TEMP: 비밀번호 마이그레이션 이후 삭제될 메서드입니다. + */ + @PostMapping("/api/v1/attendee/update-password") + public MomoApiResponse updatePassword() { + return new MomoApiResponse<>(attendeeService.updateAllPassword()); + } } diff --git a/backend/src/main/java/kr/momo/controller/meeting/MeetingControllerDocs.java b/backend/src/main/java/kr/momo/controller/meeting/MeetingControllerDocs.java index 7f6a456c6..498eaa96f 100644 --- a/backend/src/main/java/kr/momo/controller/meeting/MeetingControllerDocs.java +++ b/backend/src/main/java/kr/momo/controller/meeting/MeetingControllerDocs.java @@ -27,6 +27,7 @@ public interface MeetingControllerDocs { @Operation(summary = "약속 생성", description = "주최자가 약속을 생성하는 API 입니다.") @ApiSuccessResponse.Created("약속 생성 성공") @ApiErrorResponse.BadRequest(ERROR_CODE_TABLE_HEADER + """ + | INVALID_FORMAT_REQUEST | 유효하지 않은 요청입니다 | | INVALID_NAME_LENGTH | 이름 길이는 1자 이상 5자 이하 까지 가능합니다. | | INVALID_PASSWORD_LENGTH | 비밀번호 길이는 1자 이상 10자 이하 까지 가능합니다. | | PAST_NOT_PERMITTED | 과거 날짜로는 약속을 생성할 수 없습니다. | diff --git a/backend/src/main/java/kr/momo/controller/schedule/ScheduleController.java b/backend/src/main/java/kr/momo/controller/schedule/ScheduleController.java index d92f02531..e7cb0f34f 100644 --- a/backend/src/main/java/kr/momo/controller/schedule/ScheduleController.java +++ b/backend/src/main/java/kr/momo/controller/schedule/ScheduleController.java @@ -6,7 +6,7 @@ import kr.momo.controller.auth.AuthAttendee; import kr.momo.service.schedule.ScheduleService; import kr.momo.service.schedule.dto.AttendeeScheduleResponse; -import kr.momo.service.schedule.dto.RecommendedScheduleResponse; +import kr.momo.service.schedule.dto.RecommendedSchedulesResponse; import kr.momo.service.schedule.dto.ScheduleCreateRequest; import kr.momo.service.schedule.dto.SchedulesResponse; import lombok.RequiredArgsConstructor; @@ -51,10 +51,10 @@ public MomoApiResponse findMySchedule(@PathVariable St } @GetMapping("/api/v1/meetings/{uuid}/recommended-schedules") - public MomoApiResponse> recommendSchedules( + public MomoApiResponse recommendSchedules( @PathVariable String uuid, @RequestParam String recommendType, @RequestParam List attendeeNames ) { - List response = scheduleService.recommendSchedules( + RecommendedSchedulesResponse response = scheduleService.recommendSchedules( uuid, recommendType, attendeeNames ); return new MomoApiResponse<>(response); diff --git a/backend/src/main/java/kr/momo/controller/schedule/ScheduleControllerDocs.java b/backend/src/main/java/kr/momo/controller/schedule/ScheduleControllerDocs.java index 1734f794f..ec10b80aa 100644 --- a/backend/src/main/java/kr/momo/controller/schedule/ScheduleControllerDocs.java +++ b/backend/src/main/java/kr/momo/controller/schedule/ScheduleControllerDocs.java @@ -13,7 +13,7 @@ import kr.momo.controller.annotation.ApiSuccessResponse; import kr.momo.controller.auth.AuthAttendee; import kr.momo.service.schedule.dto.AttendeeScheduleResponse; -import kr.momo.service.schedule.dto.RecommendedScheduleResponse; +import kr.momo.service.schedule.dto.RecommendedSchedulesResponse; import kr.momo.service.schedule.dto.ScheduleCreateRequest; import kr.momo.service.schedule.dto.SchedulesResponse; import org.springframework.web.bind.annotation.PathVariable; @@ -90,13 +90,13 @@ MomoApiResponse findMySchedule( 추천 기준에 따라 이른 시간 순 혹은 길게 볼 수 있는 순으로 추천합니다. - earliest: 이른 시간 순 - longTerm: 길게 볼 수 있는 순 - + 추천 연산에 사용할 참여자 이름을 명시하여 필터링할 수 있습니다.
약속 내의 모든 참여자가 전달된 경우 일부 참여자들이 참여할 수 있는 일정을 함께 추천하며,
이외의 경우 전달된 참여자들이 모두 참여할 수 있는 일정이 추천됩니다. """) @ApiSuccessResponse.Ok("추천 일정 조회 성공") - MomoApiResponse> recommendSchedules( + MomoApiResponse recommendSchedules( @PathVariable @Schema(description = "약속 UUID") String uuid, @RequestParam @Schema(description = "추천 기준(이른 시간 순 / 길게 볼 수 있는 순)", example = "earliest") String recommendType, diff --git a/backend/src/main/java/kr/momo/domain/attendee/Attendee.java b/backend/src/main/java/kr/momo/domain/attendee/Attendee.java index 6f7025826..b7ba78b7a 100644 --- a/backend/src/main/java/kr/momo/domain/attendee/Attendee.java +++ b/backend/src/main/java/kr/momo/domain/attendee/Attendee.java @@ -19,6 +19,7 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; @Table(name = "attendee") @Entity @@ -46,6 +47,10 @@ public class Attendee extends BaseEntity { @Column(nullable = false, length = 10) private Role role; + public Attendee(Meeting meeting, String name, AttendeePassword password, Role role) { + this(meeting, new AttendeeName(name), password, role); + } + public Attendee(Meeting meeting, AttendeeName name, AttendeePassword password, Role role) { this.meeting = meeting; this.name = name; @@ -53,10 +58,6 @@ public Attendee(Meeting meeting, AttendeeName name, AttendeePassword password, R this.role = role; } - public Attendee(Meeting meeting, String name, String password, Role role) { - this(meeting, new AttendeeName(name), new AttendeePassword(password), role); - } - public boolean isHost() { return role.isHost(); } @@ -65,15 +66,15 @@ public boolean isNotHost() { return !isHost(); } - public void verifyPassword(AttendeePassword other) { - this.password.verifyPassword(other); + public void updatePassword(String password) { + this.password = new AttendeePassword(password); } - public String name() { - return this.name.getName(); + public void verifyPassword(AttendeeRawPassword rawPassword, PasswordEncoder passwordEncoder) { + password.verifyMatch(rawPassword, passwordEncoder); } - public String password() { - return this.password.getPassword(); + public String name() { + return name.getName(); } } diff --git a/backend/src/main/java/kr/momo/domain/attendee/AttendeePassword.java b/backend/src/main/java/kr/momo/domain/attendee/AttendeePassword.java index 066cdfc16..e9005710b 100644 --- a/backend/src/main/java/kr/momo/domain/attendee/AttendeePassword.java +++ b/backend/src/main/java/kr/momo/domain/attendee/AttendeePassword.java @@ -2,49 +2,27 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; -import java.util.regex.Pattern; import kr.momo.exception.MomoException; import kr.momo.exception.code.AttendeeErrorCode; import lombok.AccessLevel; -import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; @Embeddable @Getter -@EqualsAndHashCode @NoArgsConstructor(access = AccessLevel.PROTECTED) public class AttendeePassword { - private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@*#$%]+$"); - - @Column(nullable = false, length = 10) + @Column(nullable = false) private String password; public AttendeePassword(String password) { - validatePassword(password); this.password = password; } - private void validatePassword(String password) { - validatePasswordLength(password); - validatePasswordFormat(password); - } - - private void validatePasswordLength(String password) { - if (password.length() > 10) { - throw new MomoException(AttendeeErrorCode.INVALID_PASSWORD_LENGTH); - } - } - - private void validatePasswordFormat(String password) { - if (!PASSWORD_PATTERN.matcher(password).matches()) { - throw new MomoException(AttendeeErrorCode.INVALID_PASSWORD_FORMAT); - } - } - - public void verifyPassword(AttendeePassword other) { - if (!this.equals(other)) { + public void verifyMatch(AttendeeRawPassword rawPassword, PasswordEncoder passwordEncoder) { + if (!passwordEncoder.matches(rawPassword.password(), password)) { throw new MomoException(AttendeeErrorCode.PASSWORD_MISMATCHED); } } diff --git a/backend/src/main/java/kr/momo/domain/attendee/AttendeeRawPassword.java b/backend/src/main/java/kr/momo/domain/attendee/AttendeeRawPassword.java new file mode 100644 index 000000000..79658f1a1 --- /dev/null +++ b/backend/src/main/java/kr/momo/domain/attendee/AttendeeRawPassword.java @@ -0,0 +1,25 @@ +package kr.momo.domain.attendee; + +import java.util.regex.Pattern; +import kr.momo.exception.MomoException; +import kr.momo.exception.code.AttendeeErrorCode; +import org.springframework.security.crypto.password.PasswordEncoder; + +public record AttendeeRawPassword(String password) { + + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^\\d{4}+$"); + + public AttendeeRawPassword { + validatePassword(password); + } + + private void validatePassword(String password) { + if (password == null || !PASSWORD_PATTERN.matcher(password).matches()) { + throw new MomoException(AttendeeErrorCode.INVALID_PASSWORD_FORMAT); + } + } + + public AttendeePassword encodePassword(PasswordEncoder passwordEncoder) { + return new AttendeePassword(passwordEncoder.encode(password)); + } +} diff --git a/backend/src/main/java/kr/momo/domain/meeting/ConfirmedMeeting.java b/backend/src/main/java/kr/momo/domain/meeting/ConfirmedMeeting.java index 95baf2790..a9ff11499 100644 --- a/backend/src/main/java/kr/momo/domain/meeting/ConfirmedMeeting.java +++ b/backend/src/main/java/kr/momo/domain/meeting/ConfirmedMeeting.java @@ -13,6 +13,7 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.time.Duration; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -65,11 +66,20 @@ public AttendeeGroup availableAttendeesOf(List schedules) { } private boolean isScheduleWithinDateTimeRange(Schedule schedule) { + if (meeting.isDaysOnly()) { + LocalDate date = schedule.date(); + LocalDate startDate = startDateTime.toLocalDate(); + LocalDate endDate = endDateTime.toLocalDate(); + return !date.isBefore(startDate) && !date.isAfter(endDate); + } LocalDateTime dateTime = schedule.dateTime(); return !dateTime.isBefore(startDateTime) && dateTime.isBefore(endDateTime); } private long countTimeSlotOfConfirmedMeeting() { + if (meeting.isDaysOnly()) { + return Duration.between(startDateTime, endDateTime).plusDays(1).toDays(); + } return Duration.between(startDateTime, endDateTime).dividedBy(SECOND_OF_HALF_HOUR).getSeconds(); } } diff --git a/backend/src/main/java/kr/momo/domain/meeting/Meeting.java b/backend/src/main/java/kr/momo/domain/meeting/Meeting.java index 8d6b9b1dc..c2add2ae2 100644 --- a/backend/src/main/java/kr/momo/domain/meeting/Meeting.java +++ b/backend/src/main/java/kr/momo/domain/meeting/Meeting.java @@ -3,6 +3,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -34,14 +36,23 @@ public class Meeting extends BaseEntity { @Column(nullable = false) private boolean isLocked; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private MeetingType type; + @Embedded private TimeslotInterval timeslotInterval; - public Meeting(String name, String uuid, LocalTime firstTime, LocalTime lastTime) { + public Meeting(String name, String uuid, LocalTime firstTime, LocalTime lastTime, MeetingType type) { this.name = name; this.uuid = uuid; - this.timeslotInterval = new TimeslotInterval(firstTime, lastTime.minusMinutes(30)); this.isLocked = false; + this.type = type; + this.timeslotInterval = new TimeslotInterval(firstTime, lastTime.minusMinutes(30)); + } + + public Meeting(String name, String uuid, LocalTime firstTime, LocalTime lastTime) { + this(name, uuid, firstTime, lastTime, MeetingType.DATETIME); } public void lock() { @@ -60,6 +71,10 @@ public boolean isNotFullTime() { return timeslotInterval.isNotFullTime(); } + public boolean isDaysOnly() { + return type.isDaysOnly(); + } + public Timeslot getValidatedTimeslot(LocalTime other) { return timeslotInterval.getValidatedTimeslot(other); } diff --git a/backend/src/main/java/kr/momo/domain/meeting/MeetingType.java b/backend/src/main/java/kr/momo/domain/meeting/MeetingType.java new file mode 100644 index 000000000..d3554bab1 --- /dev/null +++ b/backend/src/main/java/kr/momo/domain/meeting/MeetingType.java @@ -0,0 +1,24 @@ +package kr.momo.domain.meeting; + +import com.fasterxml.jackson.annotation.JsonCreator; +import kr.momo.exception.MomoException; +import kr.momo.exception.code.MeetingErrorCode; + +public enum MeetingType { + + DAYSONLY, + DATETIME; + + public boolean isDaysOnly() { + return this.equals(DAYSONLY); + } + + @JsonCreator + public static MeetingType from(String type) { + try { + return MeetingType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException | NullPointerException e) { + throw new MomoException(MeetingErrorCode.INVALID_TYPE); + } + } +} diff --git a/backend/src/main/java/kr/momo/domain/schedule/DateInterval.java b/backend/src/main/java/kr/momo/domain/schedule/DateInterval.java new file mode 100644 index 000000000..3952a7a0d --- /dev/null +++ b/backend/src/main/java/kr/momo/domain/schedule/DateInterval.java @@ -0,0 +1,50 @@ +package kr.momo.domain.schedule; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; + +public record DateInterval( + LocalDate startDate, + LocalDate endDate +) implements RecommendInterval { + + @Override + public boolean isSequential(RecommendInterval nextInterval) { + LocalDate nextStartDate = nextInterval.startDateTime().toLocalDate(); + return endDate.plusDays(1).equals(nextStartDate); + } + + @Override + public Duration duration() { + return Duration.between(startDate, endDate); + } + + @Override + public LocalDateTime startDateTime() { + return startDate.atStartOfDay(); + } + + @Override + public LocalDateTime endDateTime() { + return endDate.atStartOfDay(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DateInterval that = (DateInterval) o; + return Objects.equals(startDate, that.startDate) && Objects.equals(endDate, that.endDate); + } + + @Override + public int hashCode() { + return Objects.hash(startDate, endDate); + } +} diff --git a/backend/src/main/java/kr/momo/domain/schedule/DateTimeInterval.java b/backend/src/main/java/kr/momo/domain/schedule/DateTimeInterval.java index de99dca01..ab8e92a36 100644 --- a/backend/src/main/java/kr/momo/domain/schedule/DateTimeInterval.java +++ b/backend/src/main/java/kr/momo/domain/schedule/DateTimeInterval.java @@ -3,12 +3,17 @@ import java.time.Duration; import java.time.LocalDateTime; -public record DateTimeInterval(LocalDateTime startDateTime, LocalDateTime endDateTime) { +public record DateTimeInterval( + LocalDateTime startDateTime, + LocalDateTime endDateTime +) implements RecommendInterval { - public boolean isSequential(DateTimeInterval nextInterval) { - return endDateTime.equals(nextInterval.startDateTime); + @Override + public boolean isSequential(RecommendInterval nextInterval) { + return endDateTime.equals(nextInterval.startDateTime()); } + @Override public Duration duration() { return Duration.between(startDateTime, endDateTime); } diff --git a/backend/src/main/java/kr/momo/domain/schedule/RecommendInterval.java b/backend/src/main/java/kr/momo/domain/schedule/RecommendInterval.java new file mode 100644 index 000000000..8c523edbc --- /dev/null +++ b/backend/src/main/java/kr/momo/domain/schedule/RecommendInterval.java @@ -0,0 +1,15 @@ +package kr.momo.domain.schedule; + +import java.time.Duration; +import java.time.LocalDateTime; + +public interface RecommendInterval { + + boolean isSequential(RecommendInterval nextInterval); + + Duration duration(); + + LocalDateTime startDateTime(); + + LocalDateTime endDateTime(); +} diff --git a/backend/src/main/java/kr/momo/domain/schedule/Schedule.java b/backend/src/main/java/kr/momo/domain/schedule/Schedule.java index 854f98993..c2ac06fce 100644 --- a/backend/src/main/java/kr/momo/domain/schedule/Schedule.java +++ b/backend/src/main/java/kr/momo/domain/schedule/Schedule.java @@ -17,6 +17,7 @@ import kr.momo.domain.BaseEntity; import kr.momo.domain.attendee.Attendee; import kr.momo.domain.availabledate.AvailableDate; +import kr.momo.domain.meeting.MeetingType; import kr.momo.domain.timeslot.Timeslot; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -52,10 +53,19 @@ public Schedule(Attendee attendee, AvailableDate availableDate, Timeslot timeslo this.timeslot = timeslot; } - public DateTimeInterval dateTimeInterval() { + public RecommendInterval recommendInterval(MeetingType type) { + return type.isDaysOnly() ? dateInterval() : dateTimeInterval(); + } + + private DateTimeInterval dateTimeInterval() { return new DateTimeInterval(dateTime(), dateTime().plusMinutes(Timeslot.DURATION_IN_MINUTE)); } + private DateInterval dateInterval() { + LocalDate date = availableDate.getDate(); + return new DateInterval(date, date); + } + public LocalDateTime dateTime() { return LocalDateTime.of(availableDate.getDate(), timeslot.startTime()); } diff --git a/backend/src/main/java/kr/momo/domain/schedule/recommend/CandidateSchedule.java b/backend/src/main/java/kr/momo/domain/schedule/recommend/CandidateSchedule.java index bb7a37e4d..7ab8f1b75 100644 --- a/backend/src/main/java/kr/momo/domain/schedule/recommend/CandidateSchedule.java +++ b/backend/src/main/java/kr/momo/domain/schedule/recommend/CandidateSchedule.java @@ -8,9 +8,10 @@ import java.util.stream.Stream; import kr.momo.domain.attendee.AttendeeGroup; import kr.momo.domain.schedule.DateTimeInterval; +import kr.momo.domain.schedule.RecommendInterval; public record CandidateSchedule( - DateTimeInterval dateTimeInterval, AttendeeGroup attendeeGroup + RecommendInterval dateTimeInterval, AttendeeGroup attendeeGroup ) { public static CandidateSchedule of( diff --git a/backend/src/main/java/kr/momo/exception/GlobalExceptionHandler.java b/backend/src/main/java/kr/momo/exception/GlobalExceptionHandler.java index 2ee59d6d5..2a88a1f89 100644 --- a/backend/src/main/java/kr/momo/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/kr/momo/exception/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -38,6 +39,17 @@ public ProblemDetail handleMethodArgumentNotValidException(MethodArgumentNotVali return new CustomProblemDetail(problemDetail, INVALID_REQUEST_FORMAT_ERROR_CODE, ex); } + @ExceptionHandler + public ProblemDetail handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + String traceId = MDC.get(LoggingInterceptor.TRACE_ID); + log.warn(EXCEPTION_LOG_FORMAT, traceId, ex); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, INVALID_REQUEST_FORMAT_MESSAGE + ); + return new CustomProblemDetail(problemDetail, INVALID_REQUEST_FORMAT_ERROR_CODE); + } + @ExceptionHandler public ProblemDetail handleInternalException(Exception ex) { String traceId = MDC.get(LoggingInterceptor.TRACE_ID); diff --git a/backend/src/main/java/kr/momo/exception/code/AttendeeErrorCode.java b/backend/src/main/java/kr/momo/exception/code/AttendeeErrorCode.java index afe20246c..eadf1991b 100644 --- a/backend/src/main/java/kr/momo/exception/code/AttendeeErrorCode.java +++ b/backend/src/main/java/kr/momo/exception/code/AttendeeErrorCode.java @@ -5,8 +5,7 @@ public enum AttendeeErrorCode implements ErrorCodeType { INVALID_NAME_LENGTH(HttpStatus.BAD_REQUEST, "이름 길이는 최대 5글자까지 가능합니다."), - INVALID_PASSWORD_LENGTH(HttpStatus.BAD_REQUEST, "비밀번호 길이는 최대 10글자까지 가능합니다."), - INVALID_PASSWORD_FORMAT(HttpStatus.BAD_REQUEST, "비밀번호 형식은 숫자, 문자, 특수문자만 가능합니다."), + INVALID_PASSWORD_FORMAT(HttpStatus.BAD_REQUEST, "비밀번호 형식은 4자리 숫자입니다."), PASSWORD_MISMATCHED(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), INVALID_ATTENDEE(HttpStatus.BAD_REQUEST, "해당 약속에 참여하는 참가자 정보가 없습니다."), NOT_FOUND_ATTENDEE(HttpStatus.NOT_FOUND, "해당 약속에 참여하는 참가자 정보가 없습니다."), diff --git a/backend/src/main/java/kr/momo/exception/code/MeetingErrorCode.java b/backend/src/main/java/kr/momo/exception/code/MeetingErrorCode.java index fd38d4578..db63230b3 100644 --- a/backend/src/main/java/kr/momo/exception/code/MeetingErrorCode.java +++ b/backend/src/main/java/kr/momo/exception/code/MeetingErrorCode.java @@ -14,7 +14,8 @@ public enum MeetingErrorCode implements ErrorCodeType { PAST_NOT_PERMITTED(HttpStatus.BAD_REQUEST, "과거 날짜로는 약속을 생성할 수 없습니다."), NOT_CONFIRMED(HttpStatus.NOT_FOUND, "아직 확정되지 않은 약속입니다."), UUID_GENERATION_FAILURE(HttpStatus.INTERNAL_SERVER_ERROR, "약속 생성 과정 중 키 생성에 실패했습니다. 잠시 후 다시 시도해주세요."), - MEETING_LOAD_FAILURE(HttpStatus.INTERNAL_SERVER_ERROR, "약속 정보를 불러오는데 실패했습니다. 약속을 다시 생성해주세요."); + MEETING_LOAD_FAILURE(HttpStatus.INTERNAL_SERVER_ERROR, "약속 정보를 불러오는데 실패했습니다. 약속을 다시 생성해주세요."), + INVALID_TYPE(HttpStatus.BAD_REQUEST, "유효하지 않은 약속 유형입니다."); private final HttpStatus httpStatus; private final String message; diff --git a/backend/src/main/java/kr/momo/service/attendee/AttendeeService.java b/backend/src/main/java/kr/momo/service/attendee/AttendeeService.java index 56aa4894a..08a7d815b 100644 --- a/backend/src/main/java/kr/momo/service/attendee/AttendeeService.java +++ b/backend/src/main/java/kr/momo/service/attendee/AttendeeService.java @@ -4,6 +4,7 @@ import kr.momo.domain.attendee.Attendee; import kr.momo.domain.attendee.AttendeeName; import kr.momo.domain.attendee.AttendeePassword; +import kr.momo.domain.attendee.AttendeeRawPassword; import kr.momo.domain.attendee.AttendeeRepository; import kr.momo.domain.attendee.Role; import kr.momo.domain.meeting.Meeting; @@ -14,6 +15,7 @@ import kr.momo.service.attendee.dto.AttendeeLoginResponse; import kr.momo.service.auth.JwtManager; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +26,7 @@ public class AttendeeService { private final AttendeeRepository attendeeRepository; private final MeetingRepository meetingRepository; private final JwtManager jwtManager; + private final PasswordEncoder passwordEncoder; @Transactional public AttendeeLoginResponse login(String uuid, AttendeeLoginRequest request) { @@ -31,24 +34,26 @@ public AttendeeLoginResponse login(String uuid, AttendeeLoginRequest request) { .orElseThrow(() -> new MomoException(MeetingErrorCode.INVALID_UUID)); AttendeeName name = new AttendeeName(request.attendeeName()); - AttendeePassword password = new AttendeePassword(request.password()); + AttendeeRawPassword rawPassword = new AttendeeRawPassword(request.password()); return attendeeRepository.findByMeetingAndName(meeting, name) - .map(attendee -> verifyPassword(attendee, password)) - .orElseGet(() -> signup(meeting, name, password)); + .map(attendee -> verifyPassword(attendee, rawPassword)) + .orElseGet(() -> signup(meeting, name, rawPassword)); } - private AttendeeLoginResponse verifyPassword(Attendee attendee, AttendeePassword password) { - attendee.verifyPassword(password); + private AttendeeLoginResponse verifyPassword(Attendee attendee, AttendeeRawPassword rawPassword) { + attendee.verifyPassword(rawPassword, passwordEncoder); return AttendeeLoginResponse.from(jwtManager.generate(attendee.getId()), attendee); } - private AttendeeLoginResponse signup(Meeting meeting, AttendeeName name, AttendeePassword password) { + private AttendeeLoginResponse signup(Meeting meeting, AttendeeName name, AttendeeRawPassword rawPassword) { + AttendeePassword password = rawPassword.encodePassword(passwordEncoder); Attendee attendee = new Attendee(meeting, name, password, Role.GUEST); attendeeRepository.save(attendee); return AttendeeLoginResponse.from(jwtManager.generate(attendee.getId()), attendee); } + @Transactional(readOnly = true) public List findAll(String uuid) { Meeting meeting = meetingRepository.findByUuid(uuid) .orElseThrow(() -> new MomoException(MeetingErrorCode.INVALID_UUID)); @@ -58,4 +63,23 @@ public List findAll(String uuid) { .map(Attendee::name) .toList(); } + + /** + * TEMP: 비밀번호 마이그레이션 이후 삭제될 메서드입니다. + */ + public Integer updateAllPassword() { + List attendees = attendeeRepository.findAll(); + List rawAttendees = attendees.stream() + .filter(attendee -> attendee.getPassword().getPassword().length() < 15) + .toList(); + rawAttendees.forEach( + attendee -> { + String rawPassword = attendee.getPassword().getPassword(); + String encodedPassword = passwordEncoder.encode(rawPassword); + attendee.updatePassword(encodedPassword); + } + ); + attendeeRepository.saveAll(rawAttendees); + return rawAttendees.size(); + } } diff --git a/backend/src/main/java/kr/momo/service/attendee/dto/AttendeeLoginRequest.java b/backend/src/main/java/kr/momo/service/attendee/dto/AttendeeLoginRequest.java index 0ffc839e2..b43bd1eeb 100644 --- a/backend/src/main/java/kr/momo/service/attendee/dto/AttendeeLoginRequest.java +++ b/backend/src/main/java/kr/momo/service/attendee/dto/AttendeeLoginRequest.java @@ -15,8 +15,8 @@ public record AttendeeLoginRequest( @NotEmpty @Schema(description = "참가자 비밀번호", example = "1234") - @Length(max = 10, message = "비밀번호는 10자 이하입니다.") - @Pattern(regexp = "^[a-zA-Z0-9!@*#$%]+$", message = "비밀번호는 알파벳 대소문자와 숫자만 포함해야 합니다.") + @Length(max = 10, message = "비밀번호는 4자리 숫자입니다.") + @Pattern(regexp = "^\\d{4}+$", message = "비밀번호는 4자리 숫자여야 합니다.") String password ) { } diff --git a/backend/src/main/java/kr/momo/service/meeting/MeetingConfirmService.java b/backend/src/main/java/kr/momo/service/meeting/MeetingConfirmService.java index 27c20c529..fc17f9793 100644 --- a/backend/src/main/java/kr/momo/service/meeting/MeetingConfirmService.java +++ b/backend/src/main/java/kr/momo/service/meeting/MeetingConfirmService.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import kr.momo.domain.attendee.Attendee; import kr.momo.domain.attendee.AttendeeGroup; @@ -36,12 +37,16 @@ public class MeetingConfirmService { @Transactional public MeetingConfirmResponse create(String uuid, long attendeeId, MeetingConfirmRequest request) { - LocalDateTime startDateTime = request.toStartDateTime(); - LocalDateTime endDateTime = request.toEndDateTime(); - Meeting meeting = meetingRepository.findByUuid(uuid) .orElseThrow(() -> new MomoException(MeetingErrorCode.INVALID_UUID)); + LocalDateTime startDateTime = request.toStartDateTime(); + LocalDateTime endDateTime = request.toEndDateTime(); + if (meeting.isDaysOnly()) { + startDateTime = LocalDateTime.of(startDateTime.toLocalDate(), LocalTime.MIN); + endDateTime = LocalDateTime.of(endDateTime.toLocalDate(), LocalTime.MIN); + } + Attendee attendee = attendeeRepository.findByIdAndMeeting(attendeeId, meeting) .orElseThrow(() -> new MomoException(AttendeeErrorCode.INVALID_ATTENDEE)); diff --git a/backend/src/main/java/kr/momo/service/meeting/MeetingService.java b/backend/src/main/java/kr/momo/service/meeting/MeetingService.java index 67da08d30..4878ce14b 100644 --- a/backend/src/main/java/kr/momo/service/meeting/MeetingService.java +++ b/backend/src/main/java/kr/momo/service/meeting/MeetingService.java @@ -5,6 +5,7 @@ import java.time.LocalTime; import java.util.List; import kr.momo.domain.attendee.Attendee; +import kr.momo.domain.attendee.AttendeeRawPassword; import kr.momo.domain.attendee.AttendeeRepository; import kr.momo.domain.attendee.Role; import kr.momo.domain.availabledate.AvailableDateBatchRepository; @@ -12,6 +13,7 @@ import kr.momo.domain.availabledate.AvailableDates; import kr.momo.domain.meeting.Meeting; import kr.momo.domain.meeting.MeetingRepository; +import kr.momo.domain.meeting.MeetingType; import kr.momo.domain.meeting.UuidGenerator; import kr.momo.exception.MomoException; import kr.momo.exception.code.AttendeeErrorCode; @@ -22,6 +24,7 @@ import kr.momo.service.meeting.dto.MeetingResponse; import kr.momo.service.meeting.dto.MeetingSharingResponse; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,10 +42,13 @@ public class MeetingService { private final AvailableDateRepository availableDateRepository; private final AttendeeRepository attendeeRepository; private final AvailableDateBatchRepository availableDateBatchRepository; + private final PasswordEncoder passwordEncoder; @Transactional public MeetingCreateResponse create(MeetingCreateRequest request) { - Meeting meeting = saveMeeting(request.meetingName(), request.toMeetingStartTime(), request.toMeetingEndTime()); + Meeting meeting = saveMeeting( + request.meetingName(), request.toMeetingStartTime(), request.toMeetingEndTime(), request.type() + ); AvailableDates meetingDates = new AvailableDates(request.toAvailableMeetingDates(), meeting); validateNotPast(meetingDates); @@ -53,9 +59,9 @@ public MeetingCreateResponse create(MeetingCreateRequest request) { return MeetingCreateResponse.from(meeting, attendee, meetingDates, token); } - private Meeting saveMeeting(String meetingName, LocalTime startTime, LocalTime endTime) { + private Meeting saveMeeting(String meetingName, LocalTime startTime, LocalTime endTime, MeetingType type) { String uuid = generateUniqueUuid(); - Meeting meeting = new Meeting(meetingName, uuid, startTime, endTime); + Meeting meeting = new Meeting(meetingName, uuid, startTime, endTime, type); return meetingRepository.save(meeting); } @@ -82,7 +88,8 @@ private void validateNotPast(AvailableDates meetingDates) { } private Attendee saveHostAttendee(Meeting meeting, String hostName, String hostPassword) { - Attendee attendee = new Attendee(meeting, hostName, hostPassword, Role.HOST); + AttendeeRawPassword rawPassword = new AttendeeRawPassword(hostPassword); + Attendee attendee = new Attendee(meeting, hostName, rawPassword.encodePassword(passwordEncoder), Role.HOST); return attendeeRepository.save(attendee); } diff --git a/backend/src/main/java/kr/momo/service/meeting/dto/ConfirmedMeetingResponse.java b/backend/src/main/java/kr/momo/service/meeting/dto/ConfirmedMeetingResponse.java index 3f641eaa8..abf347c7b 100644 --- a/backend/src/main/java/kr/momo/service/meeting/dto/ConfirmedMeetingResponse.java +++ b/backend/src/main/java/kr/momo/service/meeting/dto/ConfirmedMeetingResponse.java @@ -25,7 +25,8 @@ public record ConfirmedMeetingResponse( LocalDate endDate, @JsonFormat(pattern = "HH:mm", shape = Shape.STRING) LocalTime endTime, - String endDayOfWeek + String endDayOfWeek, + String type ) { public static ConfirmedMeetingResponse from( @@ -40,7 +41,8 @@ public static ConfirmedMeetingResponse from( confirmedMeeting.getStartDateTime().getDayOfWeek().getDisplayName(TextStyle.NARROW, Locale.KOREAN), confirmedMeeting.getEndDateTime().toLocalDate(), confirmedMeeting.getEndDateTime().toLocalTime(), - confirmedMeeting.getEndDateTime().getDayOfWeek().getDisplayName(TextStyle.NARROW, Locale.KOREAN) + confirmedMeeting.getEndDateTime().getDayOfWeek().getDisplayName(TextStyle.NARROW, Locale.KOREAN), + meeting.getType().name() ); } } diff --git a/backend/src/main/java/kr/momo/service/meeting/dto/MeetingCreateRequest.java b/backend/src/main/java/kr/momo/service/meeting/dto/MeetingCreateRequest.java index 92af0876e..dbde2c4f7 100644 --- a/backend/src/main/java/kr/momo/service/meeting/dto/MeetingCreateRequest.java +++ b/backend/src/main/java/kr/momo/service/meeting/dto/MeetingCreateRequest.java @@ -3,12 +3,15 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; import java.time.LocalTime; import java.util.List; import kr.momo.controller.validator.DateFormatConstraint; import kr.momo.controller.validator.TimeFormatConstraint; +import kr.momo.domain.meeting.MeetingType; +import kr.momo.domain.timeslot.Timeslot; import org.hibernate.validator.constraints.Length; @Schema(description = "약속 생성 요청") @@ -41,7 +44,11 @@ public record MeetingCreateRequest( @NotBlank @TimeFormatConstraint @Schema(type = "string", pattern = "HH:mm", description = "약속 종료 시간", example = "20:00") - String meetingEndTime + String meetingEndTime, + + @NotNull + @Schema(description = "약속 타입", example = "DATETIME") + MeetingType type ) { public List toAvailableMeetingDates() { @@ -51,10 +58,16 @@ public List toAvailableMeetingDates() { } public LocalTime toMeetingStartTime() { - return LocalTime.parse(meetingStartTime); + if (type.isDaysOnly()) { + return Timeslot.TIME_0000.startTime(); + } + return LocalTime.parse(meetingStartTime); } public LocalTime toMeetingEndTime() { + if (type.isDaysOnly()) { + return Timeslot.TIME_0000.startTime(); + } return LocalTime.parse(meetingEndTime); } } diff --git a/backend/src/main/java/kr/momo/service/meeting/dto/MeetingResponse.java b/backend/src/main/java/kr/momo/service/meeting/dto/MeetingResponse.java index 826d3eaf1..7b55cdde3 100644 --- a/backend/src/main/java/kr/momo/service/meeting/dto/MeetingResponse.java +++ b/backend/src/main/java/kr/momo/service/meeting/dto/MeetingResponse.java @@ -36,7 +36,10 @@ public record MeetingResponse( List attendeeNames, @Schema(description = "약속 주최자 이름") - String hostName + String hostName, + + @Schema(description = "약속 유형") + String type ) { private static final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm"); @@ -55,7 +58,8 @@ public static MeetingResponse of(Meeting meeting, AvailableDates availableDates, meeting.isLocked(), dates, attendeeNames, - hostName + hostName, + meeting.getType().name() ); } diff --git a/backend/src/main/java/kr/momo/service/schedule/ScheduleService.java b/backend/src/main/java/kr/momo/service/schedule/ScheduleService.java index 2aac42c0d..f3d4366e1 100644 --- a/backend/src/main/java/kr/momo/service/schedule/ScheduleService.java +++ b/backend/src/main/java/kr/momo/service/schedule/ScheduleService.java @@ -24,6 +24,7 @@ import kr.momo.service.schedule.dto.DateTimesCreateRequest; import kr.momo.service.schedule.dto.DateTimesResponse; import kr.momo.service.schedule.dto.RecommendedScheduleResponse; +import kr.momo.service.schedule.dto.RecommendedSchedulesResponse; import kr.momo.service.schedule.dto.ScheduleCreateRequest; import kr.momo.service.schedule.dto.SchedulesResponse; import kr.momo.service.schedule.recommend.ScheduleRecommender; @@ -74,6 +75,9 @@ private Stream createSchedulesForDate( Meeting meeting, Attendee attendee, AvailableDates availableDates, DateTimesCreateRequest request ) { AvailableDate date = availableDates.findByDate(request.toDate()); + if (meeting.isDaysOnly()) { + return Stream.of(new Schedule(attendee, date, Timeslot.TIME_0000)); + } return request.toTimes().stream() .map(time -> createSchedule(meeting, attendee, date, time)); } @@ -118,7 +122,7 @@ public AttendeeScheduleResponse findMySchedule(String uuid, long attendeeId) { } @Transactional(readOnly = true) - public List recommendSchedules(String uuid, String recommendType, List names) { + public RecommendedSchedulesResponse recommendSchedules(String uuid, String recommendType, List names) { Meeting meeting = meetingRepository.findByUuid(uuid) .orElseThrow(() -> new MomoException(MeetingErrorCode.NOT_FOUND_MEETING)); AttendeeGroup attendeeGroup = new AttendeeGroup(attendeeRepository.findAllByMeeting(meeting)); @@ -127,8 +131,11 @@ public List recommendSchedules(String uuid, String ScheduleRecommender recommender = scheduleRecommenderFactory.getRecommenderOf( attendeeGroup, filteredGroup ); - List recommendedResult = recommender.recommend(filteredGroup, recommendType); + List recommendedResult = recommender.recommend(filteredGroup, recommendType, + meeting.getType()); - return RecommendedScheduleResponse.fromCandidateSchedules(recommendedResult); + List scheduleResponses = RecommendedScheduleResponse.fromCandidateSchedules( + recommendedResult); + return RecommendedSchedulesResponse.of(meeting.getType(), scheduleResponses); } } diff --git a/backend/src/main/java/kr/momo/service/schedule/dto/RecommendedSchedulesResponse.java b/backend/src/main/java/kr/momo/service/schedule/dto/RecommendedSchedulesResponse.java new file mode 100644 index 000000000..3f6707d7a --- /dev/null +++ b/backend/src/main/java/kr/momo/service/schedule/dto/RecommendedSchedulesResponse.java @@ -0,0 +1,15 @@ +package kr.momo.service.schedule.dto; + +import java.util.List; +import kr.momo.domain.meeting.MeetingType; + +public record RecommendedSchedulesResponse( + String type, + List recommendedSchedules +) { + + public static RecommendedSchedulesResponse of(MeetingType type, + List recommendedSchedules) { + return new RecommendedSchedulesResponse(type.name(), recommendedSchedules); + } +} diff --git a/backend/src/main/java/kr/momo/service/schedule/recommend/FilteredScheduleRecommender.java b/backend/src/main/java/kr/momo/service/schedule/recommend/FilteredScheduleRecommender.java index 20ec18e9c..e9dadccc6 100644 --- a/backend/src/main/java/kr/momo/service/schedule/recommend/FilteredScheduleRecommender.java +++ b/backend/src/main/java/kr/momo/service/schedule/recommend/FilteredScheduleRecommender.java @@ -2,6 +2,7 @@ import java.util.List; import kr.momo.domain.attendee.AttendeeGroup; +import kr.momo.domain.meeting.MeetingType; import kr.momo.domain.schedule.DateAndTimeslot; import kr.momo.domain.schedule.ScheduleRepository; import kr.momo.domain.schedule.recommend.CandidateSchedule; @@ -17,7 +18,8 @@ public FilteredScheduleRecommender(ScheduleRepository scheduleRepository) { } @Override - protected List extractProperSortedDiscreteScheduleOf(AttendeeGroup filteredGroup) { + protected List extractProperSortedDiscreteScheduleOf(AttendeeGroup filteredGroup, + MeetingType type) { return findAllScheduleAvailableByEveryAttendee(filteredGroup); } diff --git a/backend/src/main/java/kr/momo/service/schedule/recommend/ScheduleRecommender.java b/backend/src/main/java/kr/momo/service/schedule/recommend/ScheduleRecommender.java index e55166c44..0aedbd1a7 100644 --- a/backend/src/main/java/kr/momo/service/schedule/recommend/ScheduleRecommender.java +++ b/backend/src/main/java/kr/momo/service/schedule/recommend/ScheduleRecommender.java @@ -2,6 +2,7 @@ import java.util.List; import kr.momo.domain.attendee.AttendeeGroup; +import kr.momo.domain.meeting.MeetingType; import kr.momo.domain.schedule.ScheduleRepository; import kr.momo.domain.schedule.recommend.CandidateSchedule; import kr.momo.domain.schedule.recommend.CandidateScheduleSorter; @@ -15,20 +16,20 @@ public abstract class ScheduleRecommender { protected final ScheduleRepository scheduleRepository; - public List recommend(AttendeeGroup group, String recommendType) { - List mergedCandidateSchedules = calcCandidateSchedules(group); + public List recommend(AttendeeGroup group, String recommendType, MeetingType meetingType) { + List mergedCandidateSchedules = calcCandidateSchedules(group, meetingType); sortSchedules(mergedCandidateSchedules, recommendType); return mergedCandidateSchedules.stream() .limit(getMaxRecommendCount()) .toList(); } - private List calcCandidateSchedules(AttendeeGroup group) { - List intersectedDateTimes = extractProperSortedDiscreteScheduleOf(group); + private List calcCandidateSchedules(AttendeeGroup group, MeetingType type) { + List intersectedDateTimes = extractProperSortedDiscreteScheduleOf(group, type); return CandidateSchedule.mergeContinuous(intersectedDateTimes, this::isContinuous); } - abstract List extractProperSortedDiscreteScheduleOf(AttendeeGroup group); + abstract List extractProperSortedDiscreteScheduleOf(AttendeeGroup group, MeetingType type); abstract boolean isContinuous(CandidateSchedule current, CandidateSchedule next); diff --git a/backend/src/main/java/kr/momo/service/schedule/recommend/TotalScheduleRecommender.java b/backend/src/main/java/kr/momo/service/schedule/recommend/TotalScheduleRecommender.java index b05f240e5..649366594 100644 --- a/backend/src/main/java/kr/momo/service/schedule/recommend/TotalScheduleRecommender.java +++ b/backend/src/main/java/kr/momo/service/schedule/recommend/TotalScheduleRecommender.java @@ -5,7 +5,8 @@ import java.util.Map; import java.util.stream.Collectors; import kr.momo.domain.attendee.AttendeeGroup; -import kr.momo.domain.schedule.DateTimeInterval; +import kr.momo.domain.meeting.MeetingType; +import kr.momo.domain.schedule.RecommendInterval; import kr.momo.domain.schedule.Schedule; import kr.momo.domain.schedule.ScheduleRepository; import kr.momo.domain.schedule.recommend.CandidateSchedule; @@ -21,14 +22,14 @@ protected TotalScheduleRecommender(ScheduleRepository scheduleRepository) { } @Override - protected List extractProperSortedDiscreteScheduleOf(AttendeeGroup group) { - return findAllScheduleAvailableByEachAttendee(group); + protected List extractProperSortedDiscreteScheduleOf(AttendeeGroup group, MeetingType type) { + return findAllScheduleAvailableByEachAttendee(group, type); } - private List findAllScheduleAvailableByEachAttendee(AttendeeGroup group) { + private List findAllScheduleAvailableByEachAttendee(AttendeeGroup group, MeetingType type) { List schedules = scheduleRepository.findAllByAttendeeIn(group.getAttendees()); - Map groupedAttendeesByDateTimeInterval = schedules.stream() - .collect(Collectors.groupingBy(Schedule::dateTimeInterval, Collectors.mapping( + Map groupedAttendeesByDateTimeInterval = schedules.stream() + .collect(Collectors.groupingBy(schedule -> schedule.recommendInterval(type), Collectors.mapping( Schedule::getAttendee, Collectors.collectingAndThen(Collectors.toList(), AttendeeGroup::new) )) diff --git a/backend/src/main/resources/security b/backend/src/main/resources/security index 0b125e5ae..45da7cf52 160000 --- a/backend/src/main/resources/security +++ b/backend/src/main/resources/security @@ -1 +1 @@ -Subproject commit 0b125e5ae92d942d1467ebd86526d504c16f1993 +Subproject commit 45da7cf5212cf5a9d95234bf83466b02862724ff diff --git a/backend/src/test/java/kr/momo/controller/attendee/AttendeeControllerTest.java b/backend/src/test/java/kr/momo/controller/attendee/AttendeeControllerTest.java index a810156a5..a736a9b8b 100644 --- a/backend/src/test/java/kr/momo/controller/attendee/AttendeeControllerTest.java +++ b/backend/src/test/java/kr/momo/controller/attendee/AttendeeControllerTest.java @@ -26,6 +26,7 @@ import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; @IsolateDatabase @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @@ -43,6 +44,9 @@ class AttendeeControllerTest { @Autowired private JwtManager jwtManager; + @Autowired + private PasswordEncoder passwordEncoder; + @BeforeEach void setUp() { RestAssured.port = port; @@ -52,9 +56,9 @@ void setUp() { @Test void login() { Meeting meeting = meetingRepository.save(MeetingFixture.COFFEE.create()); - Attendee attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); - - AttendeeLoginRequest request = new AttendeeLoginRequest(attendee.name(), attendee.password()); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + AttendeeLoginRequest request = new AttendeeLoginRequest(attendee.name(), jazz.getPassword()); Response response = RestAssured.given().log().all() .contentType(ContentType.JSON) diff --git a/backend/src/test/java/kr/momo/controller/meeting/MeetingControllerTest.java b/backend/src/test/java/kr/momo/controller/meeting/MeetingControllerTest.java index a471f8de2..81b377bac 100644 --- a/backend/src/test/java/kr/momo/controller/meeting/MeetingControllerTest.java +++ b/backend/src/test/java/kr/momo/controller/meeting/MeetingControllerTest.java @@ -18,6 +18,7 @@ import kr.momo.domain.meeting.ConfirmedMeetingRepository; import kr.momo.domain.meeting.Meeting; import kr.momo.domain.meeting.MeetingRepository; +import kr.momo.domain.meeting.MeetingType; import kr.momo.domain.timeslot.Timeslot; import kr.momo.fixture.AttendeeFixture; import kr.momo.fixture.ConfirmedMeetingFixture; @@ -120,13 +121,16 @@ void create() { LocalDate today = LocalDate.now(); LocalDate tomorrow = today.plusDays(1); LocalDate dayAfterTomorrow = today.plusDays(2); + AttendeeFixture hostJazz = AttendeeFixture.HOST_JAZZ; MeetingCreateRequest request = new MeetingCreateRequest( - "host", - "momo", + hostJazz.getName(), + hostJazz.getPassword(), "momoMeeting", List.of(tomorrow.toString(), dayAfterTomorrow.toString()), "08:00", - "22:00"); + "22:00", + MeetingType.DATETIME + ); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -151,7 +155,9 @@ void throwExceptionTimeFormatIsInvalid(String startTime, String endTime) { "momoMeeting", List.of(tomorrow.toString(), dayAfterTomorrow.toString()), startTime, - endTime); + endTime, + MeetingType.DATETIME + ); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -174,7 +180,9 @@ void createByInvalidNickname() { "momoMeeting", List.of(tomorrow.toString(), dayAfterTomorrow.toString()), "08:00", - "22:00"); + "22:00", + MeetingType.DATETIME + ); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -197,7 +205,9 @@ void createByInvalidHost() { "momoMeeting", List.of(tomorrow.toString(), dayAfterTomorrow.toString()), "08:00", - "22:00"); + "22:00", + MeetingType.DATETIME + ); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -219,7 +229,32 @@ void createByDuplicatedName() { "momoMeeting", List.of(tomorrow.toString(), tomorrow.toString()), "08:00", - "22:00" + "22:00", + MeetingType.DATETIME + ); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/meetings") + .then().log().all() + .assertThat() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @DisplayName("약속을 생성할 때 약속의 타입 없이 요청한다면 400을 반환한다.") + @Test + void createByInvalidType() { + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + MeetingCreateRequest request = new MeetingCreateRequest( + "momoHost", + "momo", + "momoMeeting", + List.of(tomorrow.toString(), tomorrow.toString()), + "08:00", + "22:00", + null ); RestAssured.given().log().all() @@ -235,8 +270,9 @@ void createByDuplicatedName() { @Test void lock() { Meeting meeting = meetingRepository.save(MeetingFixture.DINNER.create()); - Attendee attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); - String token = getToken(attendee, meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + String token = getToken(jazz.getPassword(), attendee, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) @@ -252,8 +288,9 @@ void lock() { void lockWithInvalidUUID() { String invalidUUID = "INVALID_UUID"; Meeting meeting = meetingRepository.save(MeetingFixture.DINNER.create()); - Attendee attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); - String token = getToken(attendee, meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + String token = getToken(jazz.getPassword(), attendee, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) @@ -268,8 +305,9 @@ void lockWithInvalidUUID() { @Test void lockWithNoPermission() { Meeting meeting = meetingRepository.save(MeetingFixture.DINNER.create()); - Attendee attendee = attendeeRepository.save(AttendeeFixture.GUEST_PEDRO.create(meeting)); - String token = getToken(attendee, meeting); + AttendeeFixture daon = AttendeeFixture.GUEST_DAON; + Attendee attendee = attendeeRepository.save(daon.create(meeting)); + String token = getToken(daon.getPassword(), attendee, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) @@ -284,8 +322,9 @@ void lockWithNoPermission() { @Test void unlock() { Meeting meeting = meetingRepository.save(MeetingFixture.DINNER.create()); - Attendee attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); - String token = getToken(attendee, meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + String token = getToken(jazz.getPassword(), attendee, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) @@ -301,8 +340,9 @@ void unlock() { void unlockWithInvalidUUID() { String invalidUUID = "INVALID_UUID"; Meeting meeting = meetingRepository.save(MeetingFixture.DINNER.create()); - Attendee attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); - String token = getToken(attendee, meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + String token = getToken(jazz.getPassword(), attendee, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) @@ -317,8 +357,9 @@ void unlockWithInvalidUUID() { @Test void unlockWithNoPermission() { Meeting meeting = meetingRepository.save(MeetingFixture.DINNER.create()); - Attendee attendee = attendeeRepository.save(AttendeeFixture.GUEST_PEDRO.create(meeting)); - String token = getToken(attendee, meeting); + AttendeeFixture daon = AttendeeFixture.GUEST_DAON; + Attendee attendee = attendeeRepository.save(daon.create(meeting)); + String token = getToken(daon.getPassword(), attendee, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) @@ -329,8 +370,8 @@ void unlockWithNoPermission() { .statusCode(HttpStatus.FORBIDDEN.value()); } - private String getToken(Attendee attendee, Meeting meeting) { - AttendeeLoginRequest request = new AttendeeLoginRequest(attendee.name(), attendee.password()); + private String getToken(String rawPassword, Attendee attendee, Meeting meeting) { + AttendeeLoginRequest request = new AttendeeLoginRequest(attendee.name(), rawPassword); return RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -345,9 +386,10 @@ private String getToken(Attendee attendee, Meeting meeting) { @Test void confirmSchedule() { Meeting meeting = createLockedMovieMeeting(); - Attendee host = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); AvailableDate tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); - String token = getToken(host, meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + String token = getToken(jazz.getPassword(), attendee, meeting); MeetingConfirmRequest request = getValidFindRequest(tomorrow); RestAssured.given().log().all() @@ -361,19 +403,136 @@ void confirmSchedule() { .header("Location", "/api/v1/meetings/" + meeting.getUuid() + "/confirm"); } + @DisplayName("주최자가 연속적인 약속 일정을 확정하면 201 상태 코드를 응답한다.") + @Test + void confirmConsecutiveMeeting() { + Meeting meeting = MeetingFixture.DRINK.create(); + meeting.lock(); + meeting = meetingRepository.save(meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee host = attendeeRepository.save(jazz.create(meeting)); + AvailableDate tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); + availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(2), meeting)); + AvailableDate plus3Days = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(3), meeting)); + String token = getToken(jazz.getPassword(), host, meeting); + MeetingConfirmRequest request = new MeetingConfirmRequest( + tomorrow.getDate(), + Timeslot.TIME_0000.startTime(), + plus3Days.getDate(), + Timeslot.TIME_2330.startTime() + ); + + RestAssured.given().log().all() + .cookie("ACCESS_TOKEN", token) + .pathParam("uuid", meeting.getUuid()) + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/meetings/{uuid}/confirm") + .then().log().all() + .statusCode(HttpStatus.CREATED.value()) + .header("Location", "/api/v1/meetings/" + meeting.getUuid() + "/confirm"); + } + private Meeting createLockedMovieMeeting() { Meeting meeting = MeetingFixture.MOVIE.create(); meeting.lock(); return meetingRepository.save(meeting); } + @DisplayName("주최자가 유형이 DaysOnly이며 잠겨있는 약속 일정을 확정하면 201 상태 코드를 응답한다.") + @Test + void confirmConsecutiveAndDaysOnlyMeeting() { + Meeting meeting = MeetingFixture.DRINK.create(MeetingType.DAYSONLY); + meeting.lock(); + meeting = meetingRepository.save(meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee host = attendeeRepository.save(jazz.create(meeting)); + AvailableDate tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); + availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(2), meeting)); + AvailableDate plus3Days = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(3), meeting)); + String token = getToken(jazz.getPassword(), host, meeting); + MeetingConfirmRequest request = new MeetingConfirmRequest( + tomorrow.getDate(), + Timeslot.TIME_0000.startTime(), + plus3Days.getDate(), + Timeslot.TIME_0000.startTime() + ); + + RestAssured.given().log().all() + .cookie("ACCESS_TOKEN", token) + .pathParam("uuid", meeting.getUuid()) + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/meetings/{uuid}/confirm") + .then().log().all() + .statusCode(HttpStatus.CREATED.value()) + .header("Location", "/api/v1/meetings/" + meeting.getUuid() + "/confirm"); + } + + @DisplayName("주최자가 연속적이지 않은 약속 일정을 확정하면 400 상태 코드를 응답한다.") + @Test + void confirmNotConsecutiveMeeting() { + Meeting meeting = MeetingFixture.DRINK.create(); + meeting.lock(); + meeting = meetingRepository.save(meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee host = attendeeRepository.save(jazz.create(meeting)); + AvailableDate tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); + AvailableDate plus3Days = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(3), meeting)); + String token = getToken(jazz.getPassword(), host, meeting); + MeetingConfirmRequest request = new MeetingConfirmRequest( + tomorrow.getDate(), + Timeslot.TIME_0000.startTime(), + plus3Days.getDate(), + Timeslot.TIME_0000.startTime() + ); + + RestAssured.given().log().all() + .cookie("ACCESS_TOKEN", token) + .pathParam("uuid", meeting.getUuid()) + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/meetings/{uuid}/confirm") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @DisplayName("주최자가 유형이 DaysOnly이며 연속적이지 않은 약속 일정을 확정하면 400 상태 코드를 응답한다.") + @Test + void confirmNotConsecutiveAndDaysOnlyMeeting() { + Meeting meeting = MeetingFixture.DRINK.create(MeetingType.DAYSONLY); + meeting.lock(); + meeting = meetingRepository.save(meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee host = attendeeRepository.save(jazz.create(meeting)); + AvailableDate tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); + AvailableDate plus3Days = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(3), meeting)); + String token = getToken(jazz.getPassword(), host, meeting); + MeetingConfirmRequest request = new MeetingConfirmRequest( + tomorrow.getDate(), + Timeslot.TIME_0000.startTime(), + plus3Days.getDate(), + Timeslot.TIME_0000.startTime() + ); + + RestAssured.given().log().all() + .cookie("ACCESS_TOKEN", token) + .pathParam("uuid", meeting.getUuid()) + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/meetings/{uuid}/confirm") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + @DisplayName("주최자가 아닌 참가자가 약속 일정을 확정하면 403 상태 코드를 응답한다.") @Test void confirmScheduleNotHost() { Meeting meeting = createLockedMovieMeeting(); AvailableDate tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); - Attendee guest = attendeeRepository.save(AttendeeFixture.GUEST_MARK.create(meeting)); - String token = getToken(guest, meeting); + AttendeeFixture guestMark = AttendeeFixture.GUEST_MARK; + Attendee guest = attendeeRepository.save(guestMark.create(meeting)); + String token = getToken(guestMark.getPassword(), guest, meeting); MeetingConfirmRequest request = getValidFindRequest(tomorrow); RestAssured.given().log().all() @@ -390,9 +549,10 @@ void confirmScheduleNotHost() { @Test void confirmScheduleUnlock() { Meeting meeting = meetingRepository.save(MeetingFixture.MOVIE.create()); - Attendee host = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; AvailableDate tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); - String token = getToken(host, meeting); + Attendee host = attendeeRepository.save(jazz.create(meeting)); + String token = getToken(jazz.getPassword(), host, meeting); MeetingConfirmRequest request = getValidFindRequest(tomorrow); RestAssured.given().log().all() @@ -412,8 +572,9 @@ void confirmInvalidRequest() { AvailableDate availableDate = availableDateRepository.save( new AvailableDate(LocalDate.now().plusDays(1), meeting)); String tomorrow = availableDate.getDate().format(DateTimeFormatter.ISO_DATE); - Attendee guest = attendeeRepository.save(AttendeeFixture.GUEST_MARK.create(meeting)); - String token = getToken(guest, meeting); + AttendeeFixture guestMark = AttendeeFixture.GUEST_MARK; + Attendee guest = attendeeRepository.save(guestMark.create(meeting)); + String token = getToken(guestMark.getPassword(), guest, meeting); MeetingConfirmRequest request = new MeetingConfirmRequest(tomorrow, "3:00", tomorrow, "03:00"); @@ -431,9 +592,10 @@ void confirmInvalidRequest() { @Test void cancelConfirmedMeeting() { Meeting meeting = createLockedMovieMeeting(); - Attendee host = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); confirmedMeetingRepository.save(ConfirmedMeetingFixture.MOVIE.create(meeting)); - String token = getToken(host, meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + String token = getToken(jazz.getPassword(), attendee, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) @@ -456,8 +618,9 @@ void cancelConfirmedMeeting() { @Test void cancelConfirmedMeetingNonExist() { Meeting meeting = createLockedMovieMeeting(); - Attendee host = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); - String token = getToken(host, meeting); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + String token = getToken(jazz.getPassword(), attendee, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) @@ -472,9 +635,10 @@ void cancelConfirmedMeetingNonExist() { @Test void cancelConfirmedMeetingNotHost() { Meeting meeting = createLockedMovieMeeting(); - Attendee guest = attendeeRepository.save(AttendeeFixture.GUEST_MARK.create(meeting)); confirmedMeetingRepository.save(ConfirmedMeetingFixture.MOVIE.create(meeting)); - String token = getToken(guest, meeting); + AttendeeFixture guestMark = AttendeeFixture.GUEST_MARK; + Attendee guest = attendeeRepository.save(guestMark.create(meeting)); + String token = getToken(guestMark.getPassword(), guest, meeting); RestAssured.given().log().all() .cookie("ACCESS_TOKEN", token) diff --git a/backend/src/test/java/kr/momo/controller/schedule/ScheduleControllerTest.java b/backend/src/test/java/kr/momo/controller/schedule/ScheduleControllerTest.java index bd4ad83eb..98762b778 100644 --- a/backend/src/test/java/kr/momo/controller/schedule/ScheduleControllerTest.java +++ b/backend/src/test/java/kr/momo/controller/schedule/ScheduleControllerTest.java @@ -10,7 +10,6 @@ import java.util.List; import kr.momo.domain.attendee.Attendee; import kr.momo.domain.attendee.AttendeeRepository; -import kr.momo.domain.attendee.Role; import kr.momo.domain.availabledate.AvailableDate; import kr.momo.domain.availabledate.AvailableDateRepository; import kr.momo.domain.meeting.Meeting; @@ -18,6 +17,7 @@ import kr.momo.domain.schedule.Schedule; import kr.momo.domain.schedule.ScheduleRepository; import kr.momo.domain.timeslot.Timeslot; +import kr.momo.fixture.AttendeeFixture; import kr.momo.fixture.MeetingFixture; import kr.momo.service.attendee.dto.AttendeeLoginRequest; import kr.momo.service.schedule.dto.DateTimesCreateRequest; @@ -31,6 +31,7 @@ import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; @IsolateDatabase @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @@ -51,7 +52,11 @@ class ScheduleControllerTest { @Autowired private ScheduleRepository scheduleRepository; + @Autowired + private PasswordEncoder passwordEncoder; + private Meeting meeting; + private AttendeeFixture fixture; private Attendee attendee; private AvailableDate today; private AvailableDate tomorrow; @@ -59,8 +64,9 @@ class ScheduleControllerTest { @BeforeEach void setUp() { RestAssured.port = port; + fixture = AttendeeFixture.GUEST_DAON; meeting = meetingRepository.save(MeetingFixture.MOVIE.create()); - attendee = attendeeRepository.save(new Attendee(meeting, "name", "password", Role.GUEST)); + attendee = attendeeRepository.save(fixture.create(meeting)); today = availableDateRepository.save(new AvailableDate(LocalDate.now(), meeting)); tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); } @@ -68,7 +74,7 @@ void setUp() { @DisplayName("참가자가 스케줄을 생성하는데 성공하면 200 상태 코드를 응답한다.") @Test void create() { - AttendeeLoginRequest loginRequest = new AttendeeLoginRequest(attendee.name(), attendee.password()); + AttendeeLoginRequest loginRequest = new AttendeeLoginRequest(attendee.name(), fixture.getPassword()); String token = RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -122,7 +128,7 @@ void findAllSchedules() { @DisplayName("UUID와 참가자 ID로 자신의 스케줄을 조회한다.") @Test void findMySchedule() { - AttendeeLoginRequest loginRequest = new AttendeeLoginRequest(attendee.name(), attendee.password()); + AttendeeLoginRequest loginRequest = new AttendeeLoginRequest(attendee.name(), fixture.getPassword()); createAttendeeSchedule(attendee); diff --git a/backend/src/test/java/kr/momo/domain/attendee/AttendeePasswordTest.java b/backend/src/test/java/kr/momo/domain/attendee/AttendeePasswordTest.java index 39ee7ef26..4e276d669 100644 --- a/backend/src/test/java/kr/momo/domain/attendee/AttendeePasswordTest.java +++ b/backend/src/test/java/kr/momo/domain/attendee/AttendeePasswordTest.java @@ -5,33 +5,41 @@ import kr.momo.exception.MomoException; import kr.momo.exception.code.AttendeeErrorCode; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; class AttendeePasswordTest { - @DisplayName("참가자 비밀번호가 20글자를 초과하면 예외를 발생시킨다.") - @Test - void throwsExceptionIfAttendeePasswordIsTooLong() { - assertThatThrownBy(() -> new AttendeePassword("invalid_password_length_invalid_password_length")) - .isInstanceOf(MomoException.class) - .hasMessage(AttendeeErrorCode.INVALID_PASSWORD_LENGTH.message()); + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setup() { + passwordEncoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); } - @DisplayName("참가자 비밀번호 객체가 정상 생성된다.") + @DisplayName("비밀번호와 동일한지 검증한다.") @Test - void createAttendeePasswordObjectSuccessfully() { + void verifyMatch() { + String given = "1234"; + AttendeeRawPassword rawPassword = new AttendeeRawPassword(given); + AttendeePassword password = rawPassword.encodePassword(passwordEncoder); + assertThatNoException() - .isThrownBy(() -> new AttendeePassword("momo")); + .isThrownBy(() -> password.verifyMatch(rawPassword, passwordEncoder)); } - @DisplayName("비밀번호가 서로 다르면 예외를 발생시킨다.") + @DisplayName("암호화된 비밀번호와 서로 다르면 예외를 발생시킨다.") @Test void throwsExceptionForMismatchedPasswords() { - AttendeePassword password = new AttendeePassword("1234"); - AttendeePassword other = new AttendeePassword("123456"); + String given = "1234"; + AttendeeRawPassword rawPassword = new AttendeeRawPassword(given); + AttendeeRawPassword other = new AttendeeRawPassword("4321"); + AttendeePassword password = rawPassword.encodePassword(passwordEncoder); - assertThatThrownBy(() -> password.verifyPassword(other)) + assertThatThrownBy(() -> password.verifyMatch(other, passwordEncoder)) .isInstanceOf(MomoException.class) .hasMessage(AttendeeErrorCode.PASSWORD_MISMATCHED.message()); } diff --git a/backend/src/test/java/kr/momo/domain/attendee/AttendeeRawPasswordTest.java b/backend/src/test/java/kr/momo/domain/attendee/AttendeeRawPasswordTest.java new file mode 100644 index 000000000..6809cbafa --- /dev/null +++ b/backend/src/test/java/kr/momo/domain/attendee/AttendeeRawPasswordTest.java @@ -0,0 +1,23 @@ +package kr.momo.domain.attendee; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.momo.exception.MomoException; +import kr.momo.exception.code.AttendeeErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class AttendeeRawPasswordTest { + + @DisplayName("참가자 비밀번호가 숫자가 아니거나 4자를 초과하면 예외를 발생시킨다.") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"invalid_password_length", "1@13", " ", "mo12"}) + void throwsExceptionIfAttendeePasswordIsTooLong(String given) { + assertThatThrownBy(() -> new AttendeeRawPassword(given)) + .isInstanceOf(MomoException.class) + .hasMessage(AttendeeErrorCode.INVALID_PASSWORD_FORMAT.message()); + } +} diff --git a/backend/src/test/java/kr/momo/domain/attendee/AttendeeTest.java b/backend/src/test/java/kr/momo/domain/attendee/AttendeeTest.java index 1c81e4af1..e985664a1 100644 --- a/backend/src/test/java/kr/momo/domain/attendee/AttendeeTest.java +++ b/backend/src/test/java/kr/momo/domain/attendee/AttendeeTest.java @@ -6,20 +6,32 @@ import kr.momo.domain.meeting.Meeting; import kr.momo.exception.MomoException; import kr.momo.exception.code.AttendeeErrorCode; +import kr.momo.fixture.AttendeeFixture; import kr.momo.fixture.MeetingFixture; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; class AttendeeTest { + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setup() { + passwordEncoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); + } + @DisplayName("참가자의 비밀번호가 일치하지 않으면 예외를 발생시킨다.") @Test void throwsExceptionIfPasswordDoesNotMatch() { Meeting meeting = MeetingFixture.DINNER.create(); - Attendee attendee = new Attendee(meeting, "jazz", "1111", Role.GUEST); - AttendeePassword other = new AttendeePassword("1234"); + AttendeeFixture daon = AttendeeFixture.GUEST_DAON; + Attendee attendee = AttendeeFixture.HOST_JAZZ.create(meeting); + AttendeeRawPassword rawPassword = new AttendeeRawPassword(daon.getPassword()); - assertThatThrownBy(() -> attendee.verifyPassword(other)) + assertThatThrownBy(() -> attendee.verifyPassword(rawPassword, passwordEncoder)) .isInstanceOf(MomoException.class) .hasMessage(AttendeeErrorCode.PASSWORD_MISMATCHED.message()); } @@ -27,11 +39,12 @@ void throwsExceptionIfPasswordDoesNotMatch() { @DisplayName("참가자의 비밀번호가 일치하면 정상 기능한다.") @Test void doesNotThrowExceptionIfPasswordMatches() { + String given = "1234"; + AttendeeRawPassword rawPassword = new AttendeeRawPassword(given); Meeting meeting = MeetingFixture.DINNER.create(); - Attendee attendee = new Attendee(meeting, "jazz", "1111", Role.GUEST); - AttendeePassword other = new AttendeePassword("1111"); + Attendee attendee = AttendeeFixture.HOST_JAZZ.create(meeting); assertThatNoException() - .isThrownBy(() -> attendee.verifyPassword(other)); + .isThrownBy(() -> attendee.verifyPassword(rawPassword, passwordEncoder)); } } diff --git a/backend/src/test/java/kr/momo/domain/meeting/ConfirmedMeetingTest.java b/backend/src/test/java/kr/momo/domain/meeting/ConfirmedMeetingTest.java index ec267eaa7..ed9801697 100644 --- a/backend/src/test/java/kr/momo/domain/meeting/ConfirmedMeetingTest.java +++ b/backend/src/test/java/kr/momo/domain/meeting/ConfirmedMeetingTest.java @@ -46,4 +46,32 @@ void availableAttendeesOf() { () -> assertThat(attendees).containsExactly(attendee1) ); } + + @DisplayName("확정된 days only 유형 약속의 범위에 포함되는 스케줄들 중 참석 가능한 참석자들을 반환한다.") + @Test + void availableAttendeesOfDaysOnly() { + Meeting meeting = MeetingFixture.DRINK.create(MeetingType.DAYSONLY); + Attendee attendee1 = AttendeeFixture.GUEST_MARK.create(meeting); + Attendee attendee2 = AttendeeFixture.HOST_JAZZ.create(meeting); + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + ConfirmedMeeting confirmedMeeting = new ConfirmedMeeting( + meeting + , LocalDateTime.of(today, LocalTime.of(0, 0)) + , LocalDateTime.of(tomorrow, LocalTime.of(0, 0)) + ); + List schedules = List.of( + new Schedule(attendee1, new AvailableDate(today, meeting), Timeslot.TIME_0000), + new Schedule(attendee1, new AvailableDate(tomorrow, meeting), Timeslot.TIME_0000), + new Schedule(attendee2, new AvailableDate(today, meeting), Timeslot.TIME_0000) + ); + + AttendeeGroup availableAttendees = confirmedMeeting.availableAttendeesOf(schedules); + List attendees = availableAttendees.getAttendees(); + + assertAll( + () -> assertThat(attendees).hasSize(1), + () -> assertThat(attendees).containsExactly(attendee1) + ); + } } diff --git a/backend/src/test/java/kr/momo/domain/meeting/MeetingTypeTest.java b/backend/src/test/java/kr/momo/domain/meeting/MeetingTypeTest.java new file mode 100644 index 000000000..4f66d5105 --- /dev/null +++ b/backend/src/test/java/kr/momo/domain/meeting/MeetingTypeTest.java @@ -0,0 +1,41 @@ +package kr.momo.domain.meeting; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import kr.momo.exception.MomoException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MeetingTypeTest { + + @DisplayName("daysonly일 때 true를 반환한다.") + @Test + void isDaysOnly() { + assertAll( + () -> assertTrue(MeetingType.DAYSONLY.isDaysOnly()), + () -> assertFalse(MeetingType.DATETIME.isDaysOnly()) + ); + } + + @DisplayName("문자열을 Type으로 반환한다.") + @Test + void from() { + assertAll( + () -> assertEquals(MeetingType.DAYSONLY, MeetingType.from("daysonly")), + () -> assertEquals(MeetingType.DATETIME, MeetingType.from("datetime")) + ); + } + + @DisplayName("문자열을 Type으로 변환할 때 널이거나 올바르지 않은 값이라면 예외를 던진다.") + @Test + void fromInvalid() { + assertAll( + () -> assertThrows(MomoException.class, () -> MeetingType.from(null)), + () -> assertThrows(MomoException.class, () -> MeetingType.from("invalid")) + ); + } +} diff --git a/backend/src/test/java/kr/momo/fixture/AttendeeFixture.java b/backend/src/test/java/kr/momo/fixture/AttendeeFixture.java index 5c3758b08..d7de5380e 100644 --- a/backend/src/test/java/kr/momo/fixture/AttendeeFixture.java +++ b/backend/src/test/java/kr/momo/fixture/AttendeeFixture.java @@ -5,14 +5,18 @@ import kr.momo.domain.attendee.AttendeePassword; import kr.momo.domain.attendee.Role; import kr.momo.domain.meeting.Meeting; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; public enum AttendeeFixture { - HOST_JAZZ("jazz", "hostPw!12", Role.HOST), - GUEST_DAON("daon", "daonPw!12", Role.GUEST), - GUEST_BAKEY("bakey", "bakeyPw!12", Role.GUEST), - GUEST_PEDRO("pedro", "pedroPw!12", Role.GUEST), - GUEST_MARK("mark", "mark!12", Role.GUEST); + HOST_JAZZ("jazz", "1234", Role.HOST), + GUEST_DAON("daon", "4321", Role.GUEST), + GUEST_BAKEY("bakey", "3422", Role.GUEST), + GUEST_PEDRO("pedro", "4353", Role.GUEST), + GUEST_MARK("mark", "1234", Role.GUEST); + + private static final PasswordEncoder ENCODER = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); private final String name; private final String password; @@ -25,6 +29,14 @@ public enum AttendeeFixture { } public Attendee create(Meeting meeting) { - return new Attendee(meeting, new AttendeeName(name), new AttendeePassword(password), role); + return new Attendee(meeting, new AttendeeName(name), new AttendeePassword(ENCODER.encode(password)), role); + } + + public String getName() { + return name; + } + + public String getPassword() { + return password; } } diff --git a/backend/src/test/java/kr/momo/fixture/MeetingFixture.java b/backend/src/test/java/kr/momo/fixture/MeetingFixture.java index 62113c59b..18e57f954 100644 --- a/backend/src/test/java/kr/momo/fixture/MeetingFixture.java +++ b/backend/src/test/java/kr/momo/fixture/MeetingFixture.java @@ -2,6 +2,7 @@ import java.time.LocalTime; import kr.momo.domain.meeting.Meeting; +import kr.momo.domain.meeting.MeetingType; public enum MeetingFixture { @@ -27,4 +28,8 @@ public enum MeetingFixture { public Meeting create() { return new Meeting(name, uuid, firstTime, lastTime); } + + public Meeting create(MeetingType type) { + return new Meeting(name, uuid, firstTime, lastTime, type); + } } diff --git a/backend/src/test/java/kr/momo/service/attendee/AttendeeServiceTest.java b/backend/src/test/java/kr/momo/service/attendee/AttendeeServiceTest.java index 83a4c1ff2..e68286eaa 100644 --- a/backend/src/test/java/kr/momo/service/attendee/AttendeeServiceTest.java +++ b/backend/src/test/java/kr/momo/service/attendee/AttendeeServiceTest.java @@ -20,6 +20,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.security.crypto.password.PasswordEncoder; @IsolateDatabase @SpringBootTest(webEnvironment = WebEnvironment.NONE) @@ -34,6 +35,9 @@ class AttendeeServiceTest { @Autowired private MeetingRepository meetingRepository; + @Autowired + private PasswordEncoder passwordEncoder; + private Meeting meeting; @BeforeEach @@ -44,8 +48,9 @@ void setUp() { @DisplayName("로그인 시 올바르지 않은 uuid로 접근할 경우 예외를 발생시킨다.") @Test void loginThrowsExceptionForInvalidUuid() { - Attendee attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); - AttendeeLoginRequest request = new AttendeeLoginRequest(attendee.name(), attendee.password()); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + AttendeeLoginRequest request = new AttendeeLoginRequest(attendee.name(), jazz.getPassword()); assertThatThrownBy(() -> attendeeService.login("invalidUUID", request)) .isInstanceOf(MomoException.class) @@ -67,8 +72,9 @@ void createsNewAttendeeIfNameIsNotAlreadyExists() { @DisplayName("로그인 시 동일한 이름이 저장되어 있으면 새로 참가자를 생성하지 않는다.") @Test void doesNotCreateAttendeeIfNameAlreadyExists() { - Attendee attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); - AttendeeLoginRequest request = new AttendeeLoginRequest(attendee.name(), attendee.password()); + AttendeeFixture jazz = AttendeeFixture.HOST_JAZZ; + Attendee attendee = attendeeRepository.save(jazz.create(meeting)); + AttendeeLoginRequest request = new AttendeeLoginRequest(attendee.name(), jazz.getPassword()); attendeeService.login(meeting.getUuid(), request); long initialCount = attendeeRepository.count(); diff --git a/backend/src/test/java/kr/momo/service/meeting/MeetingConfirmServiceTest.java b/backend/src/test/java/kr/momo/service/meeting/MeetingConfirmServiceTest.java index 9230f6bc6..186633bb6 100644 --- a/backend/src/test/java/kr/momo/service/meeting/MeetingConfirmServiceTest.java +++ b/backend/src/test/java/kr/momo/service/meeting/MeetingConfirmServiceTest.java @@ -70,8 +70,8 @@ void setUp() { meeting = MeetingFixture.MOVIE.create(); meeting.lock(); meeting = meetingRepository.save(meeting); - attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(this.meeting)); - today = availableDateRepository.save(new AvailableDate(LocalDate.now(), this.meeting)); + attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); + today = availableDateRepository.save(new AvailableDate(LocalDate.now(), meeting)); validRequest = new MeetingConfirmRequest( today.getDate(), meeting.earliestTime(), @@ -218,7 +218,8 @@ void findByUuid() { LocalTime.of(1, 30) ); - MeetingConfirmResponse confirmed = meetingConfirmService.create(meeting.getUuid(), attendee.getId(), validRequest); + MeetingConfirmResponse confirmed = meetingConfirmService.create(meeting.getUuid(), attendee.getId(), + validRequest); ConfirmedMeetingResponse response = meetingConfirmService.findByUuid(meeting.getUuid()); assertAll( @@ -232,7 +233,8 @@ void findByUuid() { () -> assertThat(response.endDate()).isEqualTo(confirmed.endDate()), () -> assertThat(response.endTime()).isEqualTo(confirmed.endTime()), () -> assertThat(response.endDayOfWeek()) - .isEqualTo(confirmed.endDate().getDayOfWeek().getDisplayName(TextStyle.NARROW, Locale.KOREAN)) + .isEqualTo(confirmed.endDate().getDayOfWeek().getDisplayName(TextStyle.NARROW, Locale.KOREAN)), + () -> assertThat(response.type()).isEqualTo(meeting.getType().name()) ); } diff --git a/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java b/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java index aad2f4d1d..6480acdd4 100644 --- a/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java +++ b/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java @@ -16,6 +16,7 @@ import kr.momo.domain.availabledate.AvailableDateRepository; import kr.momo.domain.meeting.Meeting; import kr.momo.domain.meeting.MeetingRepository; +import kr.momo.domain.meeting.MeetingType; import kr.momo.domain.meeting.UuidGenerator; import kr.momo.domain.meeting.fake.FakeUuidGenerator; import kr.momo.exception.MomoException; @@ -89,7 +90,8 @@ void findByUUID() { () -> assertThat(response.meetingName()).isEqualTo(meeting.getName()), () -> assertThat(response.isLocked()).isFalse(), () -> assertThat(response.availableDates()).hasSize(availableDates.size()), - () -> assertThat(response.attendeeNames()).isEqualTo(List.of(attendee.name())) + () -> assertThat(response.attendeeNames()).isEqualTo(List.of(attendee.name())), + () -> assertThat(response.type()).isEqualTo(meeting.getType().name()) ); } @@ -126,7 +128,8 @@ void throwExceptionWhenUuidAlreadyExistsAfterMaxAttempts() { "meetingName", List.of(LocalDate.now().toString()), "08:00", - "22:00" + "22:00", + MeetingType.DATETIME ); assertThatThrownBy(() -> meetingService.create(request)) @@ -146,7 +149,8 @@ void throwExceptionWhenDatesHavePast() { "momoMeeting", List.of(yesterday.toString(), today.toString()), "08:00", - "22:00" + "22:00", + MeetingType.DATETIME ); //when //then diff --git a/backend/src/test/java/kr/momo/service/schedule/ScheduleServiceTest.java b/backend/src/test/java/kr/momo/service/schedule/ScheduleServiceTest.java index da3e0f870..5f614d830 100644 --- a/backend/src/test/java/kr/momo/service/schedule/ScheduleServiceTest.java +++ b/backend/src/test/java/kr/momo/service/schedule/ScheduleServiceTest.java @@ -18,6 +18,7 @@ import kr.momo.domain.availabledate.AvailableDateRepository; import kr.momo.domain.meeting.Meeting; import kr.momo.domain.meeting.MeetingRepository; +import kr.momo.domain.meeting.MeetingType; import kr.momo.domain.schedule.Schedule; import kr.momo.domain.schedule.ScheduleRepository; import kr.momo.domain.timeslot.Timeslot; @@ -32,6 +33,7 @@ import kr.momo.service.schedule.dto.DateTimesCreateRequest; import kr.momo.service.schedule.dto.DateTimesResponse; import kr.momo.service.schedule.dto.RecommendedScheduleResponse; +import kr.momo.service.schedule.dto.RecommendedSchedulesResponse; import kr.momo.service.schedule.dto.ScheduleCreateRequest; import kr.momo.service.schedule.dto.SchedulesResponse; import kr.momo.support.IsolateDatabase; @@ -97,6 +99,26 @@ void createSchedulesReplacesOldSchedules() { assertThat(scheduleCount).isEqualTo(4); } + @DisplayName("days only 약속의 스케줄 생성 시 하루에 하나의 스케줄을 저장한다.") + @Test + void createDaysOnlySchedulesReplacesOldSchedules() { + meeting = meetingRepository.save(MeetingFixture.DRINK.create(MeetingType.DAYSONLY)); + attendee = attendeeRepository.save(AttendeeFixture.HOST_JAZZ.create(meeting)); + today = availableDateRepository.save(new AvailableDate(LocalDate.now(), meeting)); + tomorrow = availableDateRepository.save(new AvailableDate(LocalDate.now().plusDays(1), meeting)); + dateTimes = List.of( + new DateTimesCreateRequest(today.getDate(), + List.of(Timeslot.TIME_0000.startTime(), Timeslot.TIME_0130.startTime())), + new DateTimesCreateRequest(tomorrow.getDate(), List.of(Timeslot.TIME_0000.startTime())) + ); + ScheduleCreateRequest request = new ScheduleCreateRequest(dateTimes); + + scheduleService.create(meeting.getUuid(), attendee.getId(), request); + long scheduleCount = scheduleRepository.count(); + + assertThat(scheduleCount).as("하루에 하나의 스케줄 생성").isEqualTo(2); + } + @DisplayName("스케줄 생성 요청의 UUID가 존재하지 않으면 예외를 발생시킨다.") @Test void throwsExceptionWhenInvalidUUID() { @@ -239,11 +261,11 @@ void recommendLongTermSchedules() { List schedules = addSchedule(jazz, daon, padro, today, tomorrow); scheduleRepository.saveAll(schedules); - List responses = scheduleService.recommendSchedules( + RecommendedSchedulesResponse responses = scheduleService.recommendSchedules( movieMeeting.getUuid(), LONG_TERM_ORDER.getType(), List.of(jazz.name(), daon.name()) ); - assertThat(responses).containsExactly( + assertThat(responses.recommendedSchedules()).containsExactly( RecommendedScheduleResponse.of( 1, LocalDateTime.of(today.getDate(), Timeslot.TIME_0500.startTime()), @@ -279,11 +301,11 @@ void recommendFastestSchedules() { List schedules = addSchedule(jazz, daon, padro, today, tomorrow); scheduleRepository.saveAll(schedules); - List responses = scheduleService.recommendSchedules( + RecommendedSchedulesResponse responses = scheduleService.recommendSchedules( movieMeeting.getUuid(), EARLIEST_ORDER.getType(), List.of(jazz.name(), daon.name()) ); - assertThat(responses).containsExactly( + assertThat(responses.recommendedSchedules()).containsExactly( RecommendedScheduleResponse.of( 1, LocalDateTime.of(today.getDate(), Timeslot.TIME_0330.startTime()), @@ -328,7 +350,6 @@ private List addSchedule( schedules.add(new Schedule(attendee1, date2, Timeslot.TIME_0500)); - // attendee2 schedules.add(new Schedule(attendee2, date1, Timeslot.TIME_0330)); schedules.add(new Schedule(attendee2, date1, Timeslot.TIME_0400)); @@ -343,7 +364,6 @@ private List addSchedule( schedules.add(new Schedule(attendee2, date2, Timeslot.TIME_0230)); schedules.add(new Schedule(attendee2, date2, Timeslot.TIME_0300)); - // attendee3 schedules.add(new Schedule(attendee3, date1, Timeslot.TIME_0130)); schedules.add(new Schedule(attendee3, date1, Timeslot.TIME_0200)); @@ -378,11 +398,11 @@ void recommendContinuousSchedule() { List schedules = addNextDaySchedule(jazz, daon, today, tomorrow); scheduleRepository.saveAll(schedules); - List responses = scheduleService.recommendSchedules( + RecommendedSchedulesResponse responses = scheduleService.recommendSchedules( movieMeeting.getUuid(), LONG_TERM_ORDER.getType(), List.of(jazz.name(), daon.name()) ); - assertThat(responses).containsExactly( + assertThat(responses.recommendedSchedules()).containsExactly( RecommendedScheduleResponse.of( 1, LocalDateTime.of(today.getDate(), Timeslot.TIME_2300.startTime()), diff --git a/backend/src/test/java/kr/momo/service/schedule/recommend/ScheduleRecommenderTest.java b/backend/src/test/java/kr/momo/service/schedule/recommend/ScheduleRecommenderTest.java index df8965334..4eb69c745 100644 --- a/backend/src/test/java/kr/momo/service/schedule/recommend/ScheduleRecommenderTest.java +++ b/backend/src/test/java/kr/momo/service/schedule/recommend/ScheduleRecommenderTest.java @@ -14,6 +14,7 @@ import kr.momo.domain.availabledate.AvailableDateRepository; import kr.momo.domain.meeting.Meeting; import kr.momo.domain.meeting.MeetingRepository; +import kr.momo.domain.meeting.MeetingType; import kr.momo.domain.schedule.DateTimeInterval; import kr.momo.domain.schedule.Schedule; import kr.momo.domain.schedule.ScheduleRepository; @@ -138,7 +139,7 @@ void recommendByEachAttendeeSubsetOrderByAttendeeCountAndEarliestTime() { // when List recommendResult = totalScheduleRecommender.recommend( - group, RecommendedScheduleSortStandard.EARLIEST_ORDER.getType() + group, RecommendedScheduleSortStandard.EARLIEST_ORDER.getType(), MeetingType.DATETIME ); // then @@ -203,7 +204,7 @@ void recommendByFilteredAttendeeOrderByAttendeeCountAndEarliestTime() { // when List recommendResult = filteredScheduleRecommender.recommend( - filteredGroup, RecommendedScheduleSortStandard.EARLIEST_ORDER.getType() + filteredGroup, RecommendedScheduleSortStandard.EARLIEST_ORDER.getType(), MeetingType.DATETIME ); // then diff --git a/frontend/legacy/Calendar/Calendar.stories.tsx b/frontend/legacy/Calendar/Calendar.stories.tsx new file mode 100644 index 000000000..258dfb42e --- /dev/null +++ b/frontend/legacy/Calendar/Calendar.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import Calendar from './index'; + +const meta = { + title: 'Components/Calendar', + component: Calendar, + tags: ['autodocs'], + + parameters: { + layout: 'centered', + }, + argTypes: { + hasDate: { + description: '선택된 날짜들', + type: 'function', + control: { + disable: true, + }, + }, + onDateClick: { + description: '선택된 날짜 리스트에 특정 날짜를 추가하거나 제거할 수 있는 함수', + }, + }, + decorators: [ + (Story, context) => { + const [selectedDates, setSelectedDates] = useState([]); + + const hasDate = (date: string) => selectedDates.includes(date); + + const handleDateClick = (date: string) => { + setSelectedDates((prevDates) => + hasDate(date) ? prevDates.filter((d) => d !== date) : [...prevDates, date], + ); + }; + + return ( + + ); + }, + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + hasDate: () => false, + onDateClick: () => {}, + }, + render: (args) => { + return ; + }, +}; +export default {}; diff --git a/frontend/legacy/Calendar/Calendar.styles.ts b/frontend/legacy/Calendar/Calendar.styles.ts new file mode 100644 index 000000000..95cc429ad --- /dev/null +++ b/frontend/legacy/Calendar/Calendar.styles.ts @@ -0,0 +1,93 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const s_calendarContainer = css` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const s_calendarContent = css` + display: grid; + grid-template-columns: repeat(7, 1fr); + width: 100%; +`; + +export const s_dayOfWeekContainer = css` + margin-bottom: 2rem; +`; + +export const s_baseDayOfWeek = css` + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + min-width: 4rem; + height: 4rem; + min-height: 4rem; + + ${theme.typography.bodyMedium} +`; + +export const s_dayOfWeek = (index: number) => { + if (index === CALENDAR_PROPERTIES.sundayNumber) + return css` + ${DAY_SLOT_TEXT_STYLES.holiday} + `; + + if (index === CALENDAR_PROPERTIES.saturdayNumber) + return css` + ${DAY_SLOT_TEXT_STYLES.saturday} + `; + + return css` + ${DAY_SLOT_TEXT_STYLES.default} + `; +}; + +export const s_monthHeader = css` + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + margin-bottom: 2rem; + padding: 0 1rem; +`; + +export const s_monthNavigation = css` + cursor: pointer; + background-color: transparent; + border: none; + + ${theme.typography.titleMedium} + + &:disabled { + color: ${theme.colors.grey.primary}; + } +`; + +const DAY_SLOT_TEXT_STYLES = { + selected: css` + color: ${theme.colors.calendar.color.selected}; + `, + holiday: css` + color: ${theme.colors.calendar.color.holiday}; + `, + today: css` + color: ${theme.colors.calendar.color.today}; + `, + saturday: css` + color: #8c9eff; + `, + prevDay: css` + color: ${theme.colors.grey.primary}; + `, + default: css` + color: ${theme.colors.black}; + `, +}; diff --git a/frontend/legacy/Calendar/CalendarDate.styles.ts b/frontend/legacy/Calendar/CalendarDate.styles.ts new file mode 100644 index 000000000..86cb330b4 --- /dev/null +++ b/frontend/legacy/Calendar/CalendarDate.styles.ts @@ -0,0 +1,159 @@ +import { css } from '@emotion/react'; +import type { FlagObject } from 'types/utility'; + +import theme from '@styles/theme'; + +export const s_dateContainer = css` + width: 100%; + min-width: 4.8rem; + height: 4.8rem; +`; + +export const s_baseDateButton = () => css` + cursor: pointer; + + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + border: none; + + &:disabled { + cursor: default; + } +`; + +export const s_singleDateButton = (isSelectedDate: boolean) => css` + background-color: ${isSelectedDate ? theme.colors.pink.medium : 'transparent'}; + border-radius: 0.8rem; +`; + +export const s_rangeDateButton = (isSelectedDate: boolean) => css` + background-color: ${isSelectedDate ? theme.colors.pink.light : 'transparent'}; +`; + +export const s_baseDaySlotText = css` + ${theme.typography.bodyLight} +`; + +type DaySlotStatus = + | 'isSelectedFullDate' + | 'isPrevDate' + | 'isSunday' + | 'isSaturday' + | 'isHoliday' + | 'isToday'; + +export const s_daySlotText = ({ + isSelectedFullDate, + isPrevDate, + isSunday, + isSaturday, + isHoliday, + isToday, +}: FlagObject) => { + if (isSelectedFullDate) return DAY_SLOT_TEXT_STYLES.selected; + if (isHoliday) return DAY_SLOT_TEXT_STYLES.holiday; + if (isPrevDate) return DAY_SLOT_TEXT_STYLES.prevDay; + if (isToday) return DAY_SLOT_TEXT_STYLES.today; + if (isSunday) return DAY_SLOT_TEXT_STYLES.holiday; + if (isSaturday) return DAY_SLOT_TEXT_STYLES.saturday; + + return DAY_SLOT_TEXT_STYLES.default; +}; + +const DAY_SLOT_TEXT_STYLES = { + selected: css` + color: ${theme.colors.calendar.color.selected}; + `, + holiday: css` + color: ${theme.colors.calendar.color.holiday}; + `, + today: css` + color: ${theme.colors.calendar.color.today}; + `, + saturday: css` + color: #8c9eff; + `, + prevDay: css` + color: ${theme.colors.grey.primary}; + `, + default: css` + color: ${theme.colors.black}; + `, +}; + +export const s_holidayText = css` + font-size: 1rem; + font-weight: 300; + line-height: 1.2; +`; + +export const s_rangeStart = (isAllRangeSelected: boolean) => css` + background-color: ${theme.colors.pink.medium}; + + &::before { + content: ''; + + position: absolute; + top: 0; + right: 0; + bottom: 0; + + width: 20%; + height: 100%; + + background-color: ${isAllRangeSelected ? theme.colors.pink.light : theme.colors.white}; + } + + &::after { + content: ''; + + position: absolute; + top: 0; + right: 0.4px; + bottom: 0; + + width: 20%; + height: 100%; + + background-color: ${theme.colors.pink.medium}; + clip-path: polygon(0 0, 100% 50%, 0 100%); + } +`; + +export const s_rangeEnd = (isAllRangeSelected: boolean) => css` + background-color: ${theme.colors.pink.medium}; + + &::before { + content: ''; + + position: absolute; + top: 0; + bottom: 0; + left: 0; + + width: 20%; + height: 100%; + + background-color: ${isAllRangeSelected ? theme.colors.pink.light : theme.colors.white}; + } + + &::after { + content: ''; + + position: absolute; + top: 0; + bottom: 0; + left: 0.4px; + + width: 20%; + height: 100%; + + background-color: ${theme.colors.pink.medium}; + clip-path: polygon(100% 0, 0 50%, 100% 100%); + } +`; diff --git a/frontend/legacy/Calendar/Date/RangeCalendarDate.tsx b/frontend/legacy/Calendar/Date/RangeCalendarDate.tsx new file mode 100644 index 000000000..7aa17b25a --- /dev/null +++ b/frontend/legacy/Calendar/Date/RangeCalendarDate.tsx @@ -0,0 +1,74 @@ +import type { DateInfo } from '@hooks/useCalendarInfo/useCalendar.type'; +import { getDateInfo2 } from '@hooks/useCalendarInfo/useCalendarInfo.utils'; + +import { + s_baseDateButton, + s_baseDaySlotText, + s_dateContainer, + s_daySlotText, + s_holidayText, + s_rangeDateButton, + s_rangeEnd, + s_rangeStart, +} from '../CalendarDate.styles'; + +interface RangeCalendarDateProps { + dateInfo: DateInfo; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; + isRangeStart: boolean; + isRangeEnd: boolean; + isAllRangeSelected: boolean; +} + +export default function RangeCalendarDate({ + dateInfo, + hasDate, + onDateClick, + isRangeStart, + isRangeEnd, + isAllRangeSelected, +}: RangeCalendarDateProps) { + const { key, value, status } = dateInfo; + const { + date, + currentFullDate, + isHoliday, + isToday, + isSaturday, + isSunday, + isPrevDate, + holidayName, + } = getDateInfo2(value, new Date()); + const isSelectedFullDate = hasDate(currentFullDate); + + return status === 'currentMonth' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/legacy/Calendar/Date/SingleCalendarDate.tsx b/frontend/legacy/Calendar/Date/SingleCalendarDate.tsx new file mode 100644 index 000000000..1ea709c37 --- /dev/null +++ b/frontend/legacy/Calendar/Date/SingleCalendarDate.tsx @@ -0,0 +1,62 @@ +import type { DateInfo } from '@hooks/useCalendarInfo/useCalendar.type'; +import { getDateInfo2 } from '@hooks/useCalendarInfo/useCalendarInfo.utils'; + +import { + s_baseDateButton, + s_baseDaySlotText, + s_dateContainer, + s_daySlotText, + s_holidayText, + s_singleDateButton, +} from '../CalendarDate.styles'; + +interface SingleCalendarDateProps { + dateInfo: DateInfo; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; +} + +export default function SingleCalendarDate({ + dateInfo, + hasDate, + onDateClick, +}: SingleCalendarDateProps) { + const { key, value, status } = dateInfo; + const { + date, + currentFullDate, + isHoliday, + isToday, + isSaturday, + isSunday, + isPrevDate, + holidayName, + } = getDateInfo2(value, new Date()); + const isSelectedFullDate = hasDate(currentFullDate); + + return status === 'currentMonth' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/legacy/Calendar/index.tsx b/frontend/legacy/Calendar/index.tsx new file mode 100644 index 000000000..da59f1931 --- /dev/null +++ b/frontend/legacy/Calendar/index.tsx @@ -0,0 +1,98 @@ +import useCalendar from '@hooks/useCalendarInfo/useCalendar'; +import useDateSelect from '@hooks/useDateSelect/useDateSelect'; + +import TabButton from '../Buttons/TabButton'; +import { + s_baseDayOfWeek, + s_calendarContainer, + s_calendarContent, + s_dayOfWeek, + s_dayOfWeekContainer, + s_monthHeader, + s_monthNavigation, +} from './Calendar.styles'; +import RangeCalendarDate from './Date/RangeCalendarDate'; +import SingleCalendarDate from './Date/SingleCalendarDate'; + +interface CalendarProps { + onDateClick: (date: string) => void; +} + +export default function Calendar({ onDateClick }: CalendarProps) { + const { headers, body, view, isCurrentMonth } = useCalendar(); + const { currentYear, currentMonth, weekDays } = headers; + const { moveToNextMonth, moveToPrevMonth } = view; + const { + dateSelectMode, + toggleDateSelectMode, + handleSelectedDates, + hasDate, + checkIsRangeStartDate, + checkIsRangeEndDate, + isAllRangeSelected, + } = useDateSelect(); + + return ( +
+
+ + + {currentYear}년 {currentMonth + 1}월 + + + toggleDateSelectMode('single')} + > + 하나씩 + + toggleDateSelectMode('range')} + > + 기간 + +
+
+ {weekDays.map((day, index) => ( +
+ {day} +
+ ))} +
+
+ {body.value.map(({ key, value: onWeekDays }) => + onWeekDays.map((dateInfo) => { + return dateSelectMode === 'single' ? ( + + ) : ( + + ); + }), + )} +
+
+ ); +} diff --git a/frontend/src/hooks/useCalendarInfo/useCalendarInfo.ts b/frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.ts similarity index 100% rename from frontend/src/hooks/useCalendarInfo/useCalendarInfo.ts rename to frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.ts diff --git a/frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.utils.ts b/frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.utils.ts new file mode 100644 index 000000000..2bf770754 --- /dev/null +++ b/frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.utils.ts @@ -0,0 +1,164 @@ +import { getHolidayNames } from '@hyunbinseo/holidays-kr'; +import type { DateInfo, MonthStatus, MonthlyDays } from 'types/calendar'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const getCurrentDateInfo = () => { + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; + + return { + currentDate, + currentYear, + currentMonth, + } as const; +}; + +export const generateMonthDaySlots = (year: number, month: number) => { + const startDate = new Date(year, month - 1, 1); + const firstDayIndex = startDate.getDay(); + + const lastDateOfMonth = new Date(year, month, 0); + const lastDayNumber = lastDateOfMonth.getDate(); + + const daySlotCount = firstDayIndex + lastDayNumber; + + return { firstDayIndex, daySlotCount } as const; +}; + +const getDateBaseInfo = (date: Date) => { + const currentYear = getYear(date); + const currentMonth = getMonth(date); + const currentDate = getDate(date); + const currentDay = getDay(date); + + return { currentYear, currentMonth, currentDate, currentDay }; +}; + +export const getDateInfo2 = (date: Date, today: Date) => { + const { currentYear, currentMonth, currentDate, currentDay } = getDateBaseInfo(date); + + const currentFullDate = getFullDate(date); + const todayFullDate = getFullDate(today); + + const holidayName = getHolidayNames(date); + const formattedHolidayName = holidayName ? holidayName[0] : null; + const isHoliday = formattedHolidayName !== null; + + const isSaturday = currentDay === CALENDAR_PROPERTIES.saturdayNumber; + const isSunday = currentDay === CALENDAR_PROPERTIES.sundayNumber; + const isPrevDate = currentFullDate < todayFullDate; + + const isToday = currentFullDate === todayFullDate; + + return { + date: currentDate, + currentFullDate, + isHoliday, + isToday, + isSunday, + isSaturday, + isPrevDate, + holidayName: formattedHolidayName, + } as const; +}; + +export const getDateInfo = ({ + year, + month, + firstDayIndex, + index, + currentDate, +}: { + year: number; + month: number; + firstDayIndex: number; + index: number; + currentDate: Date; +}) => { + const date = index - firstDayIndex + 1; + const todayDate = currentDate.getDate(); + const formattedMonth = String(month).padStart(2, '0'); + const formattedCurrentMonth = String(currentDate.getMonth() + 1).padStart(2, '0'); + + const fullDate = `${year}-${formattedMonth}-${String(date).padStart(2, '0')}`; + const todayFullDate = `${year}-${formattedCurrentMonth}-${String(todayDate).padStart(2, '0')}`; + + const isValidDate = index >= firstDayIndex; + const isHoliday = index % CALENDAR_PROPERTIES.daysInOneWeek === 0; + const isSaturday = index % CALENDAR_PROPERTIES.daysInOneWeek === 6; + const isPrevDate = formattedMonth === formattedCurrentMonth && date < todayDate; + const isToday = fullDate === todayFullDate; + + return { date, fullDate, isValidDate, isToday, isSaturday, isHoliday, isPrevDate } as const; +}; + +export const getMonth = (date: Date) => date.getMonth(); +export const getYear = (date: Date) => date.getFullYear(); +export const getDay = (date: Date) => date.getDay(); +export const getDate = (date: Date) => date.getDate(); +export const getFullDate = (date: Date) => { + const year = getYear(date); + const month = String(getMonth(date) + 1).padStart(2, '0'); + const day = String(getDate(date)).padStart(2, '0'); + + return `${year}-${month}-${day}`; +}; + +export const setFirstDate = (date: Date) => { + const newDate = new Date(date); + newDate.setDate(1); + + return newDate; +}; + +export const getNumberOfWeeks = (date: Date) => { + const firstOfMonth = setFirstDate(date); + const daysInMonth = new Date(getYear(date), getMonth(date) + 1, 0).getDate(); + const dayOfWeek = firstOfMonth.getDay(); + + return Math.ceil((daysInMonth + ((dayOfWeek + 7) % 7)) / 7); +}; + +export const getMonthlyStartIndex = (date: Date) => { + const firstOfMonth = setFirstDate(date); + return (firstOfMonth.getDay() + 7) % 7; +}; + +export const getWeeklyDate = (startDate: Date, currentMonth: number): DateInfo[] => + Array.from({ length: CALENDAR_PROPERTIES.daysInOneWeek }, (_, i) => { + const date = new Date(startDate); + date.setDate(getDate(startDate) + i); + + let status: MonthStatus; + if (getMonth(date) < currentMonth || (getMonth(date) === 11 && currentMonth === 0)) { + status = 'prevMonth'; + } else if (getMonth(date) > currentMonth || (getMonth(date) === 0 && currentMonth === 11)) { + status = 'nextMonth'; + } else { + status = 'currentMonth'; + } + + return { + key: `${date}`, + value: date, + status, + }; + }); + +export const getMonthlyDate = (date: Date): MonthlyDays => { + const numberOfWeeks = getNumberOfWeeks(date); + const monthlyStartDate = setFirstDate(new Date(date)); + monthlyStartDate.setDate(1 - getMonthlyStartIndex(date)); + + return Array.from({ length: numberOfWeeks }, (_, i) => { + const newDate = new Date(monthlyStartDate); + newDate.setDate(getDate(monthlyStartDate) + 7 * i); + + return { + key: getYear(date) * getMonth(date) + i, + value: getWeeklyDate(newDate, getMonth(date)), + }; + }); +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 98481c3bd..3b1a76cd9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "@babel/preset-typescript": "7.24.7", "@chromatic-com/storybook": "1.6.1", "@emotion/babel-plugin": "11.11.0", + "@hyunbinseo/holidays-kr": "3.2025.1", "@storybook/addon-essentials": "8.2.4", "@storybook/addon-interactions": "8.2.4", "@storybook/addon-links": "8.2.4", @@ -44,6 +45,7 @@ "@typescript-eslint/eslint-plugin": "7.16.0", "@typescript-eslint/parser": "7.16.0", "babel-loader": "9.1.3", + "copy-webpack-plugin": "^12.0.2", "dotenv-webpack": "8.1.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-jsx-a11y": "6.9.0", @@ -58,6 +60,7 @@ "msw": "2.3.1", "postcss": "8.4.39", "postcss-styled-syntax": "0.6.4", + "react-lottie": "^1.2.4", "storybook": "8.2.4", "stylelint": "16.6.1", "stylelint-config-clean-order": "6.1.0", @@ -67,9 +70,11 @@ "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.5.3", "undici": "6.19.2", - "webpack": "5.92.1", + "webpack": "5.94.0", + "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", + "webpack-font-preload-plugin": "^1.5.0", "webpack-merge": "6.0.1" } }, @@ -2928,6 +2933,15 @@ "dev": true, "peer": true }, + "node_modules/@hyunbinseo/holidays-kr": { + "version": "3.2025.1", + "resolved": "https://registry.npmjs.org/@hyunbinseo/holidays-kr/-/holidays-kr-3.2025.1.tgz", + "integrity": "sha512-5tdF8VoWzFz9r79d6yglq4V+A5DMYcBe4tM6fwQ4+BkOkdkHPAvplSp94EAilmQmGtFjVL2+ckRcvy3gqKf1Vw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/confirm": { "version": "3.1.15", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.1.15.tgz", @@ -4188,6 +4202,12 @@ "node": ">=14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", + "dev": true + }, "node_modules/@remix-run/router": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.17.1.tgz", @@ -6830,24 +6850,6 @@ "integrity": "sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig==", "dev": true }, - "node_modules/@types/eslint": { - "version": "8.56.10", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", - "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -8453,6 +8455,22 @@ "@babel/core": "^7.0.0" } }, + "node_modules/babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", + "dev": true, + "dependencies": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "node_modules/babel-runtime/node_modules/regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8595,6 +8613,12 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, "node_modules/browserslist": { "version": "4.23.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz", @@ -9298,6 +9322,82 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-webpack-plugin": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", + "dev": true, + "dependencies": { + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.1", + "globby": "^14.0.0", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "dev": true, + "hasInstallScript": true + }, "node_modules/core-js-compat": { "version": "3.37.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", @@ -9719,6 +9819,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", @@ -10144,6 +10250,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/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -10216,9 +10328,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", - "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -12065,7 +12177,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -12220,6 +12331,21 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "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/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -12447,8 +12573,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/html-minifier-terser": { "version": "6.1.0", @@ -16328,6 +16453,12 @@ "loose-envify": "cli.js" } }, + "node_modules/lottie-web": { + "version": "5.12.2", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.12.2.tgz", + "integrity": "sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg==", + "dev": true + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -16670,6 +16801,15 @@ "ufo": "^1.5.3" } }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -17384,6 +17524,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.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -18423,6 +18572,22 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-lottie": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/react-lottie/-/react-lottie-1.2.4.tgz", + "integrity": "sha512-kBGxI+MIZGBf4wZhNCWwHkMcVP+kbpmrLWH/SkO0qCKc7D7eSPcxQbfpsmsCo8v2KCBYjuGSou+xTqK44D/jMg==", + "dev": true, + "dependencies": { + "babel-runtime": "^6.26.0", + "lottie-web": "^5.1.3" + }, + "engines": { + "npm": "^3.0.0" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/react-router": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.24.1.tgz", @@ -19287,6 +19452,20 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.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", @@ -20708,6 +20887,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.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -21428,6 +21616,16 @@ "node": ">= 0.8" } }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "dev": true, + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -21496,11 +21694,10 @@ } }, "node_modules/webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", @@ -21509,7 +21706,7 @@ "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -21541,6 +21738,74 @@ } } }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "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-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "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.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "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", @@ -21754,6 +22019,133 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/webpack-font-preload-plugin": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webpack-font-preload-plugin/-/webpack-font-preload-plugin-1.5.0.tgz", + "integrity": "sha512-/Nh6MNa7/rbu3ZcqSR1SxB+G5XaITu7U2yZO5INTsVRpVlMLQmHQZCoDt4PP+iFyBdvBCDbA0CImRXHarQ0wpQ==", + "dev": true, + "dependencies": { + "jsdom": "^19.0.0", + "webpack-sources": "^3.2.2" + }, + "engines": { + "node": ">= 10.17.0" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/jsdom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", + "integrity": "sha512-RYAyjCbxy/vri/CfnjUWJQQtZ3LKlLnDqj+9XLNnJPgEGeirZs3hllKR20re8LUZ6o1b1X4Jat+Qd26zmP41+A==", + "dev": true, + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.5.0", + "acorn-globals": "^6.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.1", + "decimal.js": "^10.3.1", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^3.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^10.0.0", + "ws": "^8.2.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "node_modules/webpack-font-preload-plugin/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/w3c-xmlserializer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", + "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/whatwg-url": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-10.0.0.tgz", + "integrity": "sha512-CLxxCmdUby142H5FZzn4D8ikO1cmypvXVQktsgosNy4a4BHrDHeciBBGZhb0bNoR5/MltoCatso+vFjjGx8t0w==", + "dev": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/webpack-hot-middleware": { "version": "2.26.1", "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.26.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8bbfa95ed..55440df24 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "@babel/preset-typescript": "7.24.7", "@chromatic-com/storybook": "1.6.1", "@emotion/babel-plugin": "11.11.0", + "@hyunbinseo/holidays-kr": "3.2025.1", "@storybook/addon-essentials": "8.2.4", "@storybook/addon-interactions": "8.2.4", "@storybook/addon-links": "8.2.4", @@ -63,6 +64,7 @@ "@typescript-eslint/eslint-plugin": "7.16.0", "@typescript-eslint/parser": "7.16.0", "babel-loader": "9.1.3", + "copy-webpack-plugin": "^12.0.2", "dotenv-webpack": "8.1.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-jsx-a11y": "6.9.0", @@ -77,6 +79,7 @@ "msw": "2.3.1", "postcss": "8.4.39", "postcss-styled-syntax": "0.6.4", + "react-lottie": "^1.2.4", "storybook": "8.2.4", "stylelint": "16.6.1", "stylelint-config-clean-order": "6.1.0", @@ -86,9 +89,11 @@ "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.5.3", "undici": "6.19.2", - "webpack": "5.92.1", + "webpack": "5.94.0", + "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", + "webpack-font-preload-plugin": "^1.5.0", "webpack-merge": "6.0.1" }, "msw": { diff --git a/frontend/src/apis/meetingConfirm.ts b/frontend/src/apis/meetingConfirm.ts index b333f49b8..3026c0a4d 100644 --- a/frontend/src/apis/meetingConfirm.ts +++ b/frontend/src/apis/meetingConfirm.ts @@ -1,6 +1,7 @@ import { BASE_URL } from '@constants/api'; import { fetchClient } from './_common/fetchClient'; +import type { MeetingType } from './meetings'; export interface ConfirmDates { startDate: string; @@ -11,11 +12,11 @@ export interface ConfirmDates { interface PostMeetingConfirmRequest { uuid: string; - requests: ConfirmDates; } export interface GetConfirmedMeetingInfoResponse extends ConfirmDates { + type: MeetingType; hostName: string; meetingName: string; availableAttendeeNames: string[]; diff --git a/frontend/src/apis/meetingRecommend.ts b/frontend/src/apis/meetingRecommend.ts index def4078cc..d4050e3b7 100644 --- a/frontend/src/apis/meetingRecommend.ts +++ b/frontend/src/apis/meetingRecommend.ts @@ -1,9 +1,10 @@ import { fetchClient } from './_common/fetchClient'; +import type { MeetingType } from './meetings'; interface GetMeetingRecommendRequest { uuid: string; recommendType: string; - attendeeNames: string[] | undefined; + attendeeNames: string[]; } export interface MeetingRecommend { @@ -16,18 +17,25 @@ export interface MeetingRecommend { attendeeNames: string[]; rank: string; } + +export interface GetMeetingRecommendResponse { + type: MeetingType; + recommendedSchedules: MeetingRecommend[]; +} + export const getMeetingTimeRecommends = async ({ uuid, recommendType, attendeeNames, -}: GetMeetingRecommendRequest): Promise => { - if (!attendeeNames) return []; +}: GetMeetingRecommendRequest): Promise => { + const urlParams = new URLSearchParams(); + + urlParams.append('recommendType', recommendType); + if (attendeeNames) urlParams.append('attendeeNames', attendeeNames.join(',')); - const path = `/${uuid}/recommended-schedules?recommendType=${recommendType}&attendeeNames=${attendeeNames.join( - ',', - )}`; + const path = `/${uuid}/recommended-schedules?${urlParams.toString()}`; - const data = await fetchClient({ + const data = await fetchClient({ path, method: 'GET', }); diff --git a/frontend/src/apis/meetings.ts b/frontend/src/apis/meetings.ts index 02fb61280..4493d9bcb 100644 --- a/frontend/src/apis/meetings.ts +++ b/frontend/src/apis/meetings.ts @@ -6,6 +6,7 @@ import { BASE_URL } from '@constants/api'; import { fetchClient } from './_common/fetchClient'; +export type MeetingType = 'DAYSONLY' | 'DATETIME'; interface MeetingBaseResponse { meetingName: string; firstTime: string; @@ -14,6 +15,7 @@ interface MeetingBaseResponse { hostName: string; availableDates: string[]; attendeeNames: string[]; + type: MeetingType; } export interface MeetingBase { @@ -24,6 +26,7 @@ export interface MeetingBase { hostName: string; availableDates: string[]; attendeeNames: string[]; + type: MeetingType; } export interface MeetingRequest { @@ -60,15 +63,17 @@ export const getMeetingBase = async (uuid: string): Promise => { availableDates: data.availableDates, attendeeNames: data.attendeeNames, hostName: data.hostName, + type: data.type, }; }; -interface PostMeetingRequest { +export interface PostMeetingRequest { hostName: string; hostPassword: string; meetingName: string; availableMeetingDates: string[]; meetingStartTime: string; + type: MeetingType; meetingEndTime: string; } @@ -79,6 +84,7 @@ interface PostMeetingResponse { earliestTime: string; lastTime: string; availableDates: string[]; + type: MeetingType; } export const postMeeting = async (request: PostMeetingRequest): Promise => { diff --git a/frontend/src/assets/fonts/Pretendard-Black.woff2 b/frontend/src/assets/fonts/Pretendard-Black.woff2 deleted file mode 100644 index eafe68353..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-Black.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/Pretendard-Bold.subset.woff2 b/frontend/src/assets/fonts/Pretendard-Bold.subset.woff2 new file mode 100644 index 000000000..f80bdb727 Binary files /dev/null and b/frontend/src/assets/fonts/Pretendard-Bold.subset.woff2 differ diff --git a/frontend/src/assets/fonts/Pretendard-Bold.woff2 b/frontend/src/assets/fonts/Pretendard-Bold.woff2 deleted file mode 100644 index 4d40a1ab8..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-Bold.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/Pretendard-ExtraBold.woff2 b/frontend/src/assets/fonts/Pretendard-ExtraBold.woff2 deleted file mode 100644 index dcd57e757..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-ExtraBold.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/Pretendard-ExtraLight.woff2 b/frontend/src/assets/fonts/Pretendard-ExtraLight.woff2 deleted file mode 100644 index e5104022b..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-ExtraLight.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/Pretendard-Light.subset.woff2 b/frontend/src/assets/fonts/Pretendard-Light.subset.woff2 new file mode 100644 index 000000000..5d4fa6c1b Binary files /dev/null and b/frontend/src/assets/fonts/Pretendard-Light.subset.woff2 differ diff --git a/frontend/src/assets/fonts/Pretendard-Light.woff2 b/frontend/src/assets/fonts/Pretendard-Light.woff2 deleted file mode 100644 index 7f82fe847..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-Light.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/Pretendard-Medium.subset.woff2 b/frontend/src/assets/fonts/Pretendard-Medium.subset.woff2 new file mode 100644 index 000000000..941998059 Binary files /dev/null and b/frontend/src/assets/fonts/Pretendard-Medium.subset.woff2 differ diff --git a/frontend/src/assets/fonts/Pretendard-Medium.woff2 b/frontend/src/assets/fonts/Pretendard-Medium.woff2 deleted file mode 100644 index f8c743d60..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-Medium.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/Pretendard-Regular.subset.woff2 b/frontend/src/assets/fonts/Pretendard-Regular.subset.woff2 new file mode 100644 index 000000000..6fc8ec42f Binary files /dev/null and b/frontend/src/assets/fonts/Pretendard-Regular.subset.woff2 differ diff --git a/frontend/src/assets/fonts/Pretendard-Regular.woff2 b/frontend/src/assets/fonts/Pretendard-Regular.woff2 deleted file mode 100644 index a9f62319b..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-Regular.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/Pretendard-SemiBold.woff2 b/frontend/src/assets/fonts/Pretendard-SemiBold.woff2 deleted file mode 100644 index 4c6a32de1..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-SemiBold.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/Pretendard-Thin.woff2 b/frontend/src/assets/fonts/Pretendard-Thin.woff2 deleted file mode 100644 index 6c9bc9604..000000000 Binary files a/frontend/src/assets/fonts/Pretendard-Thin.woff2 and /dev/null differ diff --git a/frontend/src/assets/images/kakao.svg b/frontend/src/assets/images/kakao.svg index 19936ee48..5cb4ce3b0 100644 --- a/frontend/src/assets/images/kakao.svg +++ b/frontend/src/assets/images/kakao.svg @@ -4,6 +4,6 @@ - - + + \ No newline at end of file diff --git a/frontend/src/assets/images/logo.svg b/frontend/src/assets/images/logo.svg index e70dce3e5..051997a82 100644 --- a/frontend/src/assets/images/logo.svg +++ b/frontend/src/assets/images/logo.svg @@ -1,9 +1,9 @@ - + - - + + - - + + \ No newline at end of file diff --git a/frontend/src/assets/images/logoSunglass.svg b/frontend/src/assets/images/logoSunglass.svg index c6ae2dd65..4df236bc2 100644 --- a/frontend/src/assets/images/logoSunglass.svg +++ b/frontend/src/assets/images/logoSunglass.svg @@ -1,14 +1,4 @@ - - - - - - - - - - - - - + + + diff --git a/frontend/src/assets/images/momoCharacter.svg b/frontend/src/assets/images/momoCharacter.svg index 6836768bf..a149256a8 100644 --- a/frontend/src/assets/images/momoCharacter.svg +++ b/frontend/src/assets/images/momoCharacter.svg @@ -1,10 +1,9 @@ - - + - + diff --git a/frontend/src/assets/images/momoPageLoading.json b/frontend/src/assets/images/momoPageLoading.json new file mode 100644 index 000000000..44481687a --- /dev/null +++ b/frontend/src/assets/images/momoPageLoading.json @@ -0,0 +1,434 @@ +{ + "nm": "Main Scene", + "ddd": 0, + "h": 256, + "w": 256, + "meta": { "g": "@lottiefiles/creator 1.26.0" }, + "layers": [ + { + "ty": 4, + "nm": "Shape Layer 3", + "sr": 1, + "st": 0, + "op": 300.00001221925, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-70, -0.5, 0] }, + "s": { "a": 0, "k": [75, 75, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [208.6, 127.969, 0], + "t": 20 + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [208.6, 88, 0], + "t": 30 + }, + { "s": [208.6, 128, 0], "t": 40.0000016292334 } + ] + }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [33.75, 34.5] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [1, 0.2784, 0.4471] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0.658333333333303, 0.041333333333341216] }, + "s": { "a": 0, "k": [100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [-69.4666666666667, -0.4586666666666588] }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + } + ] + } + ], + "ind": 1 + }, + { + "ty": 4, + "nm": "Shape Layer 2", + "sr": 1, + "st": 0, + "op": 300.00001221925, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-70, -0.5, 0] }, + "s": { "a": 0, "k": [75, 75, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [168.6, 128, 0], + "t": 15 + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [168.6, 88, 0], + "t": 25 + }, + { "s": [168.6, 128, 0], "t": 35.0000014255792 } + ] + }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [33.75, 34.5] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [1, 0.4392, 0.5725] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [-70.125, -0.5] }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + } + ] + } + ], + "ind": 2 + }, + { + "ty": 4, + "nm": "Shape Layer 1", + "sr": 1, + "st": 0, + "op": 300.00001221925, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-70, -0.5, 0] }, + "s": { "a": 0, "k": [75, 75, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [128.594, 127.969, 0], + "t": 10 + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [128.594, 88, 0], + "t": 20 + }, + { "s": [128.594, 128, 0], "t": 30.0000012219251 } + ] + }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [33.75, 34.5] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [1, 0.5451, 0.6549] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [-70.125, -0.5] }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + } + ] + } + ], + "ind": 3 + }, + { + "ty": 4, + "nm": "Shape Layer 4", + "sr": 1, + "st": 0, + "op": 300.00001221925, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-70, -0.5, 0] }, + "s": { "a": 0, "k": [75, 75, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [88.6, 127.969, 0], + "t": 5 + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [88.6, 88, 0], + "t": 15 + }, + { "s": [88.6, 128, 0], "t": 25.0000010182709 } + ] + }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [33.75, 34.5] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [1, 0.7608, 0.8157] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [-70.125, -0.5] }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + } + ] + } + ], + "ind": 4 + }, + { + "ty": 4, + "nm": "Shape Layer 5", + "sr": 1, + "st": 0, + "op": 300.00001221925, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-70, -0.5, 0] }, + "s": { "a": 0, "k": [75, 75, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [48.6, 127.969, 0], + "t": 0 + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [48.6, 88, 0], + "t": 10 + }, + { "s": [48.6, 128, 0], "t": 20.0000008146167 } + ] + }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [33.75, 34.5] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [1, 0.8392, 0.8784] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [-70.125, -0.5] }, + "r": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + } + ] + } + ], + "ind": 5 + } + ], + "v": "5.7.0", + "fr": 29.9700012207031, + "op": 41, + "ip": 0, + "assets": [] +} diff --git a/frontend/src/assets/images/questionMomoCharacter.svg b/frontend/src/assets/images/questionMomoCharacter.svg index 59e923912..67a23d558 100644 --- a/frontend/src/assets/images/questionMomoCharacter.svg +++ b/frontend/src/assets/images/questionMomoCharacter.svg @@ -1,15 +1,12 @@ - - - - + + - + - - + diff --git a/frontend/src/assets/images/sadMomoCharacter.svg b/frontend/src/assets/images/sadMomoCharacter.svg deleted file mode 100644 index 853ba2563..000000000 --- a/frontend/src/assets/images/sadMomoCharacter.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/src/components/AttendeeTooltip/index.tsx b/frontend/src/components/AttendeeTooltip/index.tsx index f0239d081..48a84e1da 100644 --- a/frontend/src/components/AttendeeTooltip/index.tsx +++ b/frontend/src/components/AttendeeTooltip/index.tsx @@ -33,18 +33,7 @@ export default function AttendeeTooltip({ attendeeNames, position }: AttendeeToo } visibleStyles={css` - background-image: linear-gradient( - 45deg, - #33272a 25%, - transparent 25%, - transparent 50%, - #33272a 50%, - #33272a 75%, - transparent 75%, - transparent - ); - background-size: 0.8rem 0.8rem; - border: 0.2rem solid #33272a; + border: 0.3rem dashed #71717a; `} >
diff --git a/frontend/src/components/MeetingCalendar/Date/Date.styles.ts b/frontend/src/components/MeetingCalendar/Date/Date.styles.ts new file mode 100644 index 000000000..4499c3b6b --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/Date.styles.ts @@ -0,0 +1,166 @@ +import type { SerializedStyles } from '@emotion/react'; +import { css } from '@emotion/react'; +import type { FlagObject } from 'types/utility'; + +import { isValidArrayType } from '@utils/typeGuards'; + +import theme from '@styles/theme'; + +export const s_dateContainer = css` + width: 100%; + min-width: 4.8rem; + height: 4.8rem; +`; + +export const s_baseDateButton = css` + cursor: pointer; + + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + border: none; + + &:disabled { + cursor: default; + } +`; + +// 하나씩 선택하는 경우와, 시작/끝 기간으로 선택하는 경우 스타일을 구분. +// 하나로 합쳐서 스타일 함수에서 분기 처리 하는 것 보다, 이름으로 스타일 책임을 구분할 수 있는 방법을 선택. (@해리) + +export const s_singleDateButton = (isSelectedDate: boolean) => css` + background-color: ${isSelectedDate ? theme.colors.pink.medium : 'transparent'}; + border-radius: 0.8rem; +`; + +export const s_rangeDateButton = (isSelectedDate: boolean) => css` + background-color: ${isSelectedDate ? theme.colors.pink.light : 'transparent'}; +`; + +export const s_baseDateText = css` + ${theme.typography.bodyLight} +`; + +const DAY_SLOT_TEXT_STYLES = { + selected: css` + color: ${theme.colors.calendar.color.selected}; + `, + holiday: css` + color: ${theme.colors.calendar.color.holiday}; + `, + today: css` + color: ${theme.colors.calendar.color.today}; + `, + saturday: css` + color: #8c9eff; + `, + disabled: css` + color: ${theme.colors.grey.primary}; + `, + default: css` + color: ${theme.colors.black}; + `, +}; + +type DateStatus = + | 'isSelectedDate' + | 'isDisabledDate' + | 'isSunday' + | 'isSaturday' + | 'isHoliday' + | 'isToday'; + +const dateStatusStyleMap: Record = { + isSelectedDate: DAY_SLOT_TEXT_STYLES.selected, + isDisabledDate: DAY_SLOT_TEXT_STYLES.disabled, + isToday: DAY_SLOT_TEXT_STYLES.today, + isHoliday: DAY_SLOT_TEXT_STYLES.holiday, + isSunday: DAY_SLOT_TEXT_STYLES.holiday, + isSaturday: DAY_SLOT_TEXT_STYLES.saturday, +}; + +export const s_dateText = (dateStatusMap: FlagObject) => { + // key가 dateStatusMap에 속하는 타입인지 확정할 수 없는 문제 발생 -> type guard 함수로 해결(@해리) + const dateStatusArray = Object.keys(dateStatusMap); + if (!isValidArrayType(Object.keys(dateStatusMap), dateStatusArray)) return; + + const status = dateStatusArray.find((key) => dateStatusMap[key]); + + return status ? dateStatusStyleMap[status] : DAY_SLOT_TEXT_STYLES.default; +}; + +export const s_dateExtraInfoText = css` + font-size: 1rem; + font-weight: 300; + line-height: 1.2; +`; + +export const s_rangeStart = (isAllRangeSelected: boolean) => css` + background-color: ${theme.colors.pink.medium}; + + &::before { + content: ''; + + position: absolute; + top: 0; + right: 0; + bottom: 0; + + width: 20%; + height: 100%; + + background-color: ${isAllRangeSelected ? theme.colors.pink.light : theme.colors.white}; + } + + &::after { + content: ''; + + position: absolute; + top: 0; + right: 0.4px; + bottom: 0; + + width: 20%; + height: 100%; + + background-color: ${theme.colors.pink.medium}; + clip-path: polygon(0 0, 100% 50%, 0 100%); + } +`; + +export const s_rangeEnd = (isAllRangeSelected: boolean) => css` + background-color: ${theme.colors.pink.medium}; + + &::before { + content: ''; + + position: absolute; + top: 0; + bottom: 0; + left: 0; + + width: 20%; + height: 100%; + + background-color: ${isAllRangeSelected ? theme.colors.pink.light : theme.colors.white}; + } + + &::after { + content: ''; + + position: absolute; + top: 0; + bottom: 0; + left: 0.4px; + + width: 20%; + height: 100%; + + background-color: ${theme.colors.pink.medium}; + clip-path: polygon(100% 0, 0 50%, 100% 100%); + } +`; diff --git a/frontend/src/components/MeetingCalendar/Date/Date.utils.ts b/frontend/src/components/MeetingCalendar/Date/Date.utils.ts new file mode 100644 index 000000000..93a14dad8 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/Date.utils.ts @@ -0,0 +1,38 @@ +import { getHolidayNames } from '@hyunbinseo/holidays-kr'; + +import { getDate, getDay, getFullDate } from '@utils/date'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const getDateInfo = (targetDate: Date, today: Date) => { + const targetDateNumber = getDate(targetDate); + const targetDayOfWeek = getDay(targetDate); + const targetFullDate = getFullDate(targetDate); + + const todayFullDate = getFullDate(today); + + const holidayNames = getHolidayNames(targetDate); + /* + 현재 사용하고 있는 라이브러리는 하루에 공휴일이 겹칠 수 있는 경우를 대비하기 위해서 string[] 형태로 공휴일들을 관리하고 있다. + -> 그래서, 공휴일이 있는 경우 첫 번째 공휴일을 반환하도록 했다. + -> 네이버나 다른 서비스의 달력들을 참고했을 때 하루에 공휴일이 겹치는 경우 하나만 보여주는 것을 확인했다. + -> 여러개를 모두 보여주는 경우, 레이아웃이 달라질 수 있기 때문에 하나만 보여주는 것으로 결정. (@해리) + */ + const formattedHolidayName = holidayNames ? holidayNames[0] : null; + const isHoliday = formattedHolidayName !== null; + const isSaturday = targetDayOfWeek === CALENDAR_PROPERTIES.saturdayNumber; + const isSunday = targetDayOfWeek === CALENDAR_PROPERTIES.sundayNumber; + const isPrevDate = targetFullDate < todayFullDate; + const isToday = targetFullDate === todayFullDate; + + return { + date: targetDateNumber, + targetFullDate, + isHoliday, + isToday, + isSunday, + isSaturday, + isPrevDate, + holidayName: formattedHolidayName, + } as const; +}; diff --git a/frontend/src/components/MeetingCalendar/Date/DateAdditionalText.tsx b/frontend/src/components/MeetingCalendar/Date/DateAdditionalText.tsx new file mode 100644 index 000000000..8d7352b86 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/DateAdditionalText.tsx @@ -0,0 +1,36 @@ +import { s_dateExtraInfoText } from './Date.styles'; + +const DATE_INFO_TEXTS = { + today: '오늘', + rangeStart: '시작', + rangeEnd: '끝', + default: '\u00A0', +} as const; + +interface DateInfoProps { + isToday?: boolean; + isRangeStart?: boolean; + isRangeEnd?: boolean; + holidayName: string | null; +} + +const getDateInfoText = ({ isToday, isRangeStart, isRangeEnd, holidayName }: DateInfoProps) => { + if (isRangeStart) return DATE_INFO_TEXTS.rangeStart; + if (isRangeEnd) return DATE_INFO_TEXTS.rangeEnd; + if (isToday) return DATE_INFO_TEXTS.today; + if (holidayName) return holidayName; + return DATE_INFO_TEXTS.default; +}; + +export default function DateAdditionalText({ + isToday, + isRangeStart, + isRangeEnd, + holidayName, +}: DateInfoProps) { + return ( + + {getDateInfoText({ isToday, isRangeStart, isRangeEnd, holidayName })} + + ); +} diff --git a/frontend/src/components/MeetingCalendar/Date/RangeDate.tsx b/frontend/src/components/MeetingCalendar/Date/RangeDate.tsx new file mode 100644 index 000000000..44830ed52 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/RangeDate.tsx @@ -0,0 +1,84 @@ +import type { DateInfo } from 'types/calendar'; + +import { + s_baseDateButton, + s_baseDateText, + s_dateContainer, + s_dateText, + s_rangeDateButton, + s_rangeEnd, + s_rangeStart, +} from './Date.styles'; +import { getDateInfo } from './Date.utils'; +import DateAdditionalText from './DateAdditionalText'; + +interface RangeDateProps { + dateInfo: DateInfo; + today: Date; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; + isRangeStart: boolean; + isRangeEnd: boolean; + isAllRangeSelected: boolean; +} + +export default function RangeDate({ + dateInfo, + today, + hasDate, + onDateClick, + isRangeStart, + isRangeEnd, + isAllRangeSelected, +}: RangeDateProps) { + const { key, value, status } = dateInfo; + /* + - 오늘인지 아닌지 판단하기 위해서, new Date()를 어디서 호출해야 할지 고민. + -> getDateInfo를 호출할 때마다 인자로 new Date() 생성자 함수를 호출하든, getDateInfo 내부에서 호출하든 '오늘' 정보를 가지고 있는 인스턴스를 중복해서 생성한다고 판단. + -> 따라서 한 번만 호출하기 위해서 날짜 데이터 관련 책임을 가지는 useCalendar 커스텀 훅에서 '오늘' 인스턴스를 생성 후 반환하고, props로 전달받아서 재사용하는 것으로 결정.(@해리) + */ + const { + date, + targetFullDate, + isHoliday, + isToday, + isSaturday, + isSunday, + isPrevDate, + holidayName, + } = getDateInfo(value, today); + const isSelectedDate = hasDate(targetFullDate); + + return status === 'current' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/src/components/MeetingCalendar/Date/SingleDate.tsx b/frontend/src/components/MeetingCalendar/Date/SingleDate.tsx new file mode 100644 index 000000000..725008872 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/SingleDate.tsx @@ -0,0 +1,64 @@ +import type { DateInfo } from 'types/calendar'; + +import { + s_baseDateButton, + s_baseDateText, + s_dateContainer, + s_dateText, + s_singleDateButton, +} from './Date.styles'; +import { getDateInfo } from './Date.utils'; +import DateAdditionalText from './DateAdditionalText'; + +interface SingleCalendarDateProps { + dateInfo: DateInfo; + today: Date; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; +} + +export default function SingleDate({ + dateInfo, + today, + hasDate, + onDateClick, +}: SingleCalendarDateProps) { + const { key, value, status } = dateInfo; + const { + date, + targetFullDate, + isHoliday, + isToday, + isSaturday, + isSunday, + isPrevDate, + holidayName, + } = getDateInfo(value, today); + const isSelectedDate = hasDate(targetFullDate); + + return status === 'current' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/src/components/MeetingCalendar/Header/MeetingCalendarHeader.styles.ts b/frontend/src/components/MeetingCalendar/Header/MeetingCalendarHeader.styles.ts new file mode 100644 index 000000000..46e8070af --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Header/MeetingCalendarHeader.styles.ts @@ -0,0 +1,47 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_monthHeader = css` + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + height: 3.6rem; + margin-bottom: 1rem; +`; + +export const s_monthNavigationContainer = css` + display: flex; + gap: 2.4rem; + align-items: center; + height: 100%; + + ${theme.typography.bodyMedium} +`; + +export const s_monthNavigation = css` + cursor: pointer; + background-color: transparent; + border: none; + + ${theme.typography.titleMedium} + + &:disabled { + color: ${theme.colors.grey.primary}; + } +`; + +export const s_dateSelectModeTabButtonContainer = css` + display: flex; + gap: 0.4rem; + align-items: center; +`; + +// 2024 1월, 2024 12월을 그릴 때 텍스트의 너비가 달라져서 양 옆 버튼의 위치가 변경되는 문제를 해결하기 위해 고정 너비 적용. (@해리) +export const s_yearMonthText = css` + display: inline-block; + min-width: 12rem; + text-align: center; +`; diff --git a/frontend/src/components/MeetingCalendar/Header/index.tsx b/frontend/src/components/MeetingCalendar/Header/index.tsx new file mode 100644 index 000000000..52a6d313c --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Header/index.tsx @@ -0,0 +1,69 @@ +import type { DateSelectMode } from 'types/calendar'; + +import TabButton from '@components/_common/Buttons/TabButton'; + +import { s_monthNavigation } from './MeetingCalendarHeader.styles'; +import { + s_dateSelectModeTabButtonContainer, + s_monthHeader, + s_monthNavigationContainer, + s_yearMonthText, +} from './MeetingCalendarHeader.styles'; + +interface MeetingCalendarHeaderProps { + currentYear: number; + currentMonth: number; + moveToNextMonth: () => void; + moveToPrevMonth: () => void; + isCurrentMonth?: boolean; + dateSelectMode: DateSelectMode; + toggleDateSelectMode: (mode: DateSelectMode) => void; +} + +export default function MeetingCalendarHeader({ + currentYear, + currentMonth, + moveToNextMonth, + moveToPrevMonth, + isCurrentMonth, + dateSelectMode, + toggleDateSelectMode, +}: MeetingCalendarHeaderProps) { + return ( +
+
+ + + {currentYear}년 {currentMonth + 1}월 + + +
+
+ toggleDateSelectMode('single')} + aria-label="하나씩 선택하기" + > + 하나씩 + +

/

+ toggleDateSelectMode('range')} + aria-label="기간으로 선택하기" + > + 기간 + +
+
+ ); +} diff --git a/frontend/src/components/MeetingCalendar/Weekdays/MeetingCalendarWeekdays.styles.ts b/frontend/src/components/MeetingCalendar/Weekdays/MeetingCalendarWeekdays.styles.ts new file mode 100644 index 000000000..716b7ee21 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Weekdays/MeetingCalendarWeekdays.styles.ts @@ -0,0 +1,67 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const s_calendarContent = css` + display: grid; + grid-template-columns: repeat(7, 1fr); + width: 100%; +`; + +export const s_dayOfWeekContainer = css` + margin-bottom: 2rem; +`; + +export const s_baseDayOfWeek = css` + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + min-width: 4rem; + height: 4rem; + min-height: 4rem; + + ${theme.typography.bodyMedium} +`; + +export const s_dayOfWeek = (index: number) => { + if (index === CALENDAR_PROPERTIES.sundayNumber) + return css` + ${DAY_SLOT_TEXT_STYLES.holiday} + `; + + if (index === CALENDAR_PROPERTIES.saturdayNumber) + return css` + ${DAY_SLOT_TEXT_STYLES.saturday} + `; + + return css` + ${DAY_SLOT_TEXT_STYLES.default} + `; +}; + +// 현재 DAY_SLOT_TEXT_STYLES를 중복해서 정의하는 곳이 두 곳이 있음. +// MeetingCalendar.common.styles.ts 을 생성하고, 약속 달력 컴포넌트에서 중복해서 사용하는 스타일을 정의할까 고민 중.(@해리) +const DAY_SLOT_TEXT_STYLES = { + selected: css` + color: ${theme.colors.calendar.color.selected}; + `, + holiday: css` + color: ${theme.colors.calendar.color.holiday}; + `, + today: css` + color: ${theme.colors.calendar.color.today}; + `, + saturday: css` + color: #8c9eff; + `, + prevDay: css` + color: ${theme.colors.grey.primary}; + `, + default: css` + color: ${theme.colors.black}; + `, +}; diff --git a/frontend/src/components/MeetingCalendar/Weekdays/index.tsx b/frontend/src/components/MeetingCalendar/Weekdays/index.tsx new file mode 100644 index 000000000..138795c41 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Weekdays/index.tsx @@ -0,0 +1,22 @@ +import { + s_baseDayOfWeek, + s_calendarContent, + s_dayOfWeek, + s_dayOfWeekContainer, +} from './MeetingCalendarWeekdays.styles'; + +interface MeetingCalendarWeekdaysProps { + weekdays: string[]; +} + +export default function MeetingCalendarWeekdays({ weekdays }: MeetingCalendarWeekdaysProps) { + return ( +
+ {weekdays.map((day, index) => ( +
+ {day} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/MeetingConfirmCalendar/Header/Header.styles.ts b/frontend/src/components/MeetingConfirmCalendar/Header/Header.styles.ts new file mode 100644 index 000000000..65bfeefca --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/Header/Header.styles.ts @@ -0,0 +1,11 @@ +import { css } from '@emotion/react'; + +export const s_container = css` + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 3.6rem; + margin-bottom: 1rem; +`; diff --git a/frontend/src/components/MeetingConfirmCalendar/Header/Header.tsx b/frontend/src/components/MeetingConfirmCalendar/Header/Header.tsx new file mode 100644 index 000000000..e9bae83d5 --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/Header/Header.tsx @@ -0,0 +1,48 @@ +import { + s_monthNavigation, + s_monthNavigationContainer, + s_yearMonthText, +} from '@components/MeetingCalendar/Header/MeetingCalendarHeader.styles'; + +import { s_container } from './Header.styles'; + +// 해당 인터페이스 기본 CalendarHeader에서도 사용되는데 통합되는 것이 좋아보입니다.(@낙타) +interface HeaderProps { + currentYear: number; + currentMonth: number; + moveToNextMonth: () => void; + moveToPrevMonth: () => void; + isCurrentMonth?: boolean; +} + +// CSS는 대부분 MeetingCalendar를 이용했습니다. +// 추후 리팩터링을 진행할 때, '<'버튼과 '>' 버튼을 없앨지 고민중인데, 로직 분기 고민 + 일관된 사용성을 위해서 유지하기로 결정했습니다.(@낙타) +export default function Header({ + currentYear, + currentMonth, + moveToNextMonth, + moveToPrevMonth, + isCurrentMonth, +}: HeaderProps) { + return ( +
+
+ + + + {currentYear}년 {currentMonth + 1}월 + + +
+
+ ); +} diff --git a/frontend/src/components/MeetingConfirmCalendar/Picker/index.tsx b/frontend/src/components/MeetingConfirmCalendar/Picker/index.tsx new file mode 100644 index 000000000..86d18e2ce --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/Picker/index.tsx @@ -0,0 +1,86 @@ +import { useContext } from 'react'; +import { useParams } from 'react-router-dom'; + +import { AuthContext } from '@contexts/AuthProvider'; +import { TimePickerUpdateStateContext } from '@contexts/TimePickerUpdateStateProvider'; + +import { + s_bottomFixedButtonContainer, + s_fullButtonContainer, +} from '@components/Schedules/Schedules.styles'; +import { Button } from '@components/_common/Buttons/Button'; +import Calendar from '@components/_common/Calendar'; + +import useCalendarPick from '@hooks/useCalendarPick/useCalendarPick'; + +import { usePostScheduleMutation } from '@stores/servers/schedule/mutations'; + +import { getFullDate } from '@utils/date'; + +import Header from '../Header/Header'; +import SingleDate from '../SingleDate/SingleDate'; +import WeekDays from '../WeekDays'; + +interface PickerProps { + availableDates: string[]; +} + +export default function Picker({ availableDates }: PickerProps) { + const params = useParams<{ uuid: string }>(); + const uuid = params.uuid!; + const { userName } = useContext(AuthContext).state; + + const { handleToggleIsTimePickerUpdate } = useContext(TimePickerUpdateStateContext); + + const { selectedDates, hasDate, handleSelectedDate } = useCalendarPick(uuid, userName); + + const { mutate: postScheduleMutate, isPending } = usePostScheduleMutation(() => + handleToggleIsTimePickerUpdate(), + ); + + // 백엔드에 날짜 데이터 보내주기 위해 임시로 generate함수 선언(@낙타) + const generateScheduleTable = (dates: string[]) => { + return dates.map((date) => { + return { + date, + times: ['00:00'], + }; + }); + }; + + const handleOnToggle = () => { + postScheduleMutate({ uuid, requestData: generateScheduleTable(selectedDates) }); + }; + + return ( + <> + +
} /> + } /> + ( + + )} + /> + + +
+
+ + +
+
+ + ); +} diff --git a/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDate.styles.ts b/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDate.styles.ts new file mode 100644 index 000000000..458c7fc51 --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDate.styles.ts @@ -0,0 +1,12 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_additionalText = css` + ${theme.typography.captionBold} + color: ${theme.colors.black} +`; + +export const s_viewer = css` + background-color: transparent; +`; diff --git a/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDate.tsx b/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDate.tsx new file mode 100644 index 000000000..c44298c3f --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDate.tsx @@ -0,0 +1,63 @@ +import type { DateInfo } from 'types/calendar'; + +import { + s_baseDateButton, + s_baseDateText, + s_dateContainer, + s_dateText, + s_singleDateButton, +} from '@components/MeetingCalendar/Date/Date.styles'; +import { getDateInfo } from '@components/MeetingCalendar/Date/Date.utils'; + +import Check from '@assets/images/attendeeCheck.svg'; + +import { s_additionalText } from './SingleDate.styles'; + +interface DateProps { + dateInfo: DateInfo; + today: Date; + isAvailable: boolean; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; +} + +export default function SingleDate({ + dateInfo, + today, + isAvailable, + hasDate, + onDateClick, +}: DateProps) { + const { value, status } = dateInfo; + const { date, targetFullDate, isHoliday, isToday, isSaturday, isSunday, isPrevDate } = + getDateInfo(value, today); + + const isSelectedDate = hasDate(targetFullDate); + + return status === 'current' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDateViewer.tsx b/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDateViewer.tsx new file mode 100644 index 000000000..9eb8d89b6 --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/SingleDate/SingleDateViewer.tsx @@ -0,0 +1,69 @@ +import type { DateInfo } from 'types/calendar'; + +import AttendeeTooltip from '@components/AttendeeTooltip'; +import { + s_baseDateButton, + s_baseDateText, + s_dateContainer, + s_dateText, +} from '@components/MeetingCalendar/Date/Date.styles'; +import { getDateInfo } from '@components/MeetingCalendar/Date/Date.utils'; + +import Check from '@assets/images/attendeeCheck.svg'; + +import { s_additionalText, s_viewer } from './SingleDate.styles'; + +interface DateProps { + dateInfo: DateInfo; + today: Date; + isAvailable: boolean; + selectAttendee: string; + availableAttendees: string[] | undefined; + key: string; +} + +export default function SingleDateViewer({ + dateInfo, + today, + isAvailable, + selectAttendee, + availableAttendees, +}: DateProps) { + const { value, status } = dateInfo; + const { date, isHoliday, isToday, isSaturday, isSunday, isPrevDate } = getDateInfo(value, today); + + const additionalText = () => { + if (!availableAttendees) return '\u00A0'; + if (selectAttendee === '' && availableAttendees) return `+${availableAttendees.length}`; + if (selectAttendee !== '' && availableAttendees) return ; + }; + + const renderTooltip = () => + selectAttendee === '' && + availableAttendees && ; + + return status === 'current' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/src/components/MeetingConfirmCalendar/Viewer/index.tsx b/frontend/src/components/MeetingConfirmCalendar/Viewer/index.tsx new file mode 100644 index 000000000..2b7f68d2b --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/Viewer/index.tsx @@ -0,0 +1,139 @@ +import { useContext, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import type { MeetingAllSchedules, MeetingSingleSchedule } from 'types/schedule'; + +import { AuthContext } from '@contexts/AuthProvider'; +import { TimePickerUpdateStateContext } from '@contexts/TimePickerUpdateStateProvider'; + +import { s_attendeesContainer } from '@pages/MeetingConfirmPage/MeetingTimeConfirmPage.styles'; + +import { + s_bottomFixedButtonContainer, + s_circleButton, + s_fullButtonContainer, +} from '@components/Schedules/Schedules.styles'; +import { Button } from '@components/_common/Buttons/Button'; +import TabButton from '@components/_common/Buttons/TabButton'; +import Calendar from '@components/_common/Calendar'; + +import { useGetSchedules } from '@stores/servers/schedule/queries'; + +import { getFullDate } from '@utils/date'; + +import Check from '@assets/images/attendeeCheck.svg'; +import Pen from '@assets/images/pen.svg'; + +import Header from '../Header/Header'; +import SingleDateViewer from '../SingleDate/SingleDateViewer'; +import WeekDays from '../WeekDays'; + +interface ViewerProps { + availableDates: string[]; + meetingAttendees: string[]; + hostName: string; + isLocked: boolean; +} + +export default function Viewer({ + availableDates, + meetingAttendees, + hostName, + isLocked, +}: ViewerProps) { + const navigate = useNavigate(); + const params = useParams<{ uuid: string }>(); + const uuid = params.uuid!; + const [selectedAttendee, setSelectedAttendee] = useState(''); + + const { data: meetingSchedules } = useGetSchedules(uuid, selectedAttendee); + const { handleToggleIsTimePickerUpdate } = useContext(TimePickerUpdateStateContext); + const { isLoggedIn, userName } = useContext(AuthContext).state; + + const handleScheduleUpdate = () => { + if (!isLoggedIn) { + alert('로그인 해주세요'); + navigate(`/meeting/${uuid}/login`); + return; + } + + handleToggleIsTimePickerUpdate(); + }; + + const availableAttendees = (currentDate: string) => { + if (!meetingSchedules) return; + + if (selectedAttendee === '') { + const schedules = meetingSchedules as MeetingAllSchedules; + + return schedules.schedules.find(({ date }) => date === currentDate)?.attendeeNames; + } else { + const schedules = meetingSchedules as MeetingSingleSchedule; + + return schedules.schedules.map(({ date }) => date).includes(currentDate) + ? [schedules.attendeeName] + : undefined; + } + }; + + return ( + meetingSchedules && ( + <> +
+ setSelectedAttendee('')} + isActive={selectedAttendee === ''} + > + {selectedAttendee === '' && } + 전체 + + {meetingAttendees.map((attendee) => ( + setSelectedAttendee(attendee)} + isActive={selectedAttendee === attendee} + > + {selectedAttendee === attendee && } + {attendee} + + ))} +
+ + +
} /> + } /> + ( + + )} + /> + + +
+
+ {hostName === userName ? ( + + ) : ( + + )} +
+ +
+ + ) + ); +} diff --git a/frontend/src/components/MeetingConfirmCalendar/WeekDays.tsx b/frontend/src/components/MeetingConfirmCalendar/WeekDays.tsx new file mode 100644 index 000000000..ef0a1b45b --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/WeekDays.tsx @@ -0,0 +1,22 @@ +import { + s_baseDayOfWeek, + s_calendarContent, + s_dayOfWeek, + s_dayOfWeekContainer, +} from '@components/MeetingCalendar/Weekdays/MeetingCalendarWeekdays.styles'; + +interface WeekDaysProps { + weekdays: string[]; +} + +export default function WeekDays({ weekdays }: WeekDaysProps) { + return ( +
+ {weekdays.map((day, index) => ( +
+ {day} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/MeetingConfirmCalendar/index.tsx b/frontend/src/components/MeetingConfirmCalendar/index.tsx new file mode 100644 index 000000000..8378fcb05 --- /dev/null +++ b/frontend/src/components/MeetingConfirmCalendar/index.tsx @@ -0,0 +1,5 @@ +import Picker from './Picker'; +import Viewer from './Viewer'; + +const MeetingConfirmCalendar = { Viewer, Picker }; +export default MeetingConfirmCalendar; diff --git a/frontend/src/components/MeetingTimeCard/MeetingTimeOptionCardDaysOnly.tsx b/frontend/src/components/MeetingTimeCard/MeetingTimeOptionCardDaysOnly.tsx new file mode 100644 index 000000000..222156500 --- /dev/null +++ b/frontend/src/components/MeetingTimeCard/MeetingTimeOptionCardDaysOnly.tsx @@ -0,0 +1,72 @@ +import { formatFullDate } from '@utils/date'; + +import { + s_attendeeInfo, + s_baseContainer, + s_checkboxContainer, + s_checkboxInput, + s_dateInfo, + s_getSelectedStyle, + s_optionContainer, + s_recommendContainer, +} from './MeetingTimeCard.styles'; + +export interface DateInfo { + fullDate: string; + time: string; + dayOfWeek: string; +} + +interface MeetingTimeOptionCardProps { + isSelected: boolean; + onSelect: () => void; + attendeeCount: number; + schedule: { + startDate: string; + startDayOfWeek: string; + startTime: string; + endDate: string; + endDayOfWeek: string; + endTime: string; + attendeeNames: string[]; + }; +} + +export default function MeetingTimeOptionCardDaysOnly({ + isSelected, + onSelect, + attendeeCount, + schedule, +}: MeetingTimeOptionCardProps) { + const { startDate, startDayOfWeek, endDate, endDayOfWeek, attendeeNames } = schedule; + + const startDateWithDay = formatFullDate({ + fullDate: startDate, + dayOfWeek: startDayOfWeek, + format: 'korean', + }); + + const endDateWithDay = formatFullDate({ + fullDate: endDate, + dayOfWeek: endDayOfWeek, + format: 'korean', + }); + + const currentAttendeeCount = attendeeNames.length; + + return ( + + ); +} diff --git a/frontend/src/components/MeetingTimeCard/MeetingTimeRecommendCard.tsx b/frontend/src/components/MeetingTimeCard/MeetingTimeRecommendCard.tsx index 857a6ca15..aa97ecaea 100644 --- a/frontend/src/components/MeetingTimeCard/MeetingTimeRecommendCard.tsx +++ b/frontend/src/components/MeetingTimeCard/MeetingTimeRecommendCard.tsx @@ -28,7 +28,7 @@ interface MeetingRecommendCardProps extends React.HTMLAttributes }; } -export default function MeetingRecommendCard({ +export default function MeetingTimeRecommendCard({ schedule, attendeeCount, }: MeetingRecommendCardProps) { diff --git a/frontend/src/components/MeetingTimeCard/MeetingTimeRecommendCardDaysOnly.tsx b/frontend/src/components/MeetingTimeCard/MeetingTimeRecommendCardDaysOnly.tsx new file mode 100644 index 000000000..f22c372bf --- /dev/null +++ b/frontend/src/components/MeetingTimeCard/MeetingTimeRecommendCardDaysOnly.tsx @@ -0,0 +1,48 @@ +import { formatFullDate } from '@utils/date'; + +import { + s_attendeeInfo, + s_baseContainer, + s_dateInfo, + s_recommendContainer, +} from './MeetingTimeCard.styles'; + +interface MeetingRecommendCardProps extends React.HTMLAttributes { + attendeeCount: number; + schedule: { + startDate: string; + startDayOfWeek: string; + startTime: string; + endDate: string; + endDayOfWeek: string; + endTime: string; + attendeeNames: string[]; + }; +} + +export default function MeetingTimeRecommendCardDaysOnly({ + schedule, + attendeeCount, +}: MeetingRecommendCardProps) { + const { startDate, startDayOfWeek, endDate, endDayOfWeek, attendeeNames } = schedule; + + const currentAttendeeCount = attendeeNames.length; + + const startRecommendDate = formatFullDate({ + fullDate: startDate, + dayOfWeek: startDayOfWeek, + }); + + const endRecommendDate = formatFullDate({ + fullDate: endDate, + dayOfWeek: endDayOfWeek, + }); + + return ( +
+ {`${attendeeCount}명 중 ${currentAttendeeCount}명`} + {startRecommendDate}부터 + {endRecommendDate}까지 +
+ ); +} diff --git a/frontend/src/components/Schedules/ScheduleViewer/ScheduleTable.tsx b/frontend/src/components/Schedules/ScheduleViewer/ScheduleTable.tsx index d667ef90f..1f32973bf 100644 --- a/frontend/src/components/Schedules/ScheduleViewer/ScheduleTable.tsx +++ b/frontend/src/components/Schedules/ScheduleViewer/ScheduleTable.tsx @@ -1,13 +1,10 @@ -import { useQuery } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; import type { MeetingAllSchedules, MeetingSingleSchedule } from 'types/schedule'; import ScheduleDateDayList from '@components/Schedules/ScheduleTableFrame/ScheduleDateDayList'; import ScheduleTimeList from '@components/Schedules/ScheduleTableFrame/ScheduleTimeList'; -import { handleGetMeetingSchedules } from '@apis/schedules'; - -import { QUERY_KEY } from '@constants/queryKeys'; +import { useGetSchedules } from '@stores/servers/schedule/queries'; import { s_scheduleTable, @@ -35,11 +32,7 @@ export default function ScheduleTable({ const params = useParams<{ uuid: string }>(); const uuid = params.uuid!; - const { data: meetingSchedules } = useQuery({ - queryKey: [QUERY_KEY.meetingSchedules, selectedAttendee], - queryFn: () => handleGetMeetingSchedules({ uuid, attendeeName: selectedAttendee }), - staleTime: 0, - }); + const { data: meetingSchedules } = useGetSchedules(uuid, selectedAttendee); return (
diff --git a/frontend/src/components/Schedules/Schedules.styles.ts b/frontend/src/components/Schedules/Schedules.styles.ts index 41104c5b4..dfa0534b5 100644 --- a/frontend/src/components/Schedules/Schedules.styles.ts +++ b/frontend/src/components/Schedules/Schedules.styles.ts @@ -94,6 +94,8 @@ export const s_cellColorBySelected = (isSelected: number, unavailableMode = fals `; export const s_baseTimeCell = (isHalfHour: boolean, isLastRow: boolean) => css` + position: relative; + flex: 1; max-width: 6.4rem; diff --git a/frontend/src/components/_common/Buttons/TabButton/index.tsx b/frontend/src/components/_common/Buttons/TabButton/index.tsx index 1efb1bb9b..3df41157e 100644 --- a/frontend/src/components/_common/Buttons/TabButton/index.tsx +++ b/frontend/src/components/_common/Buttons/TabButton/index.tsx @@ -1,13 +1,14 @@ -import type { PropsWithChildren } from 'react'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; import { Button } from '../Button'; import { s_tabButton } from './TabButton.styles'; import type { TabButtonVariants } from './TabButton.types'; -interface TabButtonProps extends PropsWithChildren { +interface TabButtonProps extends ButtonHTMLAttributes { isActive: boolean; onClick: () => void; tabButtonVariants?: TabButtonVariants; + children: ReactNode; } export default function TabButton({ @@ -15,9 +16,15 @@ export default function TabButton({ onClick, tabButtonVariants = 'default', children, + ...props }: TabButtonProps) { return ( - ); diff --git a/frontend/src/components/_common/Calendar/Body/index.tsx b/frontend/src/components/_common/Calendar/Body/index.tsx new file mode 100644 index 000000000..d46cb1a12 --- /dev/null +++ b/frontend/src/components/_common/Calendar/Body/index.tsx @@ -0,0 +1,23 @@ +// import 하지 않으면 스토리북에서 캘린더 컴포넌트가 렌더링 되지 않아 일단 추가(@해리) +import React from 'react'; +import type { DateInfo } from 'types/calendar'; + +import { useCalendarContext } from '@hooks/useCalendarContext/useCalendarContext'; + +import { s_calendarContent } from '../Calendar.styles'; + +interface BodyProps { + renderDate: (dateInfo: DateInfo, today: Date) => JSX.Element; +} + +export default function Body({ renderDate }: BodyProps) { + const { body } = useCalendarContext(); + + return ( +
+ {body.value.map(({ value: onWeekDays }) => + onWeekDays.map((dateInfo) => renderDate(dateInfo, body.today)), + )} +
+ ); +} diff --git a/frontend/src/components/_common/Calendar/Calendar.stories.tsx b/frontend/src/components/_common/Calendar/Calendar.stories.tsx index a180f2594..5edd4e008 100644 --- a/frontend/src/components/_common/Calendar/Calendar.stories.tsx +++ b/frontend/src/components/_common/Calendar/Calendar.stories.tsx @@ -1,63 +1,100 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { useState } from 'react'; +import type { DateInfo } from 'types/calendar'; -import Calendar from './index'; +import { CalendarContext } from '@contexts/CalendarProvider'; -const meta = { +import RangeDate from '@components/MeetingCalendar/Date/RangeDate'; +import SingleDate from '@components/MeetingCalendar/Date/SingleDate'; +import MeetingCalendarHeader from '@components/MeetingCalendar/Header'; +import MeetingCalendarWeekdays from '@components/MeetingCalendar/Weekdays'; + +import useCalendar from '@hooks/useCalendar/useCalendar'; +import useDateSelect from '@hooks/useDateSelect/useDateSelect'; + +import Calendar from '.'; + +const meta: Meta = { title: 'Components/Calendar', component: Calendar, tags: ['autodocs'], - parameters: { layout: 'centered', }, - argTypes: { - hasDate: { - description: '선택된 날짜들', - type: 'function', - control: { - disable: true, - }, - }, - onDateClick: { - description: '선택된 날짜 리스트에 특정 날짜를 추가하거나 제거할 수 있는 함수', - }, - }, decorators: [ - (Story, context) => { - const [selectedDates, setSelectedDates] = useState([]); - - const hasDate = (date: string) => selectedDates.includes(date); - - const handleDateClick = (date: string) => { - setSelectedDates((prevDates) => - hasDate(date) ? prevDates.filter((d) => d !== date) : [...prevDates, date], - ); - }; + (Story) => { + const calendarData = useCalendar(); return ( - + + + ); }, ], -} satisfies Meta; +}; export default meta; -type Story = StoryObj; +type Story = StoryObj; -export const Playground: Story = { - args: { - hasDate: () => false, - onDateClick: () => {}, - }, - render: (args) => { - return ; +export const Default: Story = { + render: () => { + const { + handleSelectedDates, + hasDate, + dateSelectMode, + checkIsRangeStartDate, + checkIsRangeEndDate, + isAllRangeSelected, + toggleDateSelectMode, + } = useDateSelect(); + + const renderDate = (dateInfo: DateInfo, today: Date) => + dateSelectMode === 'single' ? ( + + ) : ( + + ); + + return ( + + ( + + )} + /> + } /> + + + ); }, }; diff --git a/frontend/src/components/_common/Calendar/Calendar.styles.ts b/frontend/src/components/_common/Calendar/Calendar.styles.ts index 65942d34a..8f44fccc1 100644 --- a/frontend/src/components/_common/Calendar/Calendar.styles.ts +++ b/frontend/src/components/_common/Calendar/Calendar.styles.ts @@ -1,168 +1,14 @@ import { css } from '@emotion/react'; -import type { FlagObject } from 'types/utility'; - -import theme from '@styles/theme'; - -import CALENDAR_PROPERTIES from '@constants/calendar'; export const s_calendarContainer = css` display: flex; + flex: 1; flex-direction: column; align-items: center; `; export const s_calendarContent = css` display: grid; - grid-auto-rows: 4rem; grid-template-columns: repeat(7, 1fr); width: 100%; `; - -export const s_dayOfWeekContainer = css` - margin-bottom: 2rem; -`; - -export const s_baseDayOfWeek = css` - display: flex; - align-items: center; - justify-content: center; - - width: 100%; - min-width: 4rem; - height: 4rem; - min-height: 4rem; - - ${theme.typography.bodyMedium} -`; - -export const s_dayOfWeek = (index: number) => { - if (index === CALENDAR_PROPERTIES.sundayNumber) - return css` - ${DAY_SLOT_TEXT_STYLES.holiday} - `; - - if (index === CALENDAR_PROPERTIES.saturdayNumber) - return css` - ${DAY_SLOT_TEXT_STYLES.saturday} - `; - - return css` - ${DAY_SLOT_TEXT_STYLES.default} - `; -}; - -export const s_monthHeader = css` - display: flex; - align-items: center; - justify-content: space-between; - - width: 100%; - margin-bottom: 2rem; - padding: 0 1rem; - - ${theme.typography.bodyMedium} -`; - -export const s_monthNavigation = css` - cursor: pointer; - background-color: transparent; - border: none; - - ${theme.typography.titleMedium} - - &:disabled { - color: ${theme.colors.grey.primary}; - } -`; - -export const s_baseDaySlot = css` - display: flex; - align-items: center; - justify-content: center; - - width: 100%; - min-width: 3.6rem; - height: 3.6rem; -`; - -export const s_daySlotButton = css` - cursor: pointer; - background-color: transparent; - border: none; - ${theme.typography.bodyLight} - - &:disabled { - cursor: default; - } -`; - -export const s_baseDaySlotText = css` - position: relative; - - display: flex; - align-items: center; - justify-content: center; - - width: 3.6rem; - height: 3.6rem; -`; - -type DaySlotStatus = 'isSelectedFullDate' | 'isPrevDate' | 'isHoliday' | 'isSaturday' | 'isToday'; - -export const s_daySlotText = ({ - isSelectedFullDate, - isPrevDate, - isHoliday, - isSaturday, - isToday, -}: FlagObject) => { - /* 덕지덕지 if문인데 어쩔 수 없다고 생각하기는 했습니다. 가독성을 위해서 switch문을 사용하는 것도 고려해보면 좋을 것 같은데, 코멘트로 의견 부탁드려요(@해리) */ - /* if문 위에서부터 아래로, 스타일이 적용되어야 하는 우선순위입니다. 선택된 날짜의 스타일이 가장 우선적으로 고려되어야 하고, 그 다음은 지난날짜,,,순 입니다. 그래서 early return 패턴을 활용했어요(@해리) */ - if (isSelectedFullDate) return DAY_SLOT_TEXT_STYLES.selected; - if (isPrevDate) return DAY_SLOT_TEXT_STYLES.prevDay; - if (isHoliday) return DAY_SLOT_TEXT_STYLES.holiday; - if (isSaturday) return DAY_SLOT_TEXT_STYLES.saturday; - if (isToday) return DAY_SLOT_TEXT_STYLES.today; - - return DAY_SLOT_TEXT_STYLES.default; -}; - -// background-color: ${theme.colors.calendar.backgroundColor.today}; -const DAY_SLOT_TEXT_STYLES = { - selected: css` - color: ${theme.colors.calendar.color.selected}; - background-color: ${theme.colors.calendar.backgroundColor.selected}; - border-radius: 50%; - `, - holiday: css` - color: ${theme.colors.calendar.color.holiday}; - `, - today: css` - color: ${theme.colors.calendar.color.today}; - border-radius: 50%; - - &::after { - content: ''; - - position: absolute; - bottom: 0.4rem; - left: 50%; - transform: translateX(-50%); - - width: 0.4rem; - height: 0.4rem; - - background-color: ${theme.colors.calendar.color.today}; - border-radius: 50%; - } - `, - saturday: css` - color: #8c9eff; - `, - prevDay: css` - color: ${theme.colors.grey.primary}; - `, - default: css` - color: ${theme.colors.black}; - `, -}; diff --git a/frontend/src/components/_common/Calendar/Header/index.tsx b/frontend/src/components/_common/Calendar/Header/index.tsx new file mode 100644 index 000000000..a394a6e90 --- /dev/null +++ b/frontend/src/components/_common/Calendar/Header/index.tsx @@ -0,0 +1,20 @@ +// import 하지 않으면 스토리북에서 캘린더 컴포넌트가 렌더링 되지 않아 일단 추가(@해리) +import { useCalendarContext } from '@hooks/useCalendarContext/useCalendarContext'; + +interface HeaderProps { + render: (props: { + currentYear: number; + currentMonth: number; + moveToNextMonth: () => void; + moveToPrevMonth: () => void; + isCurrentMonth?: boolean; + }) => JSX.Element; +} + +export function Header({ render }: HeaderProps) { + const { headers, view, isCurrentMonth } = useCalendarContext(); + const { currentYear, currentMonth } = headers; + const { moveToNextMonth, moveToPrevMonth } = view; + + return render({ currentYear, currentMonth, moveToNextMonth, moveToPrevMonth, isCurrentMonth }); +} diff --git a/frontend/src/components/_common/Calendar/Weekdays/index.tsx b/frontend/src/components/_common/Calendar/Weekdays/index.tsx new file mode 100644 index 000000000..6e50aff4c --- /dev/null +++ b/frontend/src/components/_common/Calendar/Weekdays/index.tsx @@ -0,0 +1,13 @@ +// import 하지 않으면 스토리북에서 캘린더 컴포넌트가 렌더링 되지 않아 일단 추가(@해리) +import { useCalendarContext } from '@hooks/useCalendarContext/useCalendarContext'; + +interface WeekDaysProps { + render: (weekDays: string[]) => JSX.Element; +} + +export default function WeekDays({ render }: WeekDaysProps) { + const { headers } = useCalendarContext(); + const { weekDays } = headers; + + return render(weekDays); +} diff --git a/frontend/src/components/_common/Calendar/index.tsx b/frontend/src/components/_common/Calendar/index.tsx index 6b7b7f243..a9c7f6217 100644 --- a/frontend/src/components/_common/Calendar/index.tsx +++ b/frontend/src/components/_common/Calendar/index.tsx @@ -1,88 +1,22 @@ -import useCalendarInfo from '@hooks/useCalendarInfo/useCalendarInfo'; +import type { PropsWithChildren } from 'react'; +// import 하지 않으면 스토리북에서 캘린더 컴포넌트가 렌더링 되지 않아 일단 추가(@해리) +import React from 'react'; -import { - s_baseDayOfWeek, - s_baseDaySlot, - s_baseDaySlotText, - s_calendarContainer, - s_calendarContent, - s_dayOfWeek, - s_dayOfWeekContainer, - s_daySlotButton, - s_daySlotText, - s_monthHeader, - s_monthNavigation, -} from './Calendar.styles'; +import CalendarProvider from '@contexts/CalendarProvider'; -const DAY_OF_WEEK = ['일', '월', '화', '수', '목', '금', '토'] as const; - -interface CalendarProps { - hasDate: (date: string) => boolean; - onDateClick: (date: string) => void; -} - -export default function Calendar({ hasDate, onDateClick }: CalendarProps) { - const { - yearMonthInfo, - handleGetDateInfo, - handlePrevMonthMove, - handleNextMonthMove, - isCurrentDate, - } = useCalendarInfo(); - const { year, month, daySlotCount } = yearMonthInfo; +import Body from './Body'; +import { s_calendarContainer } from './Calendar.styles'; +import { Header } from './Header'; +import WeekDays from './Weekdays'; +function CalendarMain({ children }: PropsWithChildren) { return ( -
-
- - - {year}년 {month}월 - - -
-
- {DAY_OF_WEEK.map((day, index) => ( -
- {day} -
- ))} -
-
- {Array.from({ length: daySlotCount }, (_, index) => { - const { date, fullDate, isValidDate, isToday, isHoliday, isSaturday, isPrevDate } = - handleGetDateInfo(index); - const isSelectedFullDate = hasDate(fullDate); - - return isValidDate ? ( - - ) : ( -
- ); - })} -
-
+ +
{children}
+
); } + +const Calendar = Object.assign(CalendarMain, { Header, WeekDays, Body }); + +export default Calendar; diff --git a/frontend/src/components/_common/Checkbox/Checkbox.stories.tsx b/frontend/src/components/_common/Checkbox/Checkbox.stories.tsx new file mode 100644 index 000000000..1cd2eadf4 --- /dev/null +++ b/frontend/src/components/_common/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useArgs } from 'storybook/internal/preview-api'; + +import Checkbox from '.'; + +const meta = { + title: 'Common/Checkbox', + component: Checkbox, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story, context) => { + const [{ isChecked }, setArgState] = useArgs(); + + const onToggleIsChecked = () => { + setArgState({ isChecked: !isChecked }); + }; + + return ( + + ); + }, + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + id: 'checkbox', + isChecked: false, + labelText: '체크박스입니다.', + }, +}; diff --git a/frontend/src/components/_common/Checkbox/Checkbox.styles.ts b/frontend/src/components/_common/Checkbox/Checkbox.styles.ts new file mode 100644 index 000000000..3271b8a16 --- /dev/null +++ b/frontend/src/components/_common/Checkbox/Checkbox.styles.ts @@ -0,0 +1,7 @@ +import { css } from '@emotion/react'; + +export const s_container = css` + display: flex; + gap: 0.2rem; + align-items: center; +`; diff --git a/frontend/src/components/_common/Checkbox/index.tsx b/frontend/src/components/_common/Checkbox/index.tsx new file mode 100644 index 000000000..4a6042cbf --- /dev/null +++ b/frontend/src/components/_common/Checkbox/index.tsx @@ -0,0 +1,18 @@ +import type { InputHTMLAttributes } from 'react'; + +import { s_container } from './Checkbox.styles'; + +interface CheckboxProps extends InputHTMLAttributes { + isChecked: boolean; + labelText: string; + id: string; +} + +export default function Checkbox({ isChecked, id, labelText, ...props }: CheckboxProps) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/_common/CopyLink/CopyLink.style.ts b/frontend/src/components/_common/CopyLink/CopyLink.style.ts new file mode 100644 index 000000000..1a7cbe369 --- /dev/null +++ b/frontend/src/components/_common/CopyLink/CopyLink.style.ts @@ -0,0 +1,70 @@ +import { css, keyframes } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_copyContainer = css` + display: flex; + align-items: center; + + width: 100%; + height: 3.6rem; + + background-color: ${theme.colors.white}; + border-radius: 0.8rem; +`; + +export const s_copyButtonContainer = css` + display: flex; + align-items: center; + justify-content: center; + + width: 3.6rem; + height: 3.6rem; +`; + +export const s_urlText = css` + overflow: hidden; + display: block; + + width: calc(100% - 3.6rem); + height: 100%; + padding: 0.8rem 0 0.8rem 0.8rem; + + color: ${theme.colors.grey.dark}; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + + border-radius: 0.8rem; +`; + +export const s_copyButton = css` + width: 100%; + height: 100%; + background-color: inherit; + border-radius: 0.8rem; + + &:hover { + opacity: 0.3; + } +`; + +const drawCheck = keyframes` + to { + stroke-dashoffset: 0; + } +`; + +export const s_check = css` + path { + fill: none; + stroke: ${theme.colors.green.deepDark}; + stroke-dasharray: 100; + stroke-dashoffset: 100; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 8; + + animation: ${drawCheck} 0.8s ease-in-out forwards; + } +`; diff --git a/frontend/src/components/_common/CopyLink/index.tsx b/frontend/src/components/_common/CopyLink/index.tsx new file mode 100644 index 000000000..26c60c5c4 --- /dev/null +++ b/frontend/src/components/_common/CopyLink/index.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +import { copyToClipboard } from '@utils/clipboard'; + +import CheckIcon from '@assets/images/check.svg'; +import CopyIcon from '@assets/images/copy.svg'; + +import { + s_check, + s_copyButton, + s_copyButtonContainer, + s_copyContainer, + s_urlText, +} from './CopyLink.style'; + +interface CopyLinkProps { + url: string; +} + +export default function CopyLink({ url }: CopyLinkProps) { + const [copyComplete, setCopyComplete] = useState(false); + + const handleCopyClick = async () => { + setCopyComplete(true); + setTimeout(() => setCopyComplete(false), 2500); + }; + + return ( +
+ {url} +
+ {copyComplete ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/_common/Header/Header.styles.ts b/frontend/src/components/_common/Header/Header.styles.ts index 5c483d32b..4f8c71329 100644 --- a/frontend/src/components/_common/Header/Header.styles.ts +++ b/frontend/src/components/_common/Header/Header.styles.ts @@ -1,5 +1,7 @@ import { css } from '@emotion/react'; +import theme from '@styles/theme'; + // 테이블의 시간 범위가 길어지는 경우, Header가 가리는 문제를 해결하기 위해 z-index 수정(@해리) export const s_header = css` position: sticky; @@ -27,5 +29,5 @@ export const s_logoContainer = css` export const s_title = css` font-size: 2.4rem; - font-weight: 800; + font-weight: ${theme.typography.titleBold}; `; diff --git a/frontend/src/components/_common/Modal/ConfirmModal/ConfirmModal.stories.tsx b/frontend/src/components/_common/Modal/ConfirmModal/ConfirmModal.stories.tsx new file mode 100644 index 000000000..eb0c01da8 --- /dev/null +++ b/frontend/src/components/_common/Modal/ConfirmModal/ConfirmModal.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { useArgs } from 'storybook/internal/preview-api'; + +import theme from '@styles/theme'; + +import ConfirmModal from '.'; + +const meta = { + title: 'common/Modal/Confirm', + component: ConfirmModal, + decorators: [ + (Story, context) => { + const [{ isOpen }, setArgState] = useArgs(); + + const handleButtonClick = () => setArgState({ isOpen: !isOpen }); + + return ( + <> + + + + ); + }, + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true, + onClose: () => {}, + onConfirm: () => {}, + position: 'center', + size: 'small', + title: '입력하신 약속 정보를 확인해주세요.', + children: ( +
+

+ 약속이름: 해빙낙 정기토론회 +

+

+ 주최자: 해리와빙봉 +

+

+ 약속 시간: 10:00 ~ 18:00 +

+

+ 가능 날짜: 어쩌고 +

+
+ ), + }, +}; + +export const ButtonPositionColumn: Story = { + args: { + isOpen: true, + onClose: () => {}, + onConfirm: () => {}, + position: 'center', + size: 'almostFull', + title: '입력하신 약속 정보를 확인해주세요.', + children: ( +
+

+ 약속이름: 해빙낙 정기토론회 +

+

+ 주최자: 해리와빙봉 +

+

+ 약속 시간: 10:00 ~ 18:00 +

+

+ 가능 날짜: 어쩌고 +

+
+ ), + buttonPosition: 'column', + }, +}; diff --git a/frontend/src/components/_common/Modal/ConfirmModal/ConfirmModal.styles.ts b/frontend/src/components/_common/Modal/ConfirmModal/ConfirmModal.styles.ts new file mode 100644 index 000000000..0d47af27a --- /dev/null +++ b/frontend/src/components/_common/Modal/ConfirmModal/ConfirmModal.styles.ts @@ -0,0 +1,23 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_button = css` + ${theme.typography.bodyMedium} + flex: 1; + padding: 1.2rem; + border-radius: 1.2rem; + + &:hover { + opacity: 0.8; + } +`; +export const s_primary = css` + color: ${theme.colors.white}; + background-color: ${theme.colors.primary}; +`; + +export const s_secondary = css` + color: ${theme.colors.grey.dark}; + background-color: ${theme.colors.grey.primary}; +`; diff --git a/frontend/src/components/_common/Modal/ConfirmModal/index.tsx b/frontend/src/components/_common/Modal/ConfirmModal/index.tsx new file mode 100644 index 000000000..4dc0913b0 --- /dev/null +++ b/frontend/src/components/_common/Modal/ConfirmModal/index.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react'; + +import { Modal } from '..'; +import type { ButtonPositionType } from '../Footer'; +import type { ModalProps } from '../ModalContainer'; +import { s_button, s_primary, s_secondary } from './ConfirmModal.styles'; + +interface ConfirmModalProps extends ModalProps { + title: string; + buttonPosition?: ButtonPositionType; + onConfirm: () => void; + children: ReactNode; +} + +export default function ConfirmModal({ + isOpen, + onClose, + onConfirm, + title, + position, + size, + children, + buttonPosition = 'row', +}: ConfirmModalProps) { + return ( + + {title} + {children} + + + + + + ); +} diff --git a/frontend/src/components/_common/Modal/Footer/Footer.styles.ts b/frontend/src/components/_common/Modal/Footer/Footer.styles.ts new file mode 100644 index 000000000..ebf32885c --- /dev/null +++ b/frontend/src/components/_common/Modal/Footer/Footer.styles.ts @@ -0,0 +1,10 @@ +import { css } from '@emotion/react'; + +import type { ButtonPositionType } from '.'; + +export const s_container = (buttonPosition: ButtonPositionType) => css` + display: flex; + flex-direction: ${buttonPosition}; + gap: 0.8rem; + margin-top: 2.4rem; +`; diff --git a/frontend/src/components/_common/Modal/Footer/index.tsx b/frontend/src/components/_common/Modal/Footer/index.tsx new file mode 100644 index 000000000..061d8286a --- /dev/null +++ b/frontend/src/components/_common/Modal/Footer/index.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren } from 'react'; + +import { s_container } from './Footer.styles'; + +export type ButtonPositionType = 'column' | 'row'; + +interface FooterProps { + buttonPosition: ButtonPositionType; +} + +export default function Footer({ + buttonPosition = 'row', + children, +}: PropsWithChildren) { + return
{children}
; +} diff --git a/frontend/src/components/_common/Modal/Header/Header.styles.ts b/frontend/src/components/_common/Modal/Header/Header.styles.ts new file mode 100644 index 000000000..33a02e847 --- /dev/null +++ b/frontend/src/components/_common/Modal/Header/Header.styles.ts @@ -0,0 +1,7 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_title = css` + ${theme.typography.subTitleBold} +`; diff --git a/frontend/src/components/_common/Modal/Header/index.tsx b/frontend/src/components/_common/Modal/Header/index.tsx new file mode 100644 index 000000000..45add12a8 --- /dev/null +++ b/frontend/src/components/_common/Modal/Header/index.tsx @@ -0,0 +1,7 @@ +import type { PropsWithChildren } from 'react'; + +import { s_title } from './Header.styles'; + +export default function Header({ children }: PropsWithChildren) { + return

{children}

; +} diff --git a/frontend/src/components/_common/Modal/Main/Main.styles.ts b/frontend/src/components/_common/Modal/Main/Main.styles.ts new file mode 100644 index 000000000..c209ccfc6 --- /dev/null +++ b/frontend/src/components/_common/Modal/Main/Main.styles.ts @@ -0,0 +1,10 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_container = css` + ${theme.typography.bodyLight} + display: flex; + flex-direction: column; + white-space: break-spaces; +`; diff --git a/frontend/src/components/_common/Modal/Main/index.tsx b/frontend/src/components/_common/Modal/Main/index.tsx new file mode 100644 index 000000000..ad5f9bfe0 --- /dev/null +++ b/frontend/src/components/_common/Modal/Main/index.tsx @@ -0,0 +1,7 @@ +import type { PropsWithChildren } from 'react'; + +import { s_container } from './Main.styles'; + +export default function Main({ children }: PropsWithChildren) { + return
{children}
; +} diff --git a/frontend/src/components/_common/Modal/Modal.style.ts b/frontend/src/components/_common/Modal/Modal.style.ts new file mode 100644 index 000000000..ef20cd2cc --- /dev/null +++ b/frontend/src/components/_common/Modal/Modal.style.ts @@ -0,0 +1,48 @@ +import { css } from '@emotion/react'; +import type { CSSProperties } from 'react'; + +import theme from '@styles/theme'; + +import type { ModalPositionType, ModalSizeType } from './ModalContainer'; + +const sizeSelector: Record = { + small: '320px', + almostFull: '90%', + full: '100%', +}; + +export const s_container = css` + position: fixed; + z-index: 99; + inset: 0; +`; + +export const s_position = (position: ModalPositionType) => css` + display: flex; + align-items: ${position === 'bottom' ? 'flex-end' : 'center'}; + justify-content: center; +`; + +export const s_backdrop = css` + position: absolute; + width: 100%; + height: 100%; + background-color: rgb(0 0 0 / 30%); +`; + +export const s_content = css` + position: absolute; + + display: flex; + flex-direction: column; + gap: 0.8rem; + + padding: 2rem 1.6rem 1.6rem; + + background-color: ${theme.colors.white}; + border-radius: 1rem; +`; + +export const s_size = (size: ModalSizeType) => css` + width: ${sizeSelector[size]}; +`; diff --git a/frontend/src/components/_common/Modal/ModalContainer.tsx b/frontend/src/components/_common/Modal/ModalContainer.tsx new file mode 100644 index 000000000..59d8978c8 --- /dev/null +++ b/frontend/src/components/_common/Modal/ModalContainer.tsx @@ -0,0 +1,32 @@ +import type { PropsWithChildren } from 'react'; +import { createPortal } from 'react-dom'; + +import { s_backdrop, s_container, s_content, s_position, s_size } from './Modal.style'; + +export type ModalPositionType = 'center' | 'bottom'; +export type ModalSizeType = 'small' | 'almostFull' | 'full'; + +export interface ModalProps { + position: ModalPositionType; + isOpen: boolean; + onClose: () => void; + size: ModalSizeType; +} + +export default function ModalContainer({ + isOpen, + onClose, + size, + position, + children, +}: PropsWithChildren) { + if (!isOpen) return null; + + return createPortal( +
+
+
{children}
+
, + document.body, + ); +} diff --git a/frontend/src/components/_common/Modal/index.tsx b/frontend/src/components/_common/Modal/index.tsx new file mode 100644 index 000000000..838ad03be --- /dev/null +++ b/frontend/src/components/_common/Modal/index.tsx @@ -0,0 +1,10 @@ +import Footer from './Footer'; +import Header from './Header'; +import Main from './Main'; +import ModalContainer from './ModalContainer'; + +export const Modal = Object.assign(ModalContainer, { + Header: Header, + Main: Main, + Footer: Footer, +}); diff --git a/frontend/src/components/_common/PageMoveLoading/PageMoveLoading.style.ts b/frontend/src/components/_common/PageMoveLoading/PageMoveLoading.style.ts new file mode 100644 index 000000000..c1312c109 --- /dev/null +++ b/frontend/src/components/_common/PageMoveLoading/PageMoveLoading.style.ts @@ -0,0 +1,17 @@ +import { css } from '@emotion/react'; + +import { PRIMITIVE_COLORS } from '@styles/tokens/colors'; + +export const s_loadingWrapper = css` + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + justify-content: center; + + height: 100%; +`; + +export const s_text = css` + color: ${PRIMITIVE_COLORS.grey[500]}; +`; diff --git a/frontend/src/components/_common/PageMoveLoading/index.tsx b/frontend/src/components/_common/PageMoveLoading/index.tsx new file mode 100644 index 000000000..ea62c749c --- /dev/null +++ b/frontend/src/components/_common/PageMoveLoading/index.tsx @@ -0,0 +1,23 @@ +import Lottie from 'react-lottie'; + +import momoPageLoading from '@assets/images/momoPageLoading.json'; + +import { s_loadingWrapper, s_text } from './PageMoveLoading.style'; + +export default function PageMoveLoading() { + const defaultOptions = { + loop: true, + autoplay: true, + animationData: momoPageLoading, + rendererSettings: { + preserveAspectRatio: 'xMidYMid slice', + }, + }; + + return ( +
+ +
로딩중입니다...
+
+ ); +} diff --git a/frontend/src/components/_common/Tooltip/Tooltip.styles.ts b/frontend/src/components/_common/Tooltip/Tooltip.styles.ts index 461731136..14ff0952f 100644 --- a/frontend/src/components/_common/Tooltip/Tooltip.styles.ts +++ b/frontend/src/components/_common/Tooltip/Tooltip.styles.ts @@ -3,7 +3,7 @@ import { css } from '@emotion/react'; import type { TooltipPosition } from 'types/tooltip'; export const tooltipContainer = css` - position: relative; + position: absolute; width: 100%; height: 100%; `; diff --git a/frontend/src/constants/inputFields.ts b/frontend/src/constants/inputFields.ts index b028f0f77..e8ed5ef7b 100644 --- a/frontend/src/constants/inputFields.ts +++ b/frontend/src/constants/inputFields.ts @@ -1,22 +1,12 @@ -export const INPUT_FIELD_RULES = { - meetingName: { - minLength: 1, - maxLength: 10, - }, - nickname: { - minLength: 1, - maxLength: 5, - }, - password: { - minLength: 1, - maxLength: 10, - pattern: /^[a-zA-Z0-9!@#$%]+$/, - }, +export const INPUT_FIELD_PATTERN = { + meetingName: /^.{1,10}$/, // 1~10자 사이의 모든 문자 + nickname: /^.{1,5}$/, // 1~5자 사이의 모든 문자, + password: /^\d{4}$/, // 4자리 숫자 }; export const FIELD_DESCRIPTIONS = { meetingName: '약속 이름은 1~10자 사이로 입력해 주세요.', - nickname: '닉네임을 1~5자 사이로 입력해 주세요.', - password: - '비밀번호를 1~10자 사이로 입력해 주세요.\n사용 가능한 문자는 알파벳, 숫자, 특수문자(!@#$%)입니다.', + nickname: '닉네임은 1~5자 사이로 입력해 주세요.', + password: '비밀번호는 4자리 숫자로 입력해 주세요.', + date: '날짜를 하나씩 클릭해 여러 날짜를 선택하거나\n시작일과 종료일을 클릭해 사이의 모든 날짜를 선택해 보세요', }; diff --git a/frontend/src/contexts/CalendarProvider.tsx b/frontend/src/contexts/CalendarProvider.tsx new file mode 100644 index 000000000..9f35cb50c --- /dev/null +++ b/frontend/src/contexts/CalendarProvider.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren } from 'react'; +import { createContext } from 'react'; + +import useCalendar from '@hooks/useCalendar/useCalendar'; + +type CalendarContextType = ReturnType; + +export const CalendarContext = createContext(null); + +export default function CalendarProvider({ children }: PropsWithChildren) { + const calendarData = useCalendar(); + + return ( + {children} + ); +} diff --git a/frontend/src/hooks/useCalendar/useCalendar.ts b/frontend/src/hooks/useCalendar/useCalendar.ts new file mode 100644 index 000000000..1daa482f9 --- /dev/null +++ b/frontend/src/hooks/useCalendar/useCalendar.ts @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import type { MonthlyDays } from 'types/calendar'; + +import { getMonth, getYear } from '@utils/date'; + +import { getMonthlyDate } from './useCalendar.utils'; + +interface useCalendarReturn { + headers: { + currentYear: number; + currentMonth: number; + weekDays: string[]; + }; + body: { + value: MonthlyDays; + today: Date; + }; + view: { + moveToNextMonth: () => void; + moveToPrevMonth: () => void; + }; + isCurrentMonth: boolean; +} + +const TODAY = new Date(); + +const useCalendar = (): useCalendarReturn => { + const [currentFullDate, setCurrentFullDate] = useState(new Date()); + + const currentYear = getYear(currentFullDate); + const currentMonth = getMonth(currentFullDate); + const isCurrentMonth = getYear(TODAY) === currentYear && getMonth(TODAY) === currentMonth; + + const moveToPrevMonth = () => { + setCurrentFullDate(new Date(currentYear, currentMonth - 1)); + }; + + const moveToNextMonth = () => { + setCurrentFullDate(new Date(currentYear, currentMonth + 1)); + }; + + const monthlyDates = getMonthlyDate(currentFullDate); + + return { + headers: { + currentYear, + currentMonth, + weekDays: ['일', '월', '화', '수', '목', '금', '토'], + }, + body: { + today: TODAY, + value: monthlyDates, + }, + view: { + moveToNextMonth, + moveToPrevMonth, + }, + isCurrentMonth, + }; +}; + +export default useCalendar; diff --git a/frontend/src/hooks/useCalendar/useCalendar.utils.ts b/frontend/src/hooks/useCalendar/useCalendar.utils.ts new file mode 100644 index 000000000..acc5bc4d2 --- /dev/null +++ b/frontend/src/hooks/useCalendar/useCalendar.utils.ts @@ -0,0 +1,115 @@ +import type { DateInfo, MonthStatus, MonthlyDays } from 'types/calendar'; + +import { getDate, getDay, getMonth, getYear } from '@utils/date'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const setFirstDate = (date: Date) => { + const newDate = new Date(date); + newDate.setDate(1); + + return newDate; +}; + +export const getDaysInMonth = (date: Date) => { + const lastDateOfMonth = getDate(new Date(getYear(date), getMonth(date) + 1, 0)); + + return lastDateOfMonth; +}; + +export const getNumberOfWeeks = (date: Date) => { + const firstOfMonth = setFirstDate(date); + const daysInMonth = getDaysInMonth(date); + const dayOfWeek = getDay(firstOfMonth); + + return Math.ceil((daysInMonth + dayOfWeek) / 7); +}; + +export const getMonthlyStartIndex = (date: Date) => { + const firstOfMonth = setFirstDate(date); + + return firstOfMonth.getDay(); +}; + +const getMonthStatus = (targetDate: Date, currentMonthIndex: number): MonthStatus => { + const month = getMonth(targetDate); + + if (month < currentMonthIndex) return 'prev'; + + if (month > currentMonthIndex) return 'next'; + + return 'current'; +}; + +export const getWeeklyDate = (startDate: Date, currentMonthIndex: number): DateInfo[] => + Array.from({ length: CALENDAR_PROPERTIES.daysInOneWeek }, (_, i) => { + const date = new Date(startDate); + date.setDate(getDate(startDate) + i); + + const monthStatus = getMonthStatus(date, currentMonthIndex); + + return { + key: `${date}`, + value: date, + status: monthStatus, + }; + }); + +/** + * 주어진 날짜를 기준으로 해당 월의 달력 데이터를 계산하여 반환. + * 이 함수는 해당 월이 몇 주에 걸쳐 있는지 계산하고, 주마다 날짜 정보를 생성하여 반환. + * 월의 첫 번째 주와 마지막 주가 이전 달 또는 다음 달의 날짜로 채워질 수 있다. + * 이렇게 한 이유는 현재 모모 서비스에서는, 이전 달 또는 다음 달의 데이터를 활용하고 있지 않지만 요구 사항이 변경되어 필요하게 되면 필요에 따라 유연하게 뽑아서 사용할 수 있도록 하기 위해서이다. + * + * @param {Date} date - 해당 월을 나타내는 JavaScript `Date` 객체. (해당 월의 아무 날짜나 가능합니다) + * + * @returns {MonthlyDays} 해당 월의 주 단위 데이터를 포함하는 배열을 반환합니다. 각 주는 객체로 표현된다. 아래는 객체에 대한 설명. + * - `key`: 해당 주의 고유 식별자. + * - `value`: `DateInfo` 객체 배열로, 각 날짜의 정보를 포함. + * - `key`: 특정 날짜를 나타내는 문자열 (예: `${date}` 형태). + * - `value`: 해당 날짜의 `Date` 객체. + * - `status`: 해당 날짜가 'prevMonth' (이전 달), 'currentMonth' (현재 달), 'nextMonth' (다음 달) 중 어디에 속하는지 나타내는 값. + * + * 함수 실행 과정: + * 1. `getNumberOfWeeks` 함수를 사용하여 해당 월이 몇 주에 걸쳐 있는지 계산합니다. + * 2. `getMonthlyStartIndex` 함수를 사용하여 해당 월의 첫 번째 주에 이전 달의 날짜가 필요한지 계산합니다. + * 3. `getWeeklyDate` 함수를 사용하여 주별로 날짜 데이터를 생성하며, 각 날짜가 현재 달, 이전 달, 또는 다음 달에 속하는지 판단하여 `status` 값을 설정합니다. + * + * 반환 데이터 예시: + * [ + * { + * key: 2023090, // 2023-09의 첫 번째 주 + * value: [ + * { key: '2023-08-27', value: Date, status: 'prevMonth' }, 이전 달 데이터인 8월 데이터가 포함되고, prevMonth 상태를 가짐. + * { key: '2023-08-28', value: Date, status: 'prevMonth' }, + * ... + * { key: '2023-09-03', value: Date, status: 'currentMonth' } + * ] + * }, + * ... + * { + * key: 2023094, + * value: [ + * { key: '2023-09-25', value: Date, status: 'currentMonth' }, + * { key: '2023-09-26', value: Date, status: 'currentMonth' }, + * ... + * { key: '2023-10-01', value: Date, status: 'nextMonth' } // 다음 달 데이터인 10월 데이터가 포함되고, nextMonth 상태를 가짐. + * ] + * } + * ] + */ +export const getMonthlyDate = (date: Date): MonthlyDays => { + const numberOfWeeks = getNumberOfWeeks(date); + const monthlyStartDate = setFirstDate(new Date(date)); + monthlyStartDate.setDate(1 - getMonthlyStartIndex(date)); + + return Array.from({ length: numberOfWeeks }, (_, i) => { + const newDate = new Date(monthlyStartDate); + newDate.setDate(getDate(monthlyStartDate) + CALENDAR_PROPERTIES.daysInOneWeek * i); + + return { + key: getYear(date) * getMonth(date) + i, + value: getWeeklyDate(newDate, getMonth(date)), + }; + }); +}; diff --git a/frontend/src/hooks/useCalendarContext/useCalendarContext.ts b/frontend/src/hooks/useCalendarContext/useCalendarContext.ts new file mode 100644 index 000000000..ce3a2b5c6 --- /dev/null +++ b/frontend/src/hooks/useCalendarContext/useCalendarContext.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; + +import { CalendarContext } from '@contexts/CalendarProvider'; + +export const useCalendarContext = () => { + const context = useContext(CalendarContext); + + if (!context) { + throw new Error('useCalendarContext 커스텀 훅은 캘린더 컴포넌트 내부에서만 사용할 수 있어요^^'); + } + + return context; +}; diff --git a/frontend/src/hooks/useCalendarInfo/useCalendarInfo.utils.ts b/frontend/src/hooks/useCalendarInfo/useCalendarInfo.utils.ts deleted file mode 100644 index 14d46ee64..000000000 --- a/frontend/src/hooks/useCalendarInfo/useCalendarInfo.utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import CALENDAR_PROPERTIES from '@constants/calendar'; - -export const getCurrentDateInfo = () => { - const currentDate = new Date(); - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth() + 1; - - return { - currentDate, - currentYear, - currentMonth, - } as const; -}; - -export const generateMonthDaySlots = (year: number, month: number) => { - const startDate = new Date(year, month - 1, 1); - const firstDayIndex = startDate.getDay(); - - const lastDateOfMonth = new Date(year, month, 0); - const lastDayNumber = lastDateOfMonth.getDate(); - - const daySlotCount = firstDayIndex + lastDayNumber; - - return { firstDayIndex, daySlotCount } as const; -}; - -export const getDateInfo = ({ - year, - month, - firstDayIndex, - index, - currentDate, -}: { - year: number; - month: number; - firstDayIndex: number; - index: number; - currentDate: Date; -}) => { - const date = index - firstDayIndex + 1; - const todayDate = currentDate.getDate(); - const formattedMonth = String(month).padStart(2, '0'); - const formattedCurrentMonth = String(currentDate.getMonth() + 1).padStart(2, '0'); - - const fullDate = `${year}-${formattedMonth}-${String(date).padStart(2, '0')}`; - const todayFullDate = `${year}-${formattedCurrentMonth}-${String(todayDate).padStart(2, '0')}`; - - const isValidDate = index >= firstDayIndex; - const isHoliday = index % CALENDAR_PROPERTIES.daysInOneWeek === 0; - const isSaturday = index % CALENDAR_PROPERTIES.daysInOneWeek === 6; - const isPrevDate = formattedMonth === formattedCurrentMonth && date < todayDate; - const isToday = fullDate === todayFullDate; - - return { date, fullDate, isValidDate, isToday, isSaturday, isHoliday, isPrevDate } as const; -}; diff --git a/frontend/src/hooks/useCalendarPick/useCalendarPick.tsx b/frontend/src/hooks/useCalendarPick/useCalendarPick.tsx new file mode 100644 index 000000000..f12fb7e9b --- /dev/null +++ b/frontend/src/hooks/useCalendarPick/useCalendarPick.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +import { useGetMyScheduleQuery } from '@stores/servers/meeting/queries'; + +export default function useCalendarPick(uuid: string, userName: string) { + const { data: meetingSchedules, isSuccess } = useGetMyScheduleQuery(uuid, userName); + + const [selectedDatesObj, setSelectedDatesObj] = useState>({}); + const selectedDates = Object.keys(selectedDatesObj); + + const handleSelectedDate = (date: string) => { + const copiedSelectedDatesObj = { ...selectedDatesObj }; + + if (selectedDatesObj[date]) delete copiedSelectedDatesObj[date]; + else copiedSelectedDatesObj[date] = true; + + setSelectedDatesObj({ ...copiedSelectedDatesObj }); + }; + + const hasDate = (date: string) => { + return !!selectedDatesObj[date]; + }; + + useEffect(() => { + if (isSuccess) { + const schedules = Object.fromEntries( + meetingSchedules.schedules.map(({ date }) => [date, true]), + ); + setSelectedDatesObj(schedules); + } + }, [isSuccess, meetingSchedules]); + + return { selectedDates, hasDate, handleSelectedDate }; +} diff --git a/frontend/src/hooks/useConfirmModal/useConfirmModal.ts b/frontend/src/hooks/useConfirmModal/useConfirmModal.ts new file mode 100644 index 000000000..7b85e47c4 --- /dev/null +++ b/frontend/src/hooks/useConfirmModal/useConfirmModal.ts @@ -0,0 +1,11 @@ +import { useState } from 'react'; + +export default function useConfirmModal() { + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); + + const onToggleConfirmModal = () => { + setIsConfirmModalOpen((prev) => !prev); + }; + + return { isConfirmModalOpen, onToggleConfirmModal }; +} diff --git a/frontend/src/hooks/useDateSelect/useDateSelect.ts b/frontend/src/hooks/useDateSelect/useDateSelect.ts new file mode 100644 index 000000000..d29b4d9c3 --- /dev/null +++ b/frontend/src/hooks/useDateSelect/useDateSelect.ts @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import type { DateSelectMode } from 'types/calendar'; + +import { getFullDate } from '@utils/date'; + +import { getDatesInRange } from './useDateSelect.utils'; + +const useDateSelect = () => { + const [selectedDates, setSelectedDates] = useState([]); + const [dateSelectMode, setDateSelectMode] = useState('single'); + const [rangeStartDate, setRangeStartDate] = useState(null); + const [rangeEndDate, setRangeEndDate] = useState(null); + + const toggleDateSelectMode = (mode: DateSelectMode) => { + if (mode === dateSelectMode) return; + + setDateSelectMode(mode); + setSelectedDates([]); + setRangeStartDate(null); + setRangeEndDate(null); + }; + + const handleSelectedDateBySingleMode = (date: string) => { + setSelectedDates((prev) => + prev.includes(date) ? prev.filter((d) => d !== date) : [...prev, date], + ); + }; + + const handleRangeStartDatePick = (date: string) => { + setRangeStartDate(date); + setRangeEndDate(null); + setSelectedDates([date]); + }; + + const handleRangeEndDatePick = (date: string) => { + if (!rangeStartDate || rangeStartDate === date) return; + + const start = new Date(rangeStartDate); + const end = new Date(date); + + if (end < start) { + setRangeStartDate(date); + setSelectedDates([date]); + return; + } else { + setRangeEndDate(date); + const range = getDatesInRange(start, end); + setSelectedDates(range); + } + }; + + const handleSelectedDateByRangeMode = (date: string) => { + if (isAllRangeSelected || !rangeStartDate) { + handleRangeStartDatePick(date); + return; + } + + handleRangeEndDatePick(date); + }; + + const handleSelectedDates = (date: string) => { + if (dateSelectMode === 'single') { + handleSelectedDateBySingleMode(date); + return; + } + + handleSelectedDateByRangeMode(date); + }; + + const hasDate = (date: string) => { + return selectedDates.includes(date); + }; + const checkIsRangeStartDate = (date: Date) => getFullDate(date) === rangeStartDate; + const checkIsRangeEndDate = (date: Date) => getFullDate(date) === rangeEndDate; + const isAllRangeSelected = rangeStartDate !== null && rangeEndDate != null; + + return { + selectedDates, + dateSelectMode, + toggleDateSelectMode, + checkIsRangeStartDate, + checkIsRangeEndDate, + handleSelectedDates, + hasDate, + isAllRangeSelected, + }; +}; + +export default useDateSelect; diff --git a/frontend/src/hooks/useDateSelect/useDateSelect.utils.ts b/frontend/src/hooks/useDateSelect/useDateSelect.utils.ts new file mode 100644 index 000000000..184242f1e --- /dev/null +++ b/frontend/src/hooks/useDateSelect/useDateSelect.utils.ts @@ -0,0 +1,37 @@ +import { getFullDate } from '@utils/date'; + +/** + * 주어진 시작 날짜와 끝 날짜 사이의 모든 날짜를 'YYYY-MM-DD' 형식의 문자열 배열로 반환. + * 시작 날짜와 끝 날짜를 포함하여 날짜 범위를 계산. + * + * @param {Date} start - 범위의 시작 날짜 (포함). + * @param {Date} end - 범위의 끝 날짜 (포함). + * + * @returns {string[]} 'YYYY-MM-DD' 형식의 날짜 문자열 배열. 시작 날짜부터 끝 날짜까지의 모든 날짜가 포함. + * + * 동작 과정: + * 1. `getTime()`을 사용하여 시작 날짜와 끝 날짜 사이의 일 수를 계산합니다. + * - 시작 날짜의 시간과 끝 날짜의 시간을 빼면 두 날짜 사이의 시간 차이를 밀리초 단위로 구할 수 있다. + * - `1000 * 60 * 60 * 24`로 나누어 밀리초를 일 단위로 변환한다. + * - `Math.ceil`을 사용하여 소수점 이하의 값을 올림 처리하고, 범위에 끝 날짜까지 포함하기 위해 `+1`을 더한다. + * + * 2. `Array.from`을 사용하여 계산된 일 수만큼 배열을 생성하고, 각 인덱스에 해당하는 날짜를 계산하여 배열에 추가합니다. + * - 각 날짜는 시작 날짜에 인덱스를 더해 생성되며, getFullDate 함수를 사용하여 'YYYY-MM-DD' 형식으로 변환됩니다. + * + * 예시: + * ```ts + * const start = new Date('2023-09-01'); + * const end = new Date('2023-09-03'); + * 출력: ['2023-09-01', '2023-09-02', '2023-09-03'] + * ``` + */ +export const getDatesInRange = (start: Date, end: Date): string[] => { + const daysBetween = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; + + return Array.from({ length: daysBetween }, (_, index) => { + const date = new Date(start); + date.setDate(date.getDate() + index); + + return getFullDate(date); + }); +}; diff --git a/frontend/src/hooks/useInput/useInput.ts b/frontend/src/hooks/useInput/useInput.ts index e4c7a08c6..6aba728ee 100644 --- a/frontend/src/hooks/useInput/useInput.ts +++ b/frontend/src/hooks/useInput/useInput.ts @@ -2,9 +2,8 @@ import type { ChangeEvent } from 'react'; import { useState } from 'react'; interface ValidationRules { - minLength?: number; - maxLength?: number; pattern?: RegExp; + errorMessage?: string; } const useInput = (rules?: ValidationRules) => { @@ -14,17 +13,10 @@ const useInput = (rules?: ValidationRules) => { const getValidationError = (input: string) => { if (!rules) return null; - if (rules.minLength && input.length < rules.minLength) { - return `최소 ${rules.minLength}글자 이상이어야 합니다.`; - } - - if (rules.maxLength && input.length > rules.maxLength) { - return `최대 ${rules.maxLength}글자까지 입력 가능합니다.`; - } - if (rules.pattern && !rules.pattern.test(input)) { - return '올바른 형식이 아닙니다.'; + return rules.errorMessage || '올바른 형식이 아닙니다.'; } + return null; }; diff --git a/frontend/src/hooks/useMeetingTimeRecommendFilter/useMeetingTimeRecommendFilter.ts b/frontend/src/hooks/useMeetingTimeRecommendFilter/useMeetingTimeRecommendFilter.ts index a6a02af4a..686487269 100644 --- a/frontend/src/hooks/useMeetingTimeRecommendFilter/useMeetingTimeRecommendFilter.ts +++ b/frontend/src/hooks/useMeetingTimeRecommendFilter/useMeetingTimeRecommendFilter.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -import type { MeetingRecommend } from '@apis/meetingRecommend'; +import type { GetMeetingRecommendResponse } from '@apis/meetingRecommend'; import { useGetMeetingRecommendsQuery } from '@stores/servers/meeting/queries'; @@ -16,7 +16,7 @@ interface UseMeetingTimeRecommendFilterReturn { checkSelectedAttendee: (attendeeName: string) => boolean; isSelectedAllAttendee: boolean; toggleAttendee: (attendeeName: string) => void; - meetingTimeRecommends: MeetingRecommend[] | undefined; + meetingRecommendResponse: GetMeetingRecommendResponse | undefined; } type UseMeetingTimeRecommendFilterHook = ( @@ -44,11 +44,12 @@ type UseMeetingTimeRecommendFilterHook = ( const useMeetingTimeRecommendFilter: UseMeetingTimeRecommendFilterHook = (uuid, attendeeNames) => { const [recommendType, setRecommendType] = useState('earliest'); const [currentAttendeeNames, setCurrentAttendeeNames] = useState(attendeeNames); - const { data: meetingTimeRecommends } = useGetMeetingRecommendsQuery({ + const { data: meetingRecommendResponse } = useGetMeetingRecommendsQuery({ uuid, recommendType, currentAttendeeNames, }); + const isSelectedAllAttendee = currentAttendeeNames.length === attendeeNames.length; const checkSelectedAttendee = (attendeeName: string) => @@ -80,7 +81,7 @@ const useMeetingTimeRecommendFilter: UseMeetingTimeRecommendFilterHook = (uuid, checkSelectedAttendee, isSelectedAllAttendee, toggleAttendee, - meetingTimeRecommends, + meetingRecommendResponse, }; }; diff --git a/frontend/src/hooks/useMeetingType/useMeetingType.test.ts b/frontend/src/hooks/useMeetingType/useMeetingType.test.ts new file mode 100644 index 000000000..2542555eb --- /dev/null +++ b/frontend/src/hooks/useMeetingType/useMeetingType.test.ts @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; + +import useMeetingType from './useMeetingType'; + +describe('useMeetingType', () => { + it('isChecked의 기본값은 false입니다.', () => { + const { result } = renderHook(() => useMeetingType()); + + expect(result.current.isChecked).toBe(false); + }); + + it('handleToggleIsChecked 함수를 실행하면 값이 반전됩니다.', () => { + const { result } = renderHook(() => useMeetingType()); + + act(() => { + result.current.handleToggleIsChecked(); + }); + + expect(result.current.isChecked).toBe(true); + }); + + it('isChecked가 false라면 "DATETIME"타입을 갖습니다.', () => { + const { result } = renderHook(() => useMeetingType()); + + expect(result.current.meetingType).toBe('DATETIME'); + }); + + it('isChecked가 true라면 "DAYSONLY"타입을 갖습니다.', () => { + const { result } = renderHook(() => useMeetingType()); + + act(() => { + result.current.handleToggleIsChecked(); + }); + + expect(result.current.meetingType).toBe('DAYSONLY'); + }); +}); diff --git a/frontend/src/hooks/useMeetingType/useMeetingType.ts b/frontend/src/hooks/useMeetingType/useMeetingType.ts new file mode 100644 index 000000000..2f0706ea0 --- /dev/null +++ b/frontend/src/hooks/useMeetingType/useMeetingType.ts @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +import type { MeetingType } from '@apis/meetings'; + +export default function useMeetingType() { + const [isChecked, setIsChecked] = useState(false); + + const meetingType: MeetingType = isChecked ? 'DAYSONLY' : 'DATETIME'; + + const handleToggleIsChecked = () => { + setIsChecked((prev) => !prev); + }; + + return { meetingType, isChecked, handleToggleIsChecked }; +} diff --git a/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.test.ts b/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.test.ts index 10205522e..3dca10f5f 100644 --- a/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.test.ts +++ b/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.test.ts @@ -12,18 +12,6 @@ describe('useTimeRangeDropdown', () => { expect(result.current.endTime.value).toBe(INITIAL_END_TIME); }); - it('시작 시간(startTime)을 선택하면 끝 시간(endTime)은 시작 시간(startTime)의 1시간 이후로 설정된다.', () => { - const CHANGE_TIME = '01:00'; - const CHANGE_TIME_AFTER_HOUR = '02:00'; - const { result } = renderHook(() => useTimeRangeDropdown()); - - act(() => { - result.current.handleStartTimeChange(CHANGE_TIME); - }); - - expect(result.current.endTime.value).not.toBe(CHANGE_TIME_AFTER_HOUR); - }); - it('선택한 끝 시간(endTime)이 시작 시간(startTime)보다 빠르다면 선택한 시간값으로 변경되지 않는다.', () => { const CHANGE_START_TIME = '04:00'; const CHANGE_END_TIME = '01:00'; @@ -55,4 +43,20 @@ describe('useTimeRangeDropdown', () => { expect(result.current.endTime.value).toBe(CHANGE_END_TIME); }); + + it.each([ + ['18:00', '19:00'], + ['06:00', '07:00'], + ])( + '시작 시간(%s)을 선택하면, 끝 시간은 1시간 이후인 %s로 자동 설정된다.', + (startTime, endTime) => { + const { result } = renderHook(() => useTimeRangeDropdown()); + + act(() => { + result.current.handleStartTimeChange(startTime); + }); + + expect(result.current.endTime.value).toBe(endTime); + }, + ); }); diff --git a/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.utils.ts b/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.utils.ts index ad6750e1c..0e58c2e51 100644 --- a/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.utils.ts +++ b/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.utils.ts @@ -48,9 +48,9 @@ export function isTimeSelectable(startTime: string, endTime: string) { return true; } -// 현재 시간에서 1시간 더한 시간을 반화해주는 함수(@낙타) +// 현재 시간에서 1시간 더한 시간을 반환해주는 함수(@낙타) export function addHoursToCurrentTime(currentTime: string, hours: number) { const [currentHours, currentMinutes] = currentTime.split(':').map(Number); - return currentHours + hours + ':' + currentMinutes; + return `${String(currentHours + hours).padStart(2, '0')}:${String(currentMinutes).padStart(2, '0')}`; } diff --git a/frontend/src/mocks/meeting/data/earliestAllAttendeeSchedules.json b/frontend/src/mocks/meeting/data/earliestAllAttendeeSchedules.json index e1a815557..a7677955f 100644 --- a/frontend/src/mocks/meeting/data/earliestAllAttendeeSchedules.json +++ b/frontend/src/mocks/meeting/data/earliestAllAttendeeSchedules.json @@ -1,58 +1,61 @@ { - "data": [ - { - "startDate": "2024-07-15", - "startDayOfWeek": "월", - "startTime": "20:00", - "endDate": "2024-07-15", - "endDayOfWeek": "월", - "endTime": "21:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-16", - "startDayOfWeek": "화", - "startTime": "19:00", - "endDate": "2024-07-16", - "endDayOfWeek": "화", - "endTime": "20:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-17", - "startDayOfWeek": "수", - "startTime": "18:00", - "endDate": "2024-07-17", - "endDayOfWeek": "수", - "endTime": "19:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-18", - "startDayOfWeek": "목", - "startTime": "20:00", - "endDate": "2024-07-18", - "endDayOfWeek": "목", - "endTime": "23:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-19", - "startDayOfWeek": "금", - "startTime": "19:00", - "endDate": "2024-07-19", - "endDayOfWeek": "금", - "endTime": "21:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-20", - "startDayOfWeek": "토", - "startTime": "14:00", - "endDate": "2024-07-20", - "endDayOfWeek": "토", - "endTime": "18:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - } - ] + "data": { + "type": "DAYSONLY", + "recommendedSchedules": [ + { + "startDate": "2024-07-15", + "startDayOfWeek": "월", + "startTime": "20:00", + "endDate": "2024-07-15", + "endDayOfWeek": "월", + "endTime": "21:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-16", + "startDayOfWeek": "화", + "startTime": "19:00", + "endDate": "2024-07-16", + "endDayOfWeek": "화", + "endTime": "20:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-17", + "startDayOfWeek": "수", + "startTime": "18:00", + "endDate": "2024-07-17", + "endDayOfWeek": "수", + "endTime": "19:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-18", + "startDayOfWeek": "목", + "startTime": "20:00", + "endDate": "2024-07-18", + "endDayOfWeek": "목", + "endTime": "23:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-19", + "startDayOfWeek": "금", + "startTime": "19:00", + "endDate": "2024-07-19", + "endDayOfWeek": "금", + "endTime": "21:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-20", + "startDayOfWeek": "토", + "startTime": "14:00", + "endDate": "2024-07-20", + "endDayOfWeek": "토", + "endTime": "18:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + } + ] + } } diff --git a/frontend/src/mocks/meeting/data/earliestPartialAttendeeSchedules.json b/frontend/src/mocks/meeting/data/earliestPartialAttendeeSchedules.json index 0c7159495..f7ffd4058 100644 --- a/frontend/src/mocks/meeting/data/earliestPartialAttendeeSchedules.json +++ b/frontend/src/mocks/meeting/data/earliestPartialAttendeeSchedules.json @@ -1,58 +1,61 @@ { - "data": [ - { - "startDate": "2024-07-15", - "startDayOfWeek": "월", - "startTime": "18:00", - "endDate": "2024-07-15", - "endDayOfWeek": "월", - "endTime": "19:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-16", - "startDayOfWeek": "화", - "startTime": "17:00", - "endDate": "2024-07-16", - "endDayOfWeek": "화", - "endTime": "18:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-17", - "startDayOfWeek": "수", - "startTime": "16:00", - "endDate": "2024-07-17", - "endDayOfWeek": "수", - "endTime": "17:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-18", - "startDayOfWeek": "목", - "startTime": "19:00", - "endDate": "2024-07-18", - "endDayOfWeek": "목", - "endTime": "23:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-19", - "startDayOfWeek": "금", - "startTime": "18:00", - "endDate": "2024-07-19", - "endDayOfWeek": "금", - "endTime": "21:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-20", - "startDayOfWeek": "토", - "startTime": "12:00", - "endDate": "2024-07-20", - "endDayOfWeek": "토", - "endTime": "17:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - } - ] + "data": { + "type": "DAYSONLY", + "recommendedSchedules": [ + { + "startDate": "2024-07-15", + "startDayOfWeek": "월", + "startTime": "18:00", + "endDate": "2024-07-15", + "endDayOfWeek": "월", + "endTime": "19:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-16", + "startDayOfWeek": "화", + "startTime": "17:00", + "endDate": "2024-07-16", + "endDayOfWeek": "화", + "endTime": "18:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-17", + "startDayOfWeek": "수", + "startTime": "16:00", + "endDate": "2024-07-17", + "endDayOfWeek": "수", + "endTime": "17:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-18", + "startDayOfWeek": "목", + "startTime": "19:00", + "endDate": "2024-07-18", + "endDayOfWeek": "목", + "endTime": "23:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-19", + "startDayOfWeek": "금", + "startTime": "18:00", + "endDate": "2024-07-19", + "endDayOfWeek": "금", + "endTime": "21:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-20", + "startDayOfWeek": "토", + "startTime": "12:00", + "endDate": "2024-07-20", + "endDayOfWeek": "토", + "endTime": "17:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + } + ] + } } diff --git a/frontend/src/mocks/meeting/data/longTermAllAttendeeSchedules.json b/frontend/src/mocks/meeting/data/longTermAllAttendeeSchedules.json index 8dce4ef80..dee3d4d1b 100644 --- a/frontend/src/mocks/meeting/data/longTermAllAttendeeSchedules.json +++ b/frontend/src/mocks/meeting/data/longTermAllAttendeeSchedules.json @@ -1,58 +1,61 @@ { - "data": [ - { - "startDate": "2024-07-20", - "startDayOfWeek": "토", - "startTime": "14:00", - "endDate": "2024-07-20", - "endDayOfWeek": "토", - "endTime": "18:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-18", - "startDayOfWeek": "목", - "startTime": "20:00", - "endDate": "2024-07-18", - "endDayOfWeek": "목", - "endTime": "23:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-19", - "startDayOfWeek": "금", - "startTime": "19:00", - "endDate": "2024-07-19", - "endDayOfWeek": "금", - "endTime": "21:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-16", - "startDayOfWeek": "화", - "startTime": "19:00", - "endDate": "2024-07-16", - "endDayOfWeek": "화", - "endTime": "20:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-17", - "startDayOfWeek": "수", - "startTime": "18:00", - "endDate": "2024-07-17", - "endDayOfWeek": "수", - "endTime": "19:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - }, - { - "startDate": "2024-07-15", - "startDayOfWeek": "월", - "startTime": "20:00", - "endDate": "2024-07-15", - "endDayOfWeek": "월", - "endTime": "21:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] - } - ] + "data": { + "type": "DATETIME", + "recommendedSchedules": [ + { + "startDate": "2024-07-20", + "startDayOfWeek": "토", + "startTime": "14:00", + "endDate": "2024-07-20", + "endDayOfWeek": "토", + "endTime": "18:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-18", + "startDayOfWeek": "목", + "startTime": "20:00", + "endDate": "2024-07-18", + "endDayOfWeek": "목", + "endTime": "23:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-19", + "startDayOfWeek": "금", + "startTime": "19:00", + "endDate": "2024-07-19", + "endDayOfWeek": "금", + "endTime": "21:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-16", + "startDayOfWeek": "화", + "startTime": "19:00", + "endDate": "2024-07-16", + "endDayOfWeek": "화", + "endTime": "20:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-17", + "startDayOfWeek": "수", + "startTime": "18:00", + "endDate": "2024-07-17", + "endDayOfWeek": "수", + "endTime": "19:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + }, + { + "startDate": "2024-07-15", + "startDayOfWeek": "월", + "startTime": "20:00", + "endDate": "2024-07-15", + "endDayOfWeek": "월", + "endTime": "21:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키", "페드로"] + } + ] + } } diff --git a/frontend/src/mocks/meeting/data/longTermPartialAttendeeSchedules.json b/frontend/src/mocks/meeting/data/longTermPartialAttendeeSchedules.json index 0e0a426cb..2cc51409f 100644 --- a/frontend/src/mocks/meeting/data/longTermPartialAttendeeSchedules.json +++ b/frontend/src/mocks/meeting/data/longTermPartialAttendeeSchedules.json @@ -1,58 +1,61 @@ { - "data": [ - { - "startDate": "2024-07-20", - "startDayOfWeek": "토", - "startTime": "12:00", - "endDate": "2024-07-20", - "endDayOfWeek": "토", - "endTime": "17:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-20", - "startDayOfWeek": "목", - "startTime": "19:00", - "endDate": "2024-07-18", - "endDayOfWeek": "목", - "endTime": "23:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-19", - "startDayOfWeek": "금", - "startTime": "18:00", - "endDate": "2024-07-19", - "endDayOfWeek": "금", - "endTime": "21:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-16", - "startDayOfWeek": "화", - "startTime": "17:00", - "endDate": "2024-07-16", - "endDayOfWeek": "화", - "endTime": "18:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-17", - "startDayOfWeek": "수", - "startTime": "16:00", - "endDate": "2024-07-17", - "endDayOfWeek": "수", - "endTime": "17:30", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - }, - { - "startDate": "2024-07-15", - "startDayOfWeek": "월", - "startTime": "18:00", - "endDate": "2024-07-15", - "endDayOfWeek": "월", - "endTime": "19:00", - "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] - } - ] + "data": { + "type": "DATETIME", + "recommendedSchedules": [ + { + "startDate": "2024-07-20", + "startDayOfWeek": "토", + "startTime": "12:00", + "endDate": "2024-07-20", + "endDayOfWeek": "토", + "endTime": "17:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-20", + "startDayOfWeek": "목", + "startTime": "19:00", + "endDate": "2024-07-18", + "endDayOfWeek": "목", + "endTime": "23:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-19", + "startDayOfWeek": "금", + "startTime": "18:00", + "endDate": "2024-07-19", + "endDayOfWeek": "금", + "endTime": "21:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-16", + "startDayOfWeek": "화", + "startTime": "17:00", + "endDate": "2024-07-16", + "endDayOfWeek": "화", + "endTime": "18:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-17", + "startDayOfWeek": "수", + "startTime": "16:00", + "endDate": "2024-07-17", + "endDayOfWeek": "수", + "endTime": "17:30", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + }, + { + "startDate": "2024-07-15", + "startDayOfWeek": "월", + "startTime": "18:00", + "endDate": "2024-07-15", + "endDayOfWeek": "월", + "endTime": "19:00", + "attendeeNames": ["다온", "마크", "해리", "낙타", "빙봉", "재즈", "배키"] + } + ] + } } diff --git a/frontend/src/mocks/meeting/data/meetingAllSchedules.json b/frontend/src/mocks/meeting/data/meetingAllSchedules.json index 26c9cfad8..da9d5d2f6 100644 --- a/frontend/src/mocks/meeting/data/meetingAllSchedules.json +++ b/frontend/src/mocks/meeting/data/meetingAllSchedules.json @@ -2,37 +2,37 @@ "data": { "schedules": [ { - "date": "2024-07-24", + "date": "2024-10-24", "time": "13:00", "attendeeNames": ["해리", "낙타", "빙봉"] }, { - "date": "2024-07-24", + "date": "2024-10-24", "time": "14:00", "attendeeNames": ["낙타", "빙봉"] }, { - "date": "2024-07-25", + "date": "2024-10-25", "time": "14:00", "attendeeNames": ["해리", "빙봉"] }, { - "date": "2024-07-25", + "date": "2024-10-25", "time": "15:00", "attendeeNames": ["해리", "낙타"] }, { - "date": "2024-07-26", + "date": "2024-10-26", "time": "16:00", "attendeeNames": ["해리"] }, { - "date": "2024-07-26", + "date": "2024-10-26", "time": "17:00", "attendeeNames": ["빙봉"] }, { - "date": "2024-07-27", + "date": "2024-10-27", "time": "17:00", "attendeeNames": ["빙봉"] } diff --git a/frontend/src/mocks/meeting/data/meetingSingleSchedule.json b/frontend/src/mocks/meeting/data/meetingSingleSchedule.json index f13f65e4e..400dde8b0 100644 --- a/frontend/src/mocks/meeting/data/meetingSingleSchedule.json +++ b/frontend/src/mocks/meeting/data/meetingSingleSchedule.json @@ -1,13 +1,13 @@ { "data": { - "attendeeName": "name", + "attendeeName": "모모", "schedules": [ { - "date": "2024-07-25", + "date": "2024-10-25", "times": ["12:00", "13:00", "14:00"] }, { - "date": "2024-07-26", + "date": "2024-10-26", "times": ["16:00", "17:00", "18:00"] } ] diff --git a/frontend/src/mocks/meeting/data/meetingTableFrame.json b/frontend/src/mocks/meeting/data/meetingTableFrame.json index 123265a1b..0d23f8c1f 100644 --- a/frontend/src/mocks/meeting/data/meetingTableFrame.json +++ b/frontend/src/mocks/meeting/data/meetingTableFrame.json @@ -1,10 +1,11 @@ { "data": { "meetingName": "momo", + "type": "DAYSONLY", "firstTime": "10:00", "lastTime": "18:00", "isLocked": false, - "availableDates": ["2024-07-24", "2024-07-25", "2024-07-26", "2024-07-27"], + "availableDates": ["2024-10-24", "2024-10-25", "2024-10-26", "2024-10-27"], "attendeeNames": ["낙타", "해리", "빙봉"] } } diff --git a/frontend/src/mocks/meeting/data/mySchedule.json b/frontend/src/mocks/meeting/data/mySchedule.json new file mode 100644 index 000000000..7bf8f2fd4 --- /dev/null +++ b/frontend/src/mocks/meeting/data/mySchedule.json @@ -0,0 +1,19 @@ +{ + "data": { + "attendeeName": "모모", + "schedules": [ + { + "date": "2024-10-24", + "times": ["00:00"] + }, + { + "date": "2024-10-25", + "times": ["00:00"] + }, + { + "date": "2024-10-27", + "times": ["00:00"] + } + ] + } +} diff --git a/frontend/src/mocks/meeting/meetingHandlers.ts b/frontend/src/mocks/meeting/meetingHandlers.ts index fd9a4dc4d..3b343d675 100644 --- a/frontend/src/mocks/meeting/meetingHandlers.ts +++ b/frontend/src/mocks/meeting/meetingHandlers.ts @@ -1,10 +1,13 @@ import { HttpResponse, http } from 'msw'; +import type { PostMeetingRequest } from '@apis/meetings'; + import { BASE_URL } from '@constants/api'; import meetingAllSchedules from './data/meetingAllSchedules.json'; import meetingSingleSchedule from './data/meetingSingleSchedule.json'; import meetingTableFrame from './data/meetingTableFrame.json'; +import mySchedule from './data/mySchedule.json'; const meetingHandlers = [ http.get(`${BASE_URL}/:uuid`, () => { @@ -20,6 +23,30 @@ const meetingHandlers = [ } return HttpResponse.json(meetingSingleSchedule, { status: 200 }); }), + + http.get(`${BASE_URL}/:uuid/attendees/me/schedules`, () => { + return HttpResponse.json(mySchedule, { status: 200 }); + }), + + // 요청한 type에 따라서 response 또한 같은 type이 설정되어야 하기 때문에 이와 같이 설정함(@낙타) + http.post(`${BASE_URL}`, async ({ request }) => { + const req = (await request.json()) as PostMeetingRequest; + + return HttpResponse.json( + { + data: { + uuid: 'rAnDoMUuID12', + meetingName: 'abc', + hostName: '1234', + availableDates: ['2024-09-25', '2024-09-26'], + earliestTime: '00:00', + lastTime: '00:00', + type: req.type, + }, + }, + { status: 200 }, + ); + }), ]; export default meetingHandlers; diff --git a/frontend/src/pages/AttendeeLoginPage/index.tsx b/frontend/src/pages/AttendeeLoginPage/index.tsx index 08f8a5665..30925d20f 100644 --- a/frontend/src/pages/AttendeeLoginPage/index.tsx +++ b/frontend/src/pages/AttendeeLoginPage/index.tsx @@ -1,6 +1,5 @@ import { useParams } from 'react-router-dom'; -import PasswordInput from '@components/PasswordInput'; import { Button } from '@components/_common/Buttons/Button'; import Field from '@components/_common/Field'; import Input from '@components/_common/Input'; @@ -9,7 +8,7 @@ import useInput from '@hooks/useInput/useInput'; import { usePostLoginMutation } from '@stores/servers/user/mutations'; -import { FIELD_DESCRIPTIONS, INPUT_FIELD_RULES } from '@constants/inputFields'; +import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN } from '@constants/inputFields'; import { s_container, s_inputContainer } from './AttendeeLoginPage.styles'; @@ -22,8 +21,8 @@ export default function AttendeeLoginPage() { onValueChange: handleAttendeeNameChange, errorMessage: attendeeNameErrorMessage, } = useInput({ - minLength: INPUT_FIELD_RULES.nickname.minLength, - maxLength: INPUT_FIELD_RULES.nickname.maxLength, + pattern: INPUT_FIELD_PATTERN.nickname, + errorMessage: FIELD_DESCRIPTIONS.nickname, }); const { @@ -31,9 +30,8 @@ export default function AttendeeLoginPage() { onValueChange: handleAttendeePasswordChange, errorMessage: attendeePasswordErrorMessage, } = useInput({ - minLength: INPUT_FIELD_RULES.password.minLength, - maxLength: INPUT_FIELD_RULES.password.maxLength, - pattern: INPUT_FIELD_RULES.password.pattern, + pattern: INPUT_FIELD_PATTERN.password, + errorMessage: FIELD_DESCRIPTIONS.password, }); const isFormValid = () => { @@ -79,7 +77,11 @@ export default function AttendeeLoginPage() { - ([]); + const { + selectedDates, + handleSelectedDates, + hasDate, + dateSelectMode, + checkIsRangeStartDate, + checkIsRangeEndDate, + isAllRangeSelected, + toggleDateSelectMode, + } = useDateSelect(); const { startTime, endTime, handleStartTimeChange, handleEndTimeChange } = useTimeRangeDropdown(); - - const hasDate = (date: string) => selectedDates.includes(date); - - const handleDateClick = (date: string) => { - setSelectedDates((prevDates) => - hasDate(date) ? prevDates.filter((d) => d !== date) : [...prevDates, date], - ); - }; + const { meetingType, isChecked, handleToggleIsChecked } = useMeetingType(); const isFormValid = () => { const errorMessages = [meetingNameErrorMessage, hostNameErrorMessage, hostPasswordError]; @@ -82,12 +102,40 @@ export default function CreateMeetingPage() { hostPassword: hostPassword, meetingName: meetingName, availableMeetingDates: selectedDates, - meetingStartTime: startTime.value, + meetingStartTime: isChecked ? '00:00' : startTime.value, // 시간상 24시는 존재하지 않기 때문에 백엔드에서 오류가 발생. 따라서 오전 12:00으로 표현하지만, 서버에 00:00으로 전송(@낙타) - meetingEndTime: endTime.value === INITIAL_END_TIME ? INITIAL_START_TIME : endTime.value, + meetingEndTime: isChecked + ? '00:00' + : endTime.value === INITIAL_END_TIME + ? INITIAL_START_TIME + : endTime.value, + type: meetingType, }); }; + const renderDate = (dateInfo: DateInfo, today: Date) => { + return dateSelectMode === 'single' ? ( + + ) : ( + + ); + }; + return (
{/* 추후 form 태그로 수정 예정 (@Largopie) */} @@ -114,36 +162,116 @@ export default function CreateMeetingPage() { - + - - + + + + ( + + )} + /> + } + /> + + - - - - + + + {!isChecked && ( + + + + + )}
+ +
+

+ 약속명 + {meetingName} +

+

+ 주최자 + {hostName} +

+ {!isChecked && ( +

+ 약속 시간 + {startTime.value} ~ {endTime.value} +

+ )} +
+ 가능 날짜 + {groupDates(selectedDates).map(([monthYear, dates]) => { + const [year, month] = monthYear.split('-'); + return ( +
+

+ {year}년 {month}월 +

+ {dates.join(', ')} +
+ ); + })} +
+
+
); } diff --git a/frontend/src/pages/FixedMeetingTicketPage/components/MeetingTicket/TicketInfo/index.tsx b/frontend/src/pages/FixedMeetingTicketPage/components/MeetingTicket/TicketInfo/index.tsx index 2618db768..6e48ec27f 100644 --- a/frontend/src/pages/FixedMeetingTicketPage/components/MeetingTicket/TicketInfo/index.tsx +++ b/frontend/src/pages/FixedMeetingTicketPage/components/MeetingTicket/TicketInfo/index.tsx @@ -20,6 +20,7 @@ export default function TicketInfo({ data }: MeetingTicketProps) { endDate, endTime, endDayOfWeek, + type, } = data; const formattedStartFullDate = formatFullDate({ fullDate: startDate, dayOfWeek: startDayOfWeek }); @@ -39,12 +40,14 @@ export default function TicketInfo({ data }: MeetingTicketProps) {
{dateDisplay}
-
-
시간
-
- {startTime} ~ {endTime} + {type === 'DATETIME' && ( +
+
시간
+
+ {startTime} ~ {endTime} +
-
+ )}
참여자 ({availableAttendeeNames.length}명)
diff --git a/frontend/src/pages/FixedMeetingTicketPage/components/UnconfirmedMessage/index.tsx b/frontend/src/pages/FixedMeetingTicketPage/components/UnconfirmedMessage/index.tsx index 080a4089d..2faa1be5b 100644 --- a/frontend/src/pages/FixedMeetingTicketPage/components/UnconfirmedMessage/index.tsx +++ b/frontend/src/pages/FixedMeetingTicketPage/components/UnconfirmedMessage/index.tsx @@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom'; import { Button } from '@components/_common/Buttons/Button'; -import SadMomoCharacter from '@assets/images/sadMomoCharacter.svg'; +import QuestionMomoCharacter from '@assets/images/questionMomoCharacter.svg'; import { s_container } from './UnconfirmedMessage.styles'; @@ -18,7 +18,7 @@ function UnconfirmedMessage({ uuid }: UnconfirmedMessageProps) { return (
- +
약속이 아직 확정되지 않았어요 :(
); } diff --git a/frontend/src/pages/MeetingLinkSharePage/MeetingLinkSharePage.styles.ts b/frontend/src/pages/MeetingLinkSharePage/MeetingLinkSharePage.styles.ts index cef9f407c..4a66b62dc 100644 --- a/frontend/src/pages/MeetingLinkSharePage/MeetingLinkSharePage.styles.ts +++ b/frontend/src/pages/MeetingLinkSharePage/MeetingLinkSharePage.styles.ts @@ -1,6 +1,4 @@ -import { css, keyframes } from '@emotion/react'; - -import theme from '@styles/theme'; +import { css } from '@emotion/react'; export const s_container = css` display: flex; @@ -35,103 +33,3 @@ export const s_buttonContainer = css` export const s_button = css` height: 2.8rem; `; - -export const s_copyContainer = css` - display: flex; - align-items: center; - - width: 100%; - height: 3.6rem; - - background-color: ${theme.colors.white}; - border-radius: 0.8rem; -`; - -export const s_urlText = css` - overflow: hidden; - display: block; - - width: calc(100% - 3.6rem); - height: 100%; - padding: 0.8rem 0 0.8rem 0.8rem; - - color: ${theme.colors.grey.dark}; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - - border-radius: 0.8rem; -`; - -export const s_copyButtonContainer = css` - display: flex; - align-items: center; - justify-content: center; - - width: 3.6rem; - height: 3.6rem; -`; - -export const s_copyButton = css` - width: 100%; - height: 100%; - background-color: inherit; - border-radius: 0.8rem; - - &:hover { - opacity: 0.3; - } -`; - -const drawCheck = keyframes` - to { - stroke-dashoffset: 0; - } -`; - -export const s_check = css` - path { - fill: none; - stroke: ${theme.colors.green.deepDark}; - stroke-dasharray: 100; - stroke-dashoffset: 100; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 8; - - animation: ${drawCheck} 0.8s ease-in-out forwards; - } -`; - -export const s_descriptionContainer = css` - display: flex; - flex-direction: column; - gap: 0.8rem; -`; - -export const s_description = css` - h2 { - ${theme.typography.bodyBold} - } - - h3 { - ${theme.typography.captionBold} - } - - p { - ${theme.typography.captionMedium} - display: flex; - flex-direction: column; - gap: 0.4rem; - } - - span { - ${theme.typography.captionMedium} - } -`; - -export const s_availableDatesContainer = css` - display: grid; - grid-template-columns: 1fr 10fr; - gap: 0.4rem; -`; diff --git a/frontend/src/pages/MeetingLinkSharePage/index.tsx b/frontend/src/pages/MeetingLinkSharePage/index.tsx index a5669b426..9265e035b 100644 --- a/frontend/src/pages/MeetingLinkSharePage/index.tsx +++ b/frontend/src/pages/MeetingLinkSharePage/index.tsx @@ -1,34 +1,21 @@ -import { useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import type { PostMeetingResult } from 'types/meeting'; import { Button } from '@components/_common/Buttons/Button'; +import CopyLink from '@components/_common/CopyLink'; import useKakaoTalkShare from '@hooks/useKakaoTalkShare/useKakaoTalkShare'; -import { copyToClipboard } from '@utils/clipboard'; -import groupDates from '@utils/groupDates'; - -import CheckIcon from '@assets/images/check.svg'; -import CopyIcon from '@assets/images/copy.svg'; import KakaoIcon from '@assets/images/kakao.svg'; import LogoSunglass from '@assets/images/logoSunglass.svg'; import { MEETING_INVITE_TEMPLATE_ID } from '@constants/kakao'; import { - s_availableDatesContainer, s_button, s_buttonContainer, - s_check, s_container, - s_copyButton, - s_copyButtonContainer, - s_copyContainer, - s_description, - s_descriptionContainer, s_meetingInfo, - s_urlText, } from './MeetingLinkSharePage.styles'; interface RouteState { @@ -41,7 +28,6 @@ export default function MeetingLinkSharePage() { const { state: { meetingInfo }, } = useLocation() as RouteState; - const [copyComplete, setCopyComplete] = useState(false); const navigate = useNavigate(); const params = useParams<{ uuid: string }>(); const uuid = params.uuid!; @@ -49,11 +35,6 @@ export default function MeetingLinkSharePage() { const { handleKakaoTalkShare } = useKakaoTalkShare(); - const handleCopyClick = async () => { - setCopyComplete(true); - setTimeout(() => setCopyComplete(false), 2500); - }; - const handleKakaoButtonClick = () => { handleKakaoTalkShare(MEETING_INVITE_TEMPLATE_ID, { path: uuid, @@ -66,48 +47,13 @@ export default function MeetingLinkSharePage() {
-
-
-

약속명

-

{meetingInfo.meetingName}

-
-
-

주최자

-

{meetingInfo.userName}

-
-
-

시작시간

-

{meetingInfo.firstTime}

-
-
-

끝시간

-

{meetingInfo.lastTime === '00:00' ? '24:00' : meetingInfo.lastTime}

-
-
-

가능시간

-

- {Object.entries(groupDates(meetingInfo.availableDates)).map(([month, dates]) => ( -

-

{month}월

- {dates.join(', ')} -
- ))} -

-
-
+
-
- {LINK} -
- {copyComplete ? ( - - ) : ( - - )} -
-
+ + -
diff --git a/frontend/src/pages/MeetingRecommendPage/components/MeetingTimeRecommends.tsx b/frontend/src/pages/MeetingRecommendPage/components/MeetingTimeRecommends.tsx index 807d69887..9bdfd0228 100644 --- a/frontend/src/pages/MeetingRecommendPage/components/MeetingTimeRecommends.tsx +++ b/frontend/src/pages/MeetingRecommendPage/components/MeetingTimeRecommends.tsx @@ -1,4 +1,5 @@ -import MeetingRecommendCard from '@components/MeetingTimeCard/MeetingTimeRecommendCard'; +import MeetingTimeRecommendCard from '@components/MeetingTimeCard/MeetingTimeRecommendCard'; +import MeetingTimeRecommendCardDaysOnly from '@components/MeetingTimeCard/MeetingTimeRecommendCardDaysOnly'; import TabButton from '@components/_common/Buttons/TabButton'; import Dropdown from '@components/_common/Dropdown'; @@ -14,7 +15,7 @@ interface MeetingRecommendsProps { export default function MeetingTimeRecommends({ uuid, attendeeNames }: MeetingRecommendsProps) { const { - meetingTimeRecommends, + meetingRecommendResponse, isSelectedAllAttendee, toggleAttendee, checkSelectedAttendee, @@ -52,14 +53,22 @@ export default function MeetingTimeRecommends({ uuid, attendeeNames }: MeetingRe { value: 'longTerm', label: '길게 만나고 싶어요' }, ]} /> - {meetingTimeRecommends && - meetingTimeRecommends.map((recommendInfo, index) => ( - - ))} + {meetingRecommendResponse && + meetingRecommendResponse.recommendedSchedules.map((recommendInfo, index) => + meetingRecommendResponse.type === 'DATETIME' ? ( + + ) : ( + + ), + )}
); } diff --git a/frontend/src/pages/MeetingTimePickPage/index.tsx b/frontend/src/pages/MeetingTimePickPage/index.tsx index 3d37270c7..cac09bcb8 100644 --- a/frontend/src/pages/MeetingTimePickPage/index.tsx +++ b/frontend/src/pages/MeetingTimePickPage/index.tsx @@ -4,11 +4,14 @@ import { useParams } from 'react-router-dom'; import { AuthContext } from '@contexts/AuthProvider'; import { TimePickerUpdateStateContext } from '@contexts/TimePickerUpdateStateProvider'; +import MeetingConfirmCalendar from '@components/MeetingConfirmCalendar'; import SchedulePickerContainer from '@components/Schedules/SchedulePicker/SchedulePickerContainer'; import SchedulesViewer from '@components/Schedules/ScheduleViewer/SchedulesViewer'; import ToggleButton from '@components/_common/Buttons/ToggleButton'; import Text from '@components/_common/Text'; +import type { MeetingType } from '@apis/meetings'; + import { useLockMeetingMutation, useUnlockMeetingMutation } from '@stores/servers/meeting/mutation'; import { useGetMeetingQuery } from '@stores/servers/meeting/queries'; @@ -52,6 +55,41 @@ export default function MeetingTimePickPage() { } }; + const renderPicker = (meetingType: MeetingType) => { + if (!meetingFrame) return; + + switch (meetingType) { + case 'DATETIME': + return isTimePickerUpdate ? ( + + ) : ( + + ); + default: + return isTimePickerUpdate ? ( + + ) : ( + + ); + } + }; + return (
@@ -79,24 +117,7 @@ export default function MeetingTimePickPage() {
)} - {meetingFrame && !isTimePickerUpdate ? ( - - ) : ( - meetingFrame && ( - - ) - )} + {meetingFrame && renderPicker(meetingFrame.type)}
); } diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 69a54165b..43d7de756 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,3 +1,5 @@ +import type { ComponentType } from 'react'; +import { Suspense, lazy } from 'react'; import type { RouteObject } from 'react-router-dom'; import { createBrowserRouter } from 'react-router-dom'; @@ -5,24 +7,31 @@ import GlobalLayout from '@layouts/GlobalLayout'; import { TimePickerUpdateStateProvider } from '@contexts/TimePickerUpdateStateProvider'; -import AttendeeLoginPage from '@pages/AttendeeLoginPage'; -import CreateMeetingPage from '@pages/CreateMeetingPage'; -import FixedMeetingTicketPage from '@pages/FixedMeetingTicketPage'; -import LandingPage from '@pages/LandingPage'; -import MeetingConfirmPage from '@pages/MeetingConfirmPage'; -import MeetingLinkSharePage from '@pages/MeetingLinkSharePage'; -import MeetingRecommendPage from '@pages/MeetingRecommendPage'; -import MeetingTimePickPage from '@pages/MeetingTimePickPage'; -import NotFoundPage from '@pages/NotFoundPage'; +import PageMoveLoading from '@components/_common/PageMoveLoading'; +const AttendeeLoginPage = lazy(() => import('@pages/AttendeeLoginPage')); +const CreateMeetingPage = lazy(() => import('@pages/CreateMeetingPage')); +const FixedMeetingTicketPage = lazy(() => import('@pages/FixedMeetingTicketPage')); +const LandingPage = lazy(() => import('@pages/LandingPage')); +const MeetingConfirmPage = lazy(() => import('@pages/MeetingConfirmPage')); +const MeetingLinkSharePage = lazy(() => import('@pages/MeetingLinkSharePage')); +const MeetingRecommendPage = lazy(() => import('@pages/MeetingRecommendPage')); +const MeetingTimePickPage = lazy(() => import('@pages/MeetingTimePickPage')); +const NotFoundPage = lazy(() => import('@pages/NotFoundPage')); + +const SuspenseWrapper = (Component: ComponentType) => ( + }> + + +); const meetingRoutes: RouteObject[] = [ { index: true, - element: , + element: SuspenseWrapper(LandingPage), }, { path: 'create', - element: , + element: SuspenseWrapper(CreateMeetingPage), }, { path: ':uuid', @@ -31,29 +40,29 @@ const meetingRoutes: RouteObject[] = [ index: true, element: ( - + {SuspenseWrapper(MeetingTimePickPage)} ), }, { path: 'login', - element: , + element: SuspenseWrapper(AttendeeLoginPage), }, { path: 'recommend', - element: , + element: SuspenseWrapper(MeetingRecommendPage), }, { path: 'confirm', - element: , + element: SuspenseWrapper(MeetingConfirmPage), }, { path: 'complete', - element: , + element: SuspenseWrapper(MeetingLinkSharePage), }, { path: 'fixed-meeting-ticket', - element: , + element: SuspenseWrapper(FixedMeetingTicketPage), }, ], }, @@ -67,11 +76,11 @@ const router = createBrowserRouter([ children: [ { path: '*', - element: , + element: SuspenseWrapper(NotFoundPage), }, { index: true, - element: , + element: SuspenseWrapper(LandingPage), }, { path: 'meeting', diff --git a/frontend/src/stores/servers/schedule/queries.ts b/frontend/src/stores/servers/schedule/queries.ts new file mode 100644 index 000000000..d354be165 --- /dev/null +++ b/frontend/src/stores/servers/schedule/queries.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { handleGetMeetingSchedules } from '@apis/schedules'; + +import { QUERY_KEY } from '@constants/queryKeys'; + +export const useGetSchedules = (uuid: string, selectedAttendee: string) => + useQuery({ + queryKey: [QUERY_KEY.meetingSchedules, selectedAttendee], + queryFn: () => handleGetMeetingSchedules({ uuid, attendeeName: selectedAttendee }), + staleTime: 0, + }); diff --git a/frontend/src/styles/fonts.ts b/frontend/src/styles/fonts.ts index a670d110f..1876325ee 100644 --- a/frontend/src/styles/fonts.ts +++ b/frontend/src/styles/fonts.ts @@ -1,57 +1,27 @@ import { css } from '@emotion/react'; export const fontFaces = css` - @font-face { - font-family: Pretendard; - font-weight: 100; - src: url(${require('../assets/fonts/Pretendard-Thin.woff2')}) format('woff2'); - } - - @font-face { - font-family: Pretendard; - font-weight: 200; - src: url(${require('../assets/fonts/Pretendard-ExtraLight.woff2')}) format('woff2'); - } - @font-face { font-family: Pretendard; font-weight: 300; - src: url(${require('../assets/fonts/Pretendard-Light.woff2')}) format('woff2'); + src: url(${require('../assets/fonts/Pretendard-Light.subset.woff2')}) format('woff2'); } @font-face { font-family: Pretendard; font-weight: 400; - src: url(${require('../assets/fonts/Pretendard-Regular.woff2')}) format('woff2'); + src: url(${require('../assets/fonts/Pretendard-Regular.subset.woff2')}) format('woff2'); } @font-face { font-family: Pretendard; font-weight: 500; - src: url(${require('../assets/fonts/Pretendard-Medium.woff2')}) format('woff2'); - } - - @font-face { - font-family: Pretendard; - font-weight: 600; - src: url(${require('../assets/fonts/Pretendard-SemiBold.woff2')}) format('woff2'); + src: url(${require('../assets/fonts/Pretendard-Medium.subset.woff2')}) format('woff2'); } @font-face { font-family: Pretendard; font-weight: 700; - src: url(${require('../assets/fonts/Pretendard-Bold.woff2')}) format('woff2'); - } - - @font-face { - font-family: Pretendard; - font-weight: 800; - src: url(${require('../assets/fonts/Pretendard-ExtraBold.woff2')}) format('woff2'); - } - - @font-face { - font-family: Pretendard; - font-weight: 900; - src: url(${require('../assets/fonts/Pretendard-Black.woff2')}) format('woff2'); + src: url(${require('../assets/fonts/Pretendard-Bold.subset.woff2')}) format('woff2'); } `; diff --git a/frontend/src/types/calendar.ts b/frontend/src/types/calendar.ts new file mode 100644 index 000000000..db690cd74 --- /dev/null +++ b/frontend/src/types/calendar.ts @@ -0,0 +1,14 @@ +export type DateSelectMode = 'single' | 'range'; + +export type MonthStatus = 'prev' | 'current' | 'next'; + +export interface DateInfo { + key: string; + value: Date; + status: MonthStatus; +} + +export type MonthlyDays = { + key: number; + value: DateInfo[]; +}[]; diff --git a/frontend/src/types/images.d.ts b/frontend/src/types/images.d.ts index f6ac688e5..df36c3c36 100644 --- a/frontend/src/types/images.d.ts +++ b/frontend/src/types/images.d.ts @@ -2,3 +2,4 @@ declare module '*.png'; declare module '*.jpg'; declare module '*.jpeg'; declare module '*.svg'; +declare module 'react-lottie'; diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index ee0878ea6..a3236a82a 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -20,3 +20,15 @@ export const formatTime = (time: string): string => { return `${hourPrefix} ${formattedHour}시`; }; + +export const getMonth = (date: Date) => date.getMonth(); +export const getYear = (date: Date) => date.getFullYear(); +export const getDay = (date: Date) => date.getDay(); +export const getDate = (date: Date) => date.getDate(); +export const getFullDate = (date: Date) => { + const year = getYear(date); + const month = String(getMonth(date) + 1).padStart(2, '0'); + const day = String(getDate(date)).padStart(2, '0'); + + return `${year}-${month}-${day}`; +}; diff --git a/frontend/src/utils/groupDates.ts b/frontend/src/utils/groupDates.ts index 2653ad410..48f977984 100644 --- a/frontend/src/utils/groupDates.ts +++ b/frontend/src/utils/groupDates.ts @@ -1,12 +1,16 @@ export default function groupDates(fullDates: string[]) { - const groupedDates: Record = {}; + const sortedFullDates = fullDates.sort( + (prevDate, nextDate) => new Date(prevDate).getTime() - new Date(nextDate).getTime(), + ); + const groupedDates = new Map(); - fullDates.forEach((fullDate) => { - const [, month, date] = fullDate.split('-').map(Number); + sortedFullDates.forEach((fullDate) => { + const [year, month, date] = fullDate.split('-').map(Number); + const key = `${year}-${String(month).padStart(2, '0')}`; - if (!groupedDates[month]) groupedDates[month] = [date]; - else groupedDates[month].push(date); + if (!groupedDates.has(key)) groupedDates.set(key, [date]); + else groupedDates.get(key)!.push(date); }); - return groupedDates; + return Array.from(groupedDates.entries()); } diff --git a/frontend/src/utils/typeGuards.ts b/frontend/src/utils/typeGuards.ts new file mode 100644 index 000000000..a8ef2900d --- /dev/null +++ b/frontend/src/utils/typeGuards.ts @@ -0,0 +1,6 @@ +export const isValidArrayType = ( + validArray: readonly K[], + candidateArray: readonly K[], +): candidateArray is T[] => { + return candidateArray.every((element) => validArray.includes(element)); +}; diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index ca1200e8f..3be5150a8 100644 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -1,10 +1,14 @@ const path = require('path'); +const webpack = require('webpack'); + const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const DotenvWebpackPlugin = require('dotenv-webpack'); +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const FontPreloadPlugin = require('webpack-font-preload-plugin'); const { sentryWebpackPlugin } = require('@sentry/webpack-plugin'); -const webpack = require('webpack'); module.exports = () => ({ entry: './src/index.tsx', @@ -56,7 +60,7 @@ module.exports = () => ({ }, output: { - filename: 'momo-bundle.js', + filename: '[name].[contenthash].js', path: path.resolve(__dirname, 'dist'), clean: true, publicPath: '/', @@ -69,6 +73,9 @@ module.exports = () => ({ new HtmlWebpackPlugin({ template: 'public/index.html', }), + new CopyWebpackPlugin({ + patterns: [{ from: 'public/assets/favicons', to: 'assets/favicons' }], + }), new ForkTsCheckerWebpackPlugin(), new DotenvWebpackPlugin(), sentryWebpackPlugin({ @@ -81,6 +88,11 @@ module.exports = () => ({ // ? hidden-source-map을 사용해야 삭제가 되는 것인지는 아직 모름. }, }), + new BundleAnalyzerPlugin(), + new FontPreloadPlugin({ + index: 'index.html', + extensions: ['woff2'], + }), ], devtool: 'source-map', @@ -90,4 +102,10 @@ module.exports = () => ({ maxEntrypointSize: 400000, maxAssetSize: 400000, }, + + optimization: { + splitChunks: { + chunks: 'all', + }, + }, });