diff --git a/.github/workflows/backend-dev-cd.yml b/.github/workflows/backend-dev-cd.yml index 20b230a7b..fb3c9f150 100644 --- a/.github/workflows/backend-dev-cd.yml +++ b/.github/workflows/backend-dev-cd.yml @@ -3,7 +3,7 @@ name: Backend Dev CD on: workflow_dispatch: push: - branches: ['develop'] + branches: [ 'develop' ] jobs: detect-changes: @@ -83,7 +83,7 @@ jobs: be-depoly: needs: be-build - runs-on: [self-hosted, linux, ARM64, dev] + runs-on: [ self-hosted, linux, ARM64, dev ] defaults: run: shell: bash @@ -103,20 +103,10 @@ jobs: - name: docker pull run: docker pull ${{ secrets.DOCKERHUB_USERNAME }}/momo-api-dev - # 2. 기존 컨테이너 중지 - - name: docker stop container - run: docker stop $(docker ps -q) 2>/dev/null || true - - # 3. 도커 컨테이너 실행 - - name: docker run new container - run: >- - docker run --name momo-api-dev - --rm -d -p 8080:8080 - --volume=$HOME/security:/momo/security:ro - --volume=$HOME/logs:/momo/logs - --env SPRING_PROFILE=dev - ${{ secrets.DOCKERHUB_USERNAME }}/momo-api-dev - - # 4. 미사용 이미지를 정리 + # 2. 실행 컨테이너 중 app 서비스만 재시작 + - name: Restart app service + run: docker compose -f $HOME/security/docker-compose-dev.yml restart app --no-deps + + # 3. 미사용 이미지를 정리 - name: delete old docker image run: docker system prune -f diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index d31123052..5b2d00d2b 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -2,7 +2,7 @@ name: Frontend CI on: pull_request: - branches: ['develop'] + branches: ['main', 'develop'] permissions: checks: write diff --git a/backend/src/main/java/kr/momo/config/RoutingDataSource.java b/backend/src/main/java/kr/momo/config/RoutingDataSource.java index 61610d8bd..54096002c 100644 --- a/backend/src/main/java/kr/momo/config/RoutingDataSource.java +++ b/backend/src/main/java/kr/momo/config/RoutingDataSource.java @@ -13,7 +13,7 @@ public class RoutingDataSource extends AbstractRoutingDataSource { protected Object determineCurrentLookupKey() { boolean currentTransactionReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); String key = currentTransactionReadOnly ? DataSourceConfig.REPLICA_SERVER : DataSourceConfig.SOURCE_SERVER; - log.info("Activated Datasource: {}", key); + log.debug("Activated Datasource: {}", key); return key; } } 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 e7a496ea2..ae76baf74 100644 --- a/backend/src/main/java/kr/momo/controller/attendee/AttendeeController.java +++ b/backend/src/main/java/kr/momo/controller/attendee/AttendeeController.java @@ -50,12 +50,4 @@ 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/MeetingController.java b/backend/src/main/java/kr/momo/controller/meeting/MeetingController.java index e24fa4f71..55df8ce71 100644 --- a/backend/src/main/java/kr/momo/controller/meeting/MeetingController.java +++ b/backend/src/main/java/kr/momo/controller/meeting/MeetingController.java @@ -8,6 +8,7 @@ import kr.momo.service.meeting.MeetingConfirmService; import kr.momo.service.meeting.MeetingService; import kr.momo.service.meeting.dto.ConfirmedMeetingResponse; +import kr.momo.service.meeting.dto.MeetingHomeResponse; import kr.momo.service.meeting.dto.MeetingConfirmRequest; import kr.momo.service.meeting.dto.MeetingConfirmResponse; import kr.momo.service.meeting.dto.MeetingCreateRequest; @@ -73,6 +74,12 @@ public MomoApiResponse findConfirmedMeeting(@PathVaria return new MomoApiResponse<>(response); } + @GetMapping("/api/v1/meetings/{uuid}/home") + public MomoApiResponse findMeetingHome(@PathVariable String uuid) { + MeetingHomeResponse response = meetingService.findMeetingHome(uuid); + return new MomoApiResponse<>(response); + } + @PatchMapping("/api/v1/meetings/{uuid}/lock") public void lock(@PathVariable String uuid, @AuthAttendee long id) { meetingService.lock(uuid, id); 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 498eaa96f..deee06b6f 100644 --- a/backend/src/main/java/kr/momo/controller/meeting/MeetingControllerDocs.java +++ b/backend/src/main/java/kr/momo/controller/meeting/MeetingControllerDocs.java @@ -15,6 +15,7 @@ import kr.momo.service.meeting.dto.MeetingConfirmResponse; import kr.momo.service.meeting.dto.MeetingCreateRequest; import kr.momo.service.meeting.dto.MeetingCreateResponse; +import kr.momo.service.meeting.dto.MeetingHomeResponse; import kr.momo.service.meeting.dto.MeetingResponse; import kr.momo.service.meeting.dto.MeetingSharingResponse; import org.springframework.http.ResponseEntity; @@ -147,4 +148,11 @@ ResponseEntity cancelConfirmedMeeting( @PathVariable @Schema(description = "약속 UUID") String uuid, @AuthAttendee @Schema(hidden = true) long id ); + + @Operation(summary = "약속 입장 정보 조회", description = "약속 입장에 필요한 정보를 조회하는 API 입니다.") + @ApiSuccessResponse.NoContent("약속 입장 정보 조회 성공") + @ApiErrorResponse.BadRequest(ERROR_CODE_TABLE_HEADER + """ + | NOT_FOUND_MEETING | 존재하지 않는 약속 정보 입니다. | + """) + MomoApiResponse findMeetingHome(@PathVariable @Schema(description = "약속 UUID") String uuid); } 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 7effeb711..0795979e5 100644 --- a/backend/src/main/java/kr/momo/service/attendee/AttendeeService.java +++ b/backend/src/main/java/kr/momo/service/attendee/AttendeeService.java @@ -63,24 +63,4 @@ public List findAll(String uuid) { .map(Attendee::name) .toList(); } - - /** - * TEMP: 비밀번호 마이그레이션 이후 삭제될 메서드입니다. - */ - @Transactional - 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/meeting/MeetingService.java b/backend/src/main/java/kr/momo/service/meeting/MeetingService.java index 4878ce14b..25ca8f02f 100644 --- a/backend/src/main/java/kr/momo/service/meeting/MeetingService.java +++ b/backend/src/main/java/kr/momo/service/meeting/MeetingService.java @@ -19,6 +19,7 @@ import kr.momo.exception.code.AttendeeErrorCode; import kr.momo.exception.code.MeetingErrorCode; import kr.momo.service.auth.JwtManager; +import kr.momo.service.meeting.dto.MeetingHomeResponse; import kr.momo.service.meeting.dto.MeetingCreateRequest; import kr.momo.service.meeting.dto.MeetingCreateResponse; import kr.momo.service.meeting.dto.MeetingResponse; @@ -110,6 +111,13 @@ public MeetingSharingResponse findMeetingSharing(String uuid) { return MeetingSharingResponse.from(meeting); } + @Transactional(readOnly = true) + public MeetingHomeResponse findMeetingHome(String uuid) { + Meeting meeting = meetingRepository.findByUuid(uuid) + .orElseThrow(() -> new MomoException(MeetingErrorCode.NOT_FOUND_MEETING)); + return MeetingHomeResponse.from(meeting); + } + @Transactional public void lock(String uuid, long id) { Meeting meeting = meetingRepository.findByUuid(uuid) diff --git a/backend/src/main/java/kr/momo/service/meeting/dto/MeetingHomeResponse.java b/backend/src/main/java/kr/momo/service/meeting/dto/MeetingHomeResponse.java new file mode 100644 index 000000000..2a6a98346 --- /dev/null +++ b/backend/src/main/java/kr/momo/service/meeting/dto/MeetingHomeResponse.java @@ -0,0 +1,18 @@ +package kr.momo.service.meeting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.momo.domain.meeting.Meeting; + +@Schema(description = "약속 입장 응답") +public record MeetingHomeResponse( + + @Schema(description = "약속 이름") + String meetingName, + + @Schema(description = "약속 유형") + String type +) { + public static MeetingHomeResponse from(Meeting meeting) { + return new MeetingHomeResponse(meeting.getName(), meeting.getType().name()); + } +} diff --git a/backend/src/main/resources/security b/backend/src/main/resources/security index 45da7cf52..a69e04a96 160000 --- a/backend/src/main/resources/security +++ b/backend/src/main/resources/security @@ -1 +1 @@ -Subproject commit 45da7cf5212cf5a9d95234bf83466b02862724ff +Subproject commit a69e04a96d788487452645e6e77941830a1b59a5 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 81b377bac..8819b8d41 100644 --- a/backend/src/test/java/kr/momo/controller/meeting/MeetingControllerTest.java +++ b/backend/src/test/java/kr/momo/controller/meeting/MeetingControllerTest.java @@ -689,4 +689,18 @@ private MeetingConfirmRequest getValidFindRequest(AvailableDate tomorrow) { Timeslot.TIME_0600.startTime() ); } + + @DisplayName("약속 입장 정보를 조회하면 200 상태 코드를 응답한다.") + @Test + void findMeetingHome() { + Meeting meeting = MeetingFixture.MOVIE.create(); + meeting = meetingRepository.save(meeting); + + RestAssured.given().log().all() + .pathParam("uuid", meeting.getUuid()) + .contentType(ContentType.JSON) + .when().get("/api/v1/meetings/{uuid}/home") + .then().log().all() + .statusCode(HttpStatus.OK.value()); + } } 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 6480acdd4..9a4d853d1 100644 --- a/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java +++ b/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java @@ -10,6 +10,7 @@ import java.time.LocalTime; import java.time.ZoneId; import java.util.List; +import java.util.UUID; import kr.momo.domain.attendee.Attendee; import kr.momo.domain.attendee.AttendeeRepository; import kr.momo.domain.availabledate.AvailableDate; @@ -24,6 +25,7 @@ import kr.momo.exception.code.MeetingErrorCode; import kr.momo.fixture.AttendeeFixture; import kr.momo.fixture.MeetingFixture; +import kr.momo.service.meeting.dto.MeetingHomeResponse; import kr.momo.service.meeting.dto.MeetingCreateRequest; import kr.momo.service.meeting.dto.MeetingResponse; import kr.momo.service.meeting.dto.MeetingSharingResponse; @@ -258,4 +260,28 @@ void throwsExceptionWhenUnlockAttendeeGuest() { .isInstanceOf(MomoException.class) .hasMessage(AttendeeErrorCode.ACCESS_DENIED.message()); } + + @DisplayName("UUID로 약속 입장 정보를 조회한다.") + @Test + void findMeetingHome() { + Meeting meeting = meetingRepository.save(MeetingFixture.GAME.create()); + String uuid = meeting.getUuid(); + + MeetingHomeResponse result = meetingService.findMeetingHome(uuid); + + assertAll( + () -> assertThat(result.meetingName()).isEqualTo(meeting.getName()), + () -> assertThat(result.type()).isEqualTo(meeting.getType().name()) + ); + } + + @DisplayName("약속 입장 정보를 조회시 UUID가 존재하지 않으면 예외가 발생한다.") + @Test + void throwsExceptionWhenFindHomeNoMeeting() { + String uuid = UUID.randomUUID().toString(); + + assertThatThrownBy(() -> meetingService.findMeetingHome(uuid)) + .isInstanceOf(MomoException.class) + .hasMessage(MeetingErrorCode.NOT_FOUND_MEETING.message()); + } } diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 114a63d7d..e57a1eff5 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -9,4 +9,9 @@ module.exports = { }, setupFiles: ['./jest.polyfills.js'], reporters: ['default', ['jest-junit', { outputDirectory: 'reports', outputName: 'report.xml' }]], + + moduleNameMapper: { + '^@utils/(.*)$': '/src/utils/$1', + '^@constants/(.*)$': '/src/constants/$1', + }, }; diff --git a/frontend/package.json b/frontend/package.json index 55440df24..63f652ebc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,8 +6,10 @@ "scripts": { "prod": "webpack server --open --config webpack.prod.js", "dev": "webpack server --open --config webpack.dev.js", - "build:prod": "NODE_ENV=production webpack --config webpack.prod.js && npm run sentry:sourcemaps", - "build:dev": "NODE_ENV=production webpack --config webpack.prod.js", + "build:prod-analyzer": "NODE_ENV=production USE_BUNDLE_ANALYZER=true webpack --config webpack.prod.js && npm run sentry:sourcemaps", + "build:prod": "NODE_ENV=production USE_BUNDLE_ANALYZER=false webpack --config webpack.prod.js && npm run sentry:sourcemaps", + "build:dev-analyzer": "NODE_ENV=production USE_BUNDLE_ANALYZER=true webpack --config webpack.prod.js && npm run sentry:sourcemaps", + "build:dev": "NODE_ENV=production USE_BUNDLE_ANALYZER=false webpack --config webpack.prod.js", "sentry:sourcemaps": "sentry-cli sourcemaps inject ./dist && sentry-cli sourcemaps upload -o momo2024 -p momo-harry-test /dist", "lint:css": "stylelint '**/*.styles.ts' --fix", "test": "jest", diff --git a/frontend/src/assets/images/attendeeCheck.svg b/frontend/src/assets/images/attendeeCheck.svg index da7adaf80..1c6c1615a 100644 --- a/frontend/src/assets/images/attendeeCheck.svg +++ b/frontend/src/assets/images/attendeeCheck.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/src/assets/images/check.svg b/frontend/src/assets/images/check.svg index 6551c56be..eea4cb0e0 100644 --- a/frontend/src/assets/images/check.svg +++ b/frontend/src/assets/images/check.svg @@ -1,3 +1,3 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/assets/images/exclamation.svg b/frontend/src/assets/images/exclamation.svg new file mode 100644 index 000000000..e2c8b5e92 --- /dev/null +++ b/frontend/src/assets/images/exclamation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/logoSunglass.svg b/frontend/src/assets/images/logoSunglass.svg index 4df236bc2..85c0f5bf6 100644 --- a/frontend/src/assets/images/logoSunglass.svg +++ b/frontend/src/assets/images/logoSunglass.svg @@ -1,4 +1,9 @@ - + + + + + + diff --git a/frontend/src/components/Schedules/Schedules.styles.ts b/frontend/src/components/Schedules/Schedules.styles.ts index dfa0534b5..054d428ad 100644 --- a/frontend/src/components/Schedules/Schedules.styles.ts +++ b/frontend/src/components/Schedules/Schedules.styles.ts @@ -10,7 +10,6 @@ export const s_container = css` export const s_relativeContainer = css` position: relative; flex: 1; - max-height: fit-content; `; export const s_selectModeButtonsContainer = css` diff --git a/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts b/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts new file mode 100644 index 000000000..319ff735f --- /dev/null +++ b/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts @@ -0,0 +1,28 @@ +import { css } from '@emotion/react'; + +export const s_bottomFixedStyles = css` + width: calc(100% + 1.6rem * 2); + max-width: 43rem; + + /* 버튼 컴포넌트의 full variants를 사용하려고 했으나 6rem보다 height값이 작아 직접 높이를 정의했어요(@해리) + full 버튼에 이미 의존하고 있는 컴포넌트들이 많아서 높이를 full 스타일을 변경할 수는 없었습니다. + */ + height: 6rem; + box-shadow: 0 -4px 4px rgb(0 0 0 / 25%); +`; + +export const s_bottomFixedButtonContainer = (height = 0) => css` + position: fixed; + bottom: 0; + left: 0; + transform: translateY(-${height}px); + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 6rem; + + background-color: transparent; +`; diff --git a/frontend/src/components/_common/Buttons/BottomFixedButton/index.tsx b/frontend/src/components/_common/Buttons/BottomFixedButton/index.tsx new file mode 100644 index 000000000..9e3d2450e --- /dev/null +++ b/frontend/src/components/_common/Buttons/BottomFixedButton/index.tsx @@ -0,0 +1,33 @@ +import type { ButtonHTMLAttributes } from 'react'; +import React from 'react'; + +import { Button } from '../Button'; +import { s_bottomFixedButtonContainer, s_bottomFixedStyles } from './BottomFixedButton.styles'; + +interface BottomFixedButtonProps extends ButtonHTMLAttributes { + children: React.ReactNode; + height?: number; + isLoading?: boolean; +} + +export default function BottomFixedButton({ + children, + height = 0, + isLoading = false, + ...props +}: BottomFixedButtonProps) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/_common/Toast/Toast.stories.tsx b/frontend/src/components/_common/Toast/Toast.stories.tsx new file mode 100644 index 000000000..cd59aab0a --- /dev/null +++ b/frontend/src/components/_common/Toast/Toast.stories.tsx @@ -0,0 +1,158 @@ +import { css } from '@emotion/react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ToastProvider from '@contexts/ToastProvider'; + +import useToast from '@hooks/useToast/useToast'; + +import Toast from '.'; +import { Button } from '../Buttons/Button'; + +const meta = { + title: 'Components/Toast', + component: Toast, + parameters: { + layout: 'centered', + }, + argTypes: { + message: { + control: { + type: 'text', + }, + description: '토스트 UI를 통해 사용자에게 전달할 메시지입니다.', + }, + type: { + control: { + type: 'select', + }, + options: ['default', 'warning', 'success'], + }, + isOpen: { + control: { + type: 'boolean', + }, + description: '토스트 UI에 애니메이션을 적용하기 위한 추가 상태입니다.', + }, + }, + + decorators: [ + (Story) => { + return ( + +
+ +
+
+ ); + }, + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true, + type: 'default', + message: '안녕하세요, 내 이름은 기본 토스트입니다.', + }, + + render: (args) => { + return ; + }, +}; + +export const Warning: Story = { + args: { + isOpen: true, + type: 'warning', + message: '안녕하세요, 내 이름은 경고 토스트입니다.', + }, + + render: (args) => { + return ; + }, +}; + +export const Success: Story = { + args: { + isOpen: true, + type: 'success', + message: '안녕하세요, 내 이름은 성공 토스트입니다.', + }, + + render: (args) => { + return ; + }, +}; + +export const ToastPlayground: Story = { + render: () => { + const { addToast } = useToast(); + + const renderDefaultToast = () => { + addToast({ + type: 'default', + message: '안녕하세요, 내 이름은 기본 토스트입니다', + duration: 3000, + }); + }; + + const renderSuccessToast = () => { + addToast({ + type: 'success', + message: '안녕하세요, 내 이름은 성공 토스트입니다', + duration: 3000, + }); + }; + + const renderWarningToast = () => { + addToast({ + type: 'warning', + message: '안녕하세요, 내 이름은 경고 토스트입니다', + duration: 3000, + }); + }; + + return ( +
+
+ + + +
+
+ ); + }, +}; diff --git a/frontend/src/components/_common/Toast/Toast.styles.ts b/frontend/src/components/_common/Toast/Toast.styles.ts new file mode 100644 index 000000000..c81ea2b64 --- /dev/null +++ b/frontend/src/components/_common/Toast/Toast.styles.ts @@ -0,0 +1,67 @@ +import type { SerializedStyles } from '@emotion/react'; +import { css, keyframes } from '@emotion/react'; + +import theme from '@styles/theme'; + +import type { ToastType } from './Toast.type'; + +const toastSlideIn = keyframes` + from{ + opacity: 0; + }to{ + opacity: 1; + } +`; + +const toastSlideOut = keyframes` + from{ + opacity: 1; + }to{ + opacity: 0; + } +`; + +export const s_toastContainer = (isOpen: boolean) => css` + display: flex; + gap: 1.2rem; + align-items: center; + + width: 100%; + height: 4.8rem; + padding: 1.2rem; + + background-color: #a1a1aa; + border-radius: 1.6rem; + box-shadow: 0 0.4rem 0.4rem rgb(0 0 0 / 20%); + + animation: ${isOpen ? toastSlideIn : toastSlideOut} 0.5s ease-in-out forwards; +`; + +export const s_toastText = css` + ${theme.typography.captionBold} + color: ${theme.colors.white}; +`; + +const ICON_BACKGROUND_COLORS: Record, SerializedStyles> = { + warning: css` + background-color: #ef4545; + `, + success: css` + background-color: ${theme.colors.green.mediumDark}; + `, +}; + +export const s_iconBackgroundColor = (type: Exclude) => { + return ICON_BACKGROUND_COLORS[type]; +}; + +export const s_iconContainer = css` + display: flex; + align-items: center; + justify-content: center; + + width: 2.4rem; + height: 2.4rem; + + border-radius: 50%; +`; diff --git a/frontend/src/components/_common/Toast/Toast.type.ts b/frontend/src/components/_common/Toast/Toast.type.ts new file mode 100644 index 000000000..c82cec648 --- /dev/null +++ b/frontend/src/components/_common/Toast/Toast.type.ts @@ -0,0 +1 @@ +export type ToastType = 'default' | 'warning' | 'success'; diff --git a/frontend/src/components/_common/Toast/ToastContainer.tsx b/frontend/src/components/_common/Toast/ToastContainer.tsx new file mode 100644 index 000000000..ab7b81400 --- /dev/null +++ b/frontend/src/components/_common/Toast/ToastContainer.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +import Toast from '.'; +import type { ToastType } from './Toast.type'; + +interface ToastContainerProps { + duration?: number; + type: ToastType; + message: string; +} + +const TOAST_ANIMATION_DURATION_TIME = 500; + +export default function ToastContainer({ type, message, duration = 3000 }: ToastContainerProps) { + const [isOpen, setIsOpen] = useState(true); + + useEffect(() => { + const animationTimer = setTimeout(() => { + setIsOpen(false); + }, duration - TOAST_ANIMATION_DURATION_TIME); + + return () => { + clearTimeout(animationTimer); + }; + }, [duration]); + + return ; +} diff --git a/frontend/src/components/_common/Toast/ToastList/ToastList.styles.ts b/frontend/src/components/_common/Toast/ToastList/ToastList.styles.ts new file mode 100644 index 000000000..a40991f22 --- /dev/null +++ b/frontend/src/components/_common/Toast/ToastList/ToastList.styles.ts @@ -0,0 +1,19 @@ +import { css } from '@emotion/react'; + +export const s_toastListContainer = css` + position: fixed; + z-index: 3; + top: 9rem; + left: 50%; + transform: translateX(-50%); + + display: flex; + flex-direction: column; + gap: 1.2rem; + align-items: center; + justify-content: center; + + width: 100%; + max-width: 43rem; + padding: 1.6rem; +`; diff --git a/frontend/src/components/_common/Toast/ToastList/ToastList.tsx b/frontend/src/components/_common/Toast/ToastList/ToastList.tsx new file mode 100644 index 000000000..ec0fa243d --- /dev/null +++ b/frontend/src/components/_common/Toast/ToastList/ToastList.tsx @@ -0,0 +1,20 @@ +import { createPortal } from 'react-dom'; + +import useToast from '@hooks/useToast/useToast'; + +import ToastContainer from '../ToastContainer'; +import { s_toastListContainer } from './ToastList.styles'; + +export default function ToastList() { + const { toasts } = useToast(); + + return createPortal( +
+ {toasts && + toasts.map(({ id, type, message, duration }) => ( + + ))} +
, + document.body, + ); +} diff --git a/frontend/src/components/_common/Toast/index.tsx b/frontend/src/components/_common/Toast/index.tsx new file mode 100644 index 000000000..15229c553 --- /dev/null +++ b/frontend/src/components/_common/Toast/index.tsx @@ -0,0 +1,40 @@ +import Check from '@assets/images/attendeeCheck.svg'; +import Exclamation from '@assets/images/exclamation.svg'; + +import theme from '@styles/theme'; + +import { + s_iconBackgroundColor, + s_iconContainer, + s_toastContainer, + s_toastText, +} from './Toast.styles'; +import type { ToastType } from './Toast.type'; + +interface ToastProps { + isOpen: boolean; + type: ToastType; + message: string; +} + +const iconMap: Record> | null> = { + default: null, + success: Check, + warning: Exclamation, +}; + +// 토스트 컴포넌트는 UI를 보여주는 책임만 가질 수 있도록 최대한 책임을 분리하고 스토리북을 활용한 UI 테스트를 쉽게할 수 있도록 한다.(@해리) +export default function Toast({ isOpen, type = 'default', message }: ToastProps) { + const ToastIcon = iconMap[type]; + + return ( +
+ {type !== 'default' && ( +
+ {ToastIcon && } +
+ )} +

{message}

+
+ ); +} diff --git a/frontend/src/constants/inputFields.ts b/frontend/src/constants/inputFields.ts index e8ed5ef7b..63c563616 100644 --- a/frontend/src/constants/inputFields.ts +++ b/frontend/src/constants/inputFields.ts @@ -4,6 +4,10 @@ export const INPUT_FIELD_PATTERN = { password: /^\d{4}$/, // 4자리 숫자 }; +export const INPUT_RULES = { + minimumLength: 1, +}; + export const FIELD_DESCRIPTIONS = { meetingName: '약속 이름은 1~10자 사이로 입력해 주세요.', nickname: '닉네임은 1~5자 사이로 입력해 주세요.', diff --git a/frontend/src/constants/meeting.ts b/frontend/src/constants/meeting.ts new file mode 100644 index 000000000..14f859be9 --- /dev/null +++ b/frontend/src/constants/meeting.ts @@ -0,0 +1,7 @@ +export const CREATE_MEETING_STEPS = { + meetingName: '약속이름', + meetingHostInfo: '약속주최자정보', + meetingDateTime: '약속날짜시간정보', +} as const; + +export const meetingStepValues = Object.values(CREATE_MEETING_STEPS); diff --git a/frontend/src/contexts/ToastProvider.tsx b/frontend/src/contexts/ToastProvider.tsx new file mode 100644 index 000000000..76c03ab97 --- /dev/null +++ b/frontend/src/contexts/ToastProvider.tsx @@ -0,0 +1,56 @@ +import type { PropsWithChildren } from 'react'; +import { useState } from 'react'; +import { createContext } from 'react'; + +import type { ToastType } from '@components/_common/Toast/Toast.type'; +import ToastList from '@components/_common/Toast/ToastList/ToastList'; + +interface ToastState { + id: number; + type: ToastType; + message: string; + duration?: number; +} + +interface ToastContextType { + toasts: ToastState[]; + addToast: ({ type, message, duration }: Omit) => void; +} + +export const ToastContext = createContext(null); + +export default function ToastProvider({ children }: PropsWithChildren) { + const [toasts, setToasts] = useState([]); + + const checkAlreadyRenderedToast = (toastMessage: string) => { + return toasts.find(({ message }) => message === toastMessage); + }; + + const removeToast = (toastId: number) => { + setToasts((prevToasts) => prevToasts.filter(({ id }) => id !== toastId)); + }; + + const addToast = ({ type, message, duration }: Omit) => { + if (checkAlreadyRenderedToast(message)) return; + + const toastId = Date.now(); + const newToastState = { + id: toastId, + type, + message, + duration, + }; + + setToasts((prevToasts) => [...prevToasts, newToastState]); + setTimeout(() => { + removeToast(toastId); + }, duration); + }; + + return ( + + + {children} + + ); +} diff --git a/frontend/src/hooks/useButtonOnKeyboard/useButtonOnKeyboard.ts b/frontend/src/hooks/useButtonOnKeyboard/useButtonOnKeyboard.ts new file mode 100644 index 000000000..6a33a9568 --- /dev/null +++ b/frontend/src/hooks/useButtonOnKeyboard/useButtonOnKeyboard.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +const INITIAL_BUTTON_HEIGHT = 0; + +const useButtonOnKeyboard = () => { + const [resizedButtonHeight, setResizedButtonHeight] = useState(INITIAL_BUTTON_HEIGHT); + + useEffect(() => { + const handleButtonHeightResize = () => { + if (!visualViewport?.height) return; + + setResizedButtonHeight(window.innerHeight - visualViewport.height); + }; + + // 약속 이름 -> 약속 주최자 정보 입력으로 넘어갈 때 다음 버튼을 모바일 키보드로 올리기 위해서 resize 이벤트가 발생하지 않더라도 초기에 실행되도록 구현했어요.(@해리) + handleButtonHeightResize(); + visualViewport?.addEventListener('resize', handleButtonHeightResize); + + return () => { + visualViewport?.removeEventListener('resize', handleButtonHeightResize); + }; + }, []); + + return resizedButtonHeight; +}; + +export default useButtonOnKeyboard; diff --git a/frontend/src/hooks/useCalendar/useCalendar.test.ts b/frontend/src/hooks/useCalendar/useCalendar.test.ts new file mode 100644 index 000000000..1935c6437 --- /dev/null +++ b/frontend/src/hooks/useCalendar/useCalendar.test.ts @@ -0,0 +1,175 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; + +import { getFullDate } from '@utils/date'; + +import useCalendar from './useCalendar'; + +describe('useCalendar', () => { + const TEST_YEAR = 2024; + const TEST_MONTH = 9; + const TEST_DATE = 4; + const FIRST_MONTH_INDEX = 0; + const LAST_MONTH_INDEX = 11; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('현재 달력 데이터 계산', () => { + it('현재 년도, 월을 올바르게 계산해서 반환한다.', () => { + jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); + + const { result } = renderHook(() => useCalendar()); + + const { headers, isCurrentMonth } = result.current; + const { currentYear, currentMonth } = headers; + + expect(currentYear).toBe(TEST_YEAR); + expect(currentMonth).toBe(TEST_MONTH); + expect(isCurrentMonth).toBeTruthy(); + }); + }); + + describe('달력 년도 이동 기능', () => { + it('12월에서 다음 달로 이동하면 다음 년도로 변경되어야 한다.', () => { + jest.setSystemTime(new Date(TEST_YEAR, LAST_MONTH_INDEX, TEST_DATE)); + + const { result } = renderHook(() => useCalendar()); + const { view } = result.current; + const { moveToNextMonth } = view; + + act(() => { + moveToNextMonth(); + }); + + const { headers, isCurrentMonth } = result.current; + const { currentYear, currentMonth } = headers; + + expect(currentYear).toBe(TEST_YEAR + 1); + expect(currentMonth).toBe(FIRST_MONTH_INDEX); + expect(isCurrentMonth).toBeFalsy(); + }); + + it('1월에서 이전 년도로 이동하면 이전 년도로 변경되어야 한다.', () => { + jest.setSystemTime(new Date(TEST_YEAR, FIRST_MONTH_INDEX, TEST_DATE)); + + const { result } = renderHook(() => useCalendar()); + const { view } = result.current; + const { moveToPrevMonth } = view; + + act(() => { + moveToPrevMonth(); + }); + + const { headers, isCurrentMonth } = result.current; + const { currentYear, currentMonth } = headers; + + expect(currentYear).toBe(TEST_YEAR - 1); + expect(currentMonth).toBe(LAST_MONTH_INDEX); + expect(isCurrentMonth).toBeFalsy(); + }); + }); + + describe('달력 월 이동 기능', () => { + it('이전 달로 이동하면, 달 데이터가 변경되어야 한다.', () => { + jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); + + const { result } = renderHook(() => useCalendar()); + const { view } = result.current; + const { moveToPrevMonth } = view; + + act(() => { + moveToPrevMonth(); + }); + + const { headers, isCurrentMonth } = result.current; + const { currentYear, currentMonth } = headers; + + expect(currentYear).toBe(TEST_YEAR); + expect(currentMonth).toBe(TEST_MONTH - 1); + expect(isCurrentMonth).toBeFalsy(); + }); + + it('다음 달로 이동하면 달 데이터가 변경되어야 한다..', () => { + jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); + + const { result } = renderHook(() => useCalendar()); + const { view } = result.current; + const { moveToNextMonth } = view; + + act(() => { + moveToNextMonth(); + }); + + const { headers, isCurrentMonth } = result.current; + const { currentYear, currentMonth } = headers; + + expect(currentYear).toBe(TEST_YEAR); + expect(currentMonth).toBe(TEST_MONTH + 1); + expect(isCurrentMonth).toBeFalsy(); + }); + }); + + describe('월 이동 시, 변경된 달력 데이터 계산', () => { + it('현재 달의 마지막 주에 있는 current 상태의 날짜들이 다음 달로 이동했을 때 prev 상태로 변경되어야 한다.', () => { + const { result } = renderHook(() => useCalendar()); + const { + body: { value: initialCalendarData }, + view: { moveToNextMonth }, + } = result.current; + const lastWeekOfCurrentMonth = initialCalendarData[initialCalendarData.length - 1].value; + const currentDatesInLastWeek = lastWeekOfCurrentMonth.filter( + (day) => day.status === 'current', + ); + + act(() => { + moveToNextMonth(); + }); + + const { + body: { value: updatedCalendarData }, + } = result.current; + const firstWeekOfNextMonth = updatedCalendarData[0].value; + const prevDatesInFirstWeek = firstWeekOfNextMonth.filter((day) => day.status === 'prev'); + + expect(prevDatesInFirstWeek.length).toBe(currentDatesInLastWeek.length); + currentDatesInLastWeek.forEach((date, index) => { + expect(getFullDate(prevDatesInFirstWeek[index].value)).toBe(getFullDate(date.value)); + expect(prevDatesInFirstWeek[index].status).toBe('prev'); + }); + }); + + it('현재 달의 첫 주에 있는 current 상태의 날짜들이 이전 달로 이동했을 때 next 상태로 변경되어야 한다.', () => { + const { result } = renderHook(() => useCalendar()); + const { + body: { value: initialCalendarData }, + view: { moveToPrevMonth }, + } = result.current; + const firstWeekOfCurrentMonth = initialCalendarData[0].value; + const currentDatesInFirstWeek = firstWeekOfCurrentMonth.filter( + (day) => day.status === 'current', + ); + + act(() => { + moveToPrevMonth(); + }); + + const { + body: { value: updatedCalendarData }, + } = result.current; + const lastWeekOfPrevMonth = updatedCalendarData[updatedCalendarData.length - 1].value; + const nextDatesInLastWeek = lastWeekOfPrevMonth.filter((day) => day.status === 'next'); + + expect(nextDatesInLastWeek.length).toBe(currentDatesInFirstWeek.length); + currentDatesInFirstWeek.forEach((date, index) => { + expect(getFullDate(nextDatesInLastWeek[index].value)).toBe(getFullDate(date.value)); + expect(nextDatesInLastWeek[index].status).toBe('next'); + }); + }); + }); +}); diff --git a/frontend/src/hooks/useCalendar/useCalendar.ts b/frontend/src/hooks/useCalendar/useCalendar.ts index 1daa482f9..17448f554 100644 --- a/frontend/src/hooks/useCalendar/useCalendar.ts +++ b/frontend/src/hooks/useCalendar/useCalendar.ts @@ -3,7 +3,7 @@ import type { MonthlyDays } from 'types/calendar'; import { getMonth, getYear } from '@utils/date'; -import { getMonthlyDate } from './useCalendar.utils'; +import { getMonthlyCalendarDate } from './useCalendar.utils'; interface useCalendarReturn { headers: { @@ -39,7 +39,7 @@ const useCalendar = (): useCalendarReturn => { setCurrentFullDate(new Date(currentYear, currentMonth + 1)); }; - const monthlyDates = getMonthlyDate(currentFullDate); + const monthlyCalendarDate = getMonthlyCalendarDate(currentFullDate); return { headers: { @@ -49,7 +49,7 @@ const useCalendar = (): useCalendarReturn => { }, body: { today: TODAY, - value: monthlyDates, + value: monthlyCalendarDate, }, view: { moveToNextMonth, diff --git a/frontend/src/hooks/useCalendar/useCalendar.utils.test.ts b/frontend/src/hooks/useCalendar/useCalendar.utils.test.ts new file mode 100644 index 000000000..c2bc16f0a --- /dev/null +++ b/frontend/src/hooks/useCalendar/useCalendar.utils.test.ts @@ -0,0 +1,204 @@ +import { getDate, getDay, getFullDate, getMonth, getYear } from '@utils/date'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +import { + getCalendarStartDate, + getDaysInMonth, + getMonthlyCalendarDate, + getMonthlyStartIndex, + getNumberOfWeeks, + getWeeklyDate, + setFirstDate, +} from './useCalendar.utils'; + +describe('Calendar utils', () => { + const TEST_YEAR = 2024; + const TEST_MONTH = 9; + const TEST_DATE = 4; + const TUESDAY_INDEX = 5; + const TEST_FULL_DATE = '2024-10-04'; + const testDate = new Date(TEST_YEAR, TEST_MONTH, TEST_DATE); + + describe('Calendar Base util functions test', () => { + it('getMonth 함수는 현재 달에서 1을 뺀 값을 반환한다.', () => { + const month = getMonth(testDate); + expect(month).toBe(TEST_MONTH); + }); + + it('getDay 함수는 오늘 요일에 맞는 인덱스를 반환한다.', () => { + const day = getDay(testDate); + expect(day).toBe(TUESDAY_INDEX); + }); + + it('getFullDate 함수는 오늘 날짜 정보를 문자열로 반환한다.', () => { + const fullDate = getFullDate(testDate); + expect(fullDate).toBe(TEST_FULL_DATE); + }); + }); + + describe('setFirstDate', () => { + it('날짜 객체를 해당 달의 첫 번째 날짜로 변경한다.', () => { + const result = setFirstDate(testDate); + expect(getDate(result)).toBe(1); + }); + }); + + describe('getNumberOfWeeks', () => { + it('해당 달이 총 몇 주로 구성되는지 계산해서 반환한다.', () => { + const EXPECTED_NUMBER_OF_WEEKS = 5; + const numberOfWeeksInTestDate = getNumberOfWeeks(testDate); + expect(numberOfWeeksInTestDate).toBe(EXPECTED_NUMBER_OF_WEEKS); + }); + }); + + describe('getDaysInMonth', () => { + it('해당 달의 전체 총 일수를 계산해서 반환한다.', () => { + const EXPECTED_DAYS_IN_MONTH = 31; + const dayInTestDate = getDaysInMonth(testDate); + expect(dayInTestDate).toBe(EXPECTED_DAYS_IN_MONTH); + }); + + const LEAP_YEAR = 2020; + const FEBRUARY_INDEX = 1; + const DATE = 1; + + it('윤년인 경우 2월의 총 일수를 계산해서 반환한다. (29일)', () => { + const testDate = new Date(LEAP_YEAR, FEBRUARY_INDEX, DATE); + const EXPECTED_DAYS_IN_FEBRUARY = 29; + const dayInTestDate = getDaysInMonth(testDate); + expect(dayInTestDate).toBe(EXPECTED_DAYS_IN_FEBRUARY); + }); + + it('평년인 경우 2월의 총 일수를 계산해서 반환한다. (28일)', () => { + const testDate = new Date(LEAP_YEAR + 1, FEBRUARY_INDEX, DATE); // 2021년은 평년 + const EXPECTED_DAYS_IN_FEBRUARY = 28; + const dayInTestDate = getDaysInMonth(testDate); + expect(dayInTestDate).toBe(EXPECTED_DAYS_IN_FEBRUARY); + }); + }); + + describe('getCalendarStartDate', () => { + it('달력을 그리기 위한 첫 번째 날짜를 계산해서 반환한다.', () => { + const EXPECTED_CALENDAR_START_DATE = 29; + const EXPECTED_CALENDAR_START_FULL_DATE = '2024-09-29'; + const calendarStartDate = getCalendarStartDate(testDate); + + expect(getYear(calendarStartDate)).toBe(TEST_YEAR); + expect(getMonth(calendarStartDate)).toBe(TEST_MONTH - 1); + expect(getDate(calendarStartDate)).toBe(EXPECTED_CALENDAR_START_DATE); + expect(getFullDate(calendarStartDate)).toBe(EXPECTED_CALENDAR_START_FULL_DATE); + }); + }); + + describe('getWeeklyDate', () => { + it('한 주 데이터에 이전 달이 포함되어 있으면, 해당 날짜의 상태는 prev여야 한다.', () => { + const calendarStartDate = getCalendarStartDate(testDate); + + const firstWeeklyDate = getWeeklyDate(calendarStartDate, getMonth(testDate)); + const prevStatusDates = firstWeeklyDate + .filter(({ value }) => getMonth(value) === getMonth(testDate) - 1) + .map(({ status }) => status); + + prevStatusDates.forEach((status) => { + expect(status).toBe('prev'); + }); + }); + + it('한 주 데이터에 다음 달이 포함되어 있으면, 해당 날짜의 상태는 next여야 한다.', () => { + const numberOfWeeks = getNumberOfWeeks(testDate); + const calendarStartDate = getCalendarStartDate(testDate); + calendarStartDate.setDate( + getDate(calendarStartDate) + CALENDAR_PROPERTIES.daysInOneWeek * numberOfWeeks - 1, + ); + + const lastWeeklyDate = getWeeklyDate(calendarStartDate, getMonth(testDate)); + const nextStatusDates = lastWeeklyDate + .filter(({ value }) => getMonth(value) === getMonth(testDate) - 1) + .map(({ status }) => status); + + nextStatusDates.forEach((status) => { + expect(status).toBe('next'); + }); + }); + + it('한 주 데이터에 현재 달이 포함되어 있으면, 해당 날짜의 상태는 current여야 한다.', () => { + const calendarStartDate = getCalendarStartDate(testDate); + const firstWeeklyDate = getWeeklyDate(calendarStartDate, getMonth(testDate)); + + const currentStatusDates = firstWeeklyDate + .filter(({ value }) => getMonth(value) === getMonth(testDate)) + .map(({ status }) => status); + + currentStatusDates.forEach((status) => { + expect(status).toBe('current'); + }); + }); + }); + + describe('getMonthlyCalendarDate', () => { + const EXPECTED_NUMBER_OF_WEEKS = 5; + const monthlyCalendarDate = getMonthlyCalendarDate(testDate); + + it('한 달의 달력 데이터는 해당 달의 총 주 수와 일치해야 한다.', () => { + expect(monthlyCalendarDate).toHaveLength(EXPECTED_NUMBER_OF_WEEKS); + }); + + it('달력 데이터에서 이전 달의 날짜 개수가 올바른지 확인한다.', () => { + const prevMonthDates = monthlyCalendarDate + .flatMap((week) => week.value) + .filter((day) => day.status === 'prev'); + const EXPECTED_PREV_MONTH_DAYS = getMonthlyStartIndex(testDate); + + expect(prevMonthDates).toHaveLength(EXPECTED_PREV_MONTH_DAYS); + }); + + it('달력 데이터에서 다음 달의 날짜 개수가 올바른지 확인한다.', () => { + const numberOfWeeks = getNumberOfWeeks(testDate); + const totalDays = CALENDAR_PROPERTIES.daysInOneWeek * numberOfWeeks; + const daysInMonth = getDaysInMonth(testDate); + const nextMonthDates = monthlyCalendarDate + .flatMap((week) => week.value) + .filter((day) => day.status === 'next'); + + const PREV_MONTH_DAYS = getMonthlyStartIndex(testDate); + const EXPECTED_NEXT_MONTH_DAYS = totalDays - PREV_MONTH_DAYS - daysInMonth; + + expect(nextMonthDates).toHaveLength(EXPECTED_NEXT_MONTH_DAYS); + }); + + it('달력 데이터에서 현재 달의 모든 날짜는 current 상태여야 한다.', () => { + monthlyCalendarDate.forEach((week) => { + week.value.forEach((day) => { + if (getMonth(day.value) === getMonth(testDate)) { + expect(day.status).toBe('current'); + } + }); + }); + }); + + it('달력 데이터의 첫 번째 "current" 상태 이전의 모든 날짜는 "prev" 상태여야 한다.', () => { + const flattenedCalendarDates = monthlyCalendarDate.flatMap((week) => week.value); + const firstCurrentStatusDateIndex = flattenedCalendarDates.findIndex( + (day) => day.status === 'current', + ); + + Array.from({ length: firstCurrentStatusDateIndex }, (_, index) => { + expect(flattenedCalendarDates[index].status).toBe('prev'); + }); + }); + + it('달력 데이터의 마지막 "current" 상태 이후의 모든 날짜는 "next" 상태여야 한다.', () => { + const reversedFlattenedCalendarDates = monthlyCalendarDate + .flatMap((week) => week.value) + .reverse(); + const firstCurrentStatusDateIndex = reversedFlattenedCalendarDates.findIndex( + (day) => day.status === 'current', + ); + + Array.from({ length: firstCurrentStatusDateIndex }, (_, index) => { + expect(reversedFlattenedCalendarDates[index].status).toBe('next'); + }); + }); + }); +}); diff --git a/frontend/src/hooks/useCalendar/useCalendar.utils.ts b/frontend/src/hooks/useCalendar/useCalendar.utils.ts index acc5bc4d2..2776160cf 100644 --- a/frontend/src/hooks/useCalendar/useCalendar.utils.ts +++ b/frontend/src/hooks/useCalendar/useCalendar.utils.ts @@ -18,17 +18,17 @@ export const getDaysInMonth = (date: Date) => { }; export const getNumberOfWeeks = (date: Date) => { - const firstOfMonth = setFirstDate(date); + const firstDateOfMonth = setFirstDate(date); const daysInMonth = getDaysInMonth(date); - const dayOfWeek = getDay(firstOfMonth); + const dayOfWeek = getDay(firstDateOfMonth); return Math.ceil((daysInMonth + dayOfWeek) / 7); }; export const getMonthlyStartIndex = (date: Date) => { - const firstOfMonth = setFirstDate(date); + const firstDateOfMonth = setFirstDate(date); - return firstOfMonth.getDay(); + return firstDateOfMonth.getDay(); }; const getMonthStatus = (targetDate: Date, currentMonthIndex: number): MonthStatus => { @@ -55,57 +55,20 @@ export const getWeeklyDate = (startDate: Date, currentMonthIndex: number): DateI }; }); -/** - * 주어진 날짜를 기준으로 해당 월의 달력 데이터를 계산하여 반환. - * 이 함수는 해당 월이 몇 주에 걸쳐 있는지 계산하고, 주마다 날짜 정보를 생성하여 반환. - * 월의 첫 번째 주와 마지막 주가 이전 달 또는 다음 달의 날짜로 채워질 수 있다. - * 이렇게 한 이유는 현재 모모 서비스에서는, 이전 달 또는 다음 달의 데이터를 활용하고 있지 않지만 요구 사항이 변경되어 필요하게 되면 필요에 따라 유연하게 뽑아서 사용할 수 있도록 하기 위해서이다. - * - * @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 => { +export const getCalendarStartDate = (date: Date) => { + const startDateOfMonth = setFirstDate(date); + startDateOfMonth.setDate(1 - getMonthlyStartIndex(date)); + + return startDateOfMonth; +}; + +export const getMonthlyCalendarDate = (date: Date): MonthlyDays => { const numberOfWeeks = getNumberOfWeeks(date); - const monthlyStartDate = setFirstDate(new Date(date)); - monthlyStartDate.setDate(1 - getMonthlyStartIndex(date)); + const startDateOfCalendar = getCalendarStartDate(date); return Array.from({ length: numberOfWeeks }, (_, i) => { - const newDate = new Date(monthlyStartDate); - newDate.setDate(getDate(monthlyStartDate) + CALENDAR_PROPERTIES.daysInOneWeek * i); + const newDate = new Date(startDateOfCalendar); + newDate.setDate(getDate(startDateOfCalendar) + CALENDAR_PROPERTIES.daysInOneWeek * i); return { key: getYear(date) * getMonth(date) + i, diff --git a/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts b/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts new file mode 100644 index 000000000..b18bc5fb4 --- /dev/null +++ b/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts @@ -0,0 +1,76 @@ +import useDateSelect from '@hooks/useDateSelect/useDateSelect'; +import useInput from '@hooks/useInput/useInput'; +import useMeetingType from '@hooks/useMeetingType/useMeetingType'; +import { INITIAL_END_TIME, INITIAL_START_TIME } from '@hooks/useTimeRangeDropdown/constants'; +import useTimeRangeDropdown from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown'; + +import { usePostMeetingMutation } from '@stores/servers/meeting/mutation'; + +import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN, INPUT_RULES } from '@constants/inputFields'; + +const checkInputInvalid = (value: string, errorMessage: string | null) => + value.length < INPUT_RULES.minimumLength || errorMessage !== null; + +const useCreateMeeting = () => { + const meetingNameInput = useInput({ + pattern: INPUT_FIELD_PATTERN.meetingName, + errorMessage: FIELD_DESCRIPTIONS.meetingName, + }); + const isMeetingNameInvalid = checkInputInvalid( + meetingNameInput.value, + meetingNameInput.errorMessage, + ); + + const hostNickNameInput = useInput({ + pattern: INPUT_FIELD_PATTERN.nickname, + errorMessage: FIELD_DESCRIPTIONS.nickname, + }); + const hostPasswordInput = useInput({ + pattern: INPUT_FIELD_PATTERN.password, + errorMessage: FIELD_DESCRIPTIONS.password, + }); + const isHostInfoInvalid = + checkInputInvalid(hostNickNameInput.value, hostNickNameInput.errorMessage) || + checkInputInvalid(hostPasswordInput.value, hostPasswordInput.errorMessage); + + const meetingDateInput = useDateSelect(); + const meetingTimeInput = useTimeRangeDropdown(); + const meetingTypeInput = useMeetingType(); + + const isCreateMeetingFormInvalid = + isMeetingNameInvalid || isHostInfoInvalid || meetingDateInput.areDatesUnselected; + + const { mutation: postMeetingMutation } = usePostMeetingMutation(); + const handleMeetingCreateButtonClick = () => { + const selectedDatesArray = Array.from(meetingDateInput.selectedDates); + + postMeetingMutation.mutate({ + meetingName: meetingNameInput.value, + hostName: hostNickNameInput.value, + hostPassword: hostPasswordInput.value, + availableMeetingDates: selectedDatesArray, + meetingStartTime: meetingTimeInput.startTime.value, + // 시간상 24시는 존재하지 않기 때문에 백엔드에서 오류가 발생. 따라서 오전 12:00으로 표현하지만, 서버에 00:00으로 전송(@낙타) + meetingEndTime: + meetingTimeInput.endTime.value === INITIAL_END_TIME + ? INITIAL_START_TIME + : meetingTimeInput.endTime.value, + type: meetingTypeInput.meetingType, + }); + }; + + return { + meetingNameInput, + isMeetingNameInvalid, + hostNickNameInput, + hostPasswordInput, + isHostInfoInvalid, + meetingDateInput, + meetingTimeInput, + meetingTypeInput, + isCreateMeetingFormInvalid, + handleMeetingCreateButtonClick, + }; +}; + +export default useCreateMeeting; diff --git a/frontend/src/hooks/useDateSelect/useDateSelect.ts b/frontend/src/hooks/useDateSelect/useDateSelect.ts index d29b4d9c3..9436eaaa6 100644 --- a/frontend/src/hooks/useDateSelect/useDateSelect.ts +++ b/frontend/src/hooks/useDateSelect/useDateSelect.ts @@ -6,7 +6,7 @@ import { getFullDate } from '@utils/date'; import { getDatesInRange } from './useDateSelect.utils'; const useDateSelect = () => { - const [selectedDates, setSelectedDates] = useState([]); + const [selectedDates, setSelectedDates] = useState>(new Set()); const [dateSelectMode, setDateSelectMode] = useState('single'); const [rangeStartDate, setRangeStartDate] = useState(null); const [rangeEndDate, setRangeEndDate] = useState(null); @@ -15,21 +15,24 @@ const useDateSelect = () => { if (mode === dateSelectMode) return; setDateSelectMode(mode); - setSelectedDates([]); + setSelectedDates(new Set()); setRangeStartDate(null); setRangeEndDate(null); }; const handleSelectedDateBySingleMode = (date: string) => { - setSelectedDates((prev) => - prev.includes(date) ? prev.filter((d) => d !== date) : [...prev, date], - ); + setSelectedDates((prevDates) => { + const newSelectedDates = new Set(prevDates); + newSelectedDates.has(date) ? newSelectedDates.delete(date) : newSelectedDates.add(date); + + return newSelectedDates; + }); }; const handleRangeStartDatePick = (date: string) => { setRangeStartDate(date); setRangeEndDate(null); - setSelectedDates([date]); + setSelectedDates(new Set([date])); }; const handleRangeEndDatePick = (date: string) => { @@ -40,13 +43,13 @@ const useDateSelect = () => { if (end < start) { setRangeStartDate(date); - setSelectedDates([date]); + setSelectedDates(new Set([date])); return; - } else { - setRangeEndDate(date); - const range = getDatesInRange(start, end); - setSelectedDates(range); } + + setRangeEndDate(date); + const range = getDatesInRange(start, end); + setSelectedDates(new Set(range)); }; const handleSelectedDateByRangeMode = (date: string) => { @@ -67,9 +70,9 @@ const useDateSelect = () => { handleSelectedDateByRangeMode(date); }; - const hasDate = (date: string) => { - return selectedDates.includes(date); - }; + const hasDate = (date: string) => selectedDates.has(date); + const areDatesUnselected = selectedDates.size < 1; + const checkIsRangeStartDate = (date: Date) => getFullDate(date) === rangeStartDate; const checkIsRangeEndDate = (date: Date) => getFullDate(date) === rangeEndDate; const isAllRangeSelected = rangeStartDate !== null && rangeEndDate != null; @@ -82,6 +85,7 @@ const useDateSelect = () => { checkIsRangeEndDate, handleSelectedDates, hasDate, + areDatesUnselected, isAllRangeSelected, }; }; diff --git a/frontend/src/hooks/useFunnel/FunnelMain.tsx b/frontend/src/hooks/useFunnel/FunnelMain.tsx new file mode 100644 index 000000000..15214a7d4 --- /dev/null +++ b/frontend/src/hooks/useFunnel/FunnelMain.tsx @@ -0,0 +1,26 @@ +import type { ReactElement } from 'react'; +import React, { Children, isValidElement } from 'react'; + +import type { FunnelProps, StepProps, StepType } from './useFunnel.type'; + +const isValidFunnelChild = ( + child: React.ReactNode, +): child is ReactElement> => { + return isValidElement(child) && typeof child.props.name === 'string'; +}; + +export default function FunnelMain({ + steps, + step, + children, +}: FunnelProps) { + const childrenArray = Children.toArray(children) + .filter(isValidFunnelChild) + .filter((child) => steps.includes(child.props.name)); + + const targetStep = childrenArray.find((child) => child.props.name === step); + + if (!targetStep) return null; + + return <>{targetStep}; +} diff --git a/frontend/src/hooks/useFunnel/useFunnel.tsx b/frontend/src/hooks/useFunnel/useFunnel.tsx new file mode 100644 index 000000000..9f5502d57 --- /dev/null +++ b/frontend/src/hooks/useFunnel/useFunnel.tsx @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import FunnelMain from './FunnelMain'; +import type { RouteFunnelProps, StepProps, StepType } from './useFunnel.type'; + +const useFunnel = (steps: Steps, initialStep: Steps[number]) => { + const location = useLocation(); + const navigate = useNavigate(); + + const setStep = (step: Steps[number]) => { + navigate(location.pathname, { + state: { + currentStep: step, + }, + }); + }; + + // 아직 헤더 디자인을 하지 않은 상태이기 때문에, goPrevStep은 사용하지 않는 상태입니다.(@해리) + const goPrevStep = () => { + navigate(-1); + }; + + const Step = ({ children }: StepProps) => { + return <>{children}; + }; + + // 컴포넌트가 다시 렌더링 될 때마다, Funnel 인스턴스가 다시 생성되는 문제가 있어서, useMemo로 감싸는 것으로 수정(@해리) + const Funnel = useMemo( + () => + Object.assign( + function RouteFunnel(props: RouteFunnelProps) { + const step = + (location.state as { currentStep?: Steps[number] })?.currentStep || initialStep; + + return steps={steps} step={step} {...props} />; + }, + { + Step, + }, + ), + [location.state, initialStep, steps], + ); + + return [setStep, Funnel] as const; +}; + +export default useFunnel; diff --git a/frontend/src/hooks/useFunnel/useFunnel.type.ts b/frontend/src/hooks/useFunnel/useFunnel.type.ts new file mode 100644 index 000000000..adf2fc844 --- /dev/null +++ b/frontend/src/hooks/useFunnel/useFunnel.type.ts @@ -0,0 +1,16 @@ +import type { ReactElement } from 'react'; + +export type StepType = Readonly>; + +export interface FunnelProps { + steps: Steps; + step: Steps[number]; + children: Array>>; +} + +export type RouteFunnelProps = Omit, 'steps' | 'step'>; + +export interface StepProps { + name: Steps[number]; + children: React.ReactNode; +} diff --git a/frontend/src/hooks/useInput/useInput.ts b/frontend/src/hooks/useInput/useInput.ts index 6aba728ee..4d1080315 100644 --- a/frontend/src/hooks/useInput/useInput.ts +++ b/frontend/src/hooks/useInput/useInput.ts @@ -35,4 +35,6 @@ const useInput = (rules?: ValidationRules) => { }; }; +export type UseInputReturn = ReturnType; + export default useInput; diff --git a/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.ts b/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.ts index b85287b24..0a6fb8dfe 100644 --- a/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.ts +++ b/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.ts @@ -8,7 +8,7 @@ import { isTimeSelectable, } from './useTimeRangeDropdown.utils'; -export default function useTimeRangeDropdown() { +const useTimeRangeDropdown = () => { const [startTime, setStartTime] = useState(INITIAL_START_TIME); const [endTime, setEndTime] = useState(INITIAL_END_TIME); @@ -39,4 +39,8 @@ export default function useTimeRangeDropdown() { handleStartTimeChange, handleEndTimeChange, } as const; -} +}; + +export type UseTimeRangeDropdownReturn = ReturnType; + +export default useTimeRangeDropdown; diff --git a/frontend/src/hooks/useToast/useToast.ts b/frontend/src/hooks/useToast/useToast.ts new file mode 100644 index 000000000..78e051762 --- /dev/null +++ b/frontend/src/hooks/useToast/useToast.ts @@ -0,0 +1,17 @@ +import { useContext } from 'react'; + +import { ToastContext } from '@contexts/ToastProvider'; + +const useToast = () => { + const toastContext = useContext(ToastContext); + + if (!toastContext) { + throw new Error('ToastContext를 사용할 수 없는 컴포넌트입니다.'); + } + + const { toasts, addToast } = toastContext; + + return { toasts, addToast }; +}; + +export default useToast; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 10d6dd626..0bfb0a33e 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -5,6 +5,8 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; import ReactDOM from 'react-dom/client'; +import ToastProvider from '@contexts/ToastProvider'; + import globalStyles from '@styles/global'; import theme from '@styles/theme'; @@ -51,7 +53,9 @@ enableMocking().then(() => { - + + + , diff --git a/frontend/src/mocks/meeting/meetingHandlers.ts b/frontend/src/mocks/meeting/meetingHandlers.ts index 3b343d675..2d0fca939 100644 --- a/frontend/src/mocks/meeting/meetingHandlers.ts +++ b/frontend/src/mocks/meeting/meetingHandlers.ts @@ -11,6 +11,7 @@ import mySchedule from './data/mySchedule.json'; const meetingHandlers = [ http.get(`${BASE_URL}/:uuid`, () => { + // meetingTableFrame에서 type을 "DATETIME"으로 설정하면 시간 선택 표가 나오고, "DAYSONLY"로 설정하면 달력이 나옵니다. return HttpResponse.json(meetingTableFrame, { status: 200 }); }), diff --git a/frontend/src/pages/CreateMeetingPage/CreateMeetingPage.styles.ts b/frontend/src/pages/CreateMeetingPage/CreateMeetingPage.styles.ts deleted file mode 100644 index 59162d4e4..000000000 --- a/frontend/src/pages/CreateMeetingPage/CreateMeetingPage.styles.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { css } from '@emotion/react'; - -import theme from '@styles/theme'; - -export const s_formContainer = css` - display: flex; - flex-direction: column; - gap: 2.4rem; -`; - -export const s_confirmContainer = css` - display: flex; - align-items: center; - justify-content: flex-end; - margin-bottom: 1.6rem; -`; - -export const s_descriptionContainer = css` - display: flex; - flex-direction: column; - gap: 0.8rem; - - strong { - ${theme.typography.captionBold} - color: ${theme.colors.primary} - } -`; - -export const s_description = css` - ${theme.typography.captionMedium} - display: flex; - gap: 0.4rem; -`; - -export const s_availableDateDescription = css` - display: flex; - flex-direction: column; -`; - -export const s_availableDatesContainer = css` - ${theme.typography.captionMedium} - display: grid; - grid-template-columns: 2fr 5fr; - gap: 0.4rem; -`; diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/MeetingDateTime.styles.ts b/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/MeetingDateTime.styles.ts new file mode 100644 index 000000000..5a773edeb --- /dev/null +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/MeetingDateTime.styles.ts @@ -0,0 +1,16 @@ +import { css } from '@emotion/react'; + +export const s_container = css` + overflow: scroll; + display: flex; + flex-direction: column; + gap: 2.4rem; + + height: calc(100% - 6rem); +`; + +export const s_dateCandidateSelector = css` + display: flex; + flex-direction: column; + gap: 1.2rem; +`; diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx new file mode 100644 index 000000000..9983c6a59 --- /dev/null +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx @@ -0,0 +1,131 @@ +import type { DateInfo } from 'types/calendar'; + +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 TimeRangeSelector from '@components/TimeRangeSelector'; +import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; +import Calendar from '@components/_common/Calendar'; +import Checkbox from '@components/_common/Checkbox'; +import Field from '@components/_common/Field'; + +import type useDateSelect from '@hooks/useDateSelect/useDateSelect'; +import type useMeetingType from '@hooks/useMeetingType/useMeetingType'; +import type { UseTimeRangeDropdownReturn } from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown'; + +import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; + +import { s_container, s_dateCandidateSelector } from './MeetingDateTime.styles'; + +interface MeetingDateTimeProps { + meetingDateInput: ReturnType; + meetingTimeInput: UseTimeRangeDropdownReturn; + meetingTypeInput: ReturnType; + isCreateMeetingFormInvalid: boolean; + onMeetingCreateButtonClick: () => void; +} + +export default function MeetingDateTime({ + meetingDateInput, + meetingTimeInput, + meetingTypeInput, + isCreateMeetingFormInvalid, + onMeetingCreateButtonClick, +}: MeetingDateTimeProps) { + const { + handleSelectedDates, + hasDate, + dateSelectMode, + checkIsRangeStartDate, + checkIsRangeEndDate, + isAllRangeSelected, + toggleDateSelectMode, + } = meetingDateInput; + + const { startTime, endTime, handleStartTimeChange, handleEndTimeChange } = meetingTimeInput; + + const { isChecked, handleToggleIsChecked } = meetingTypeInput; + + const renderDate = (dateInfo: DateInfo, today: Date) => { + return dateSelectMode === 'single' ? ( + + ) : ( + + ); + }; + + return ( +
+
+ + + + + ( + + )} + /> + } + /> + + + + + +
+ + {!isChecked && ( + + + + + )} + + + 약속 생성하기 + +
+ ); +} diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx new file mode 100644 index 000000000..77493d6c7 --- /dev/null +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx @@ -0,0 +1,67 @@ +import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; +import Field from '@components/_common/Field'; +import Input from '@components/_common/Input'; + +import useButtonOnKeyboard from '@hooks/useButtonOnKeyboard/useButtonOnKeyboard'; +import type { UseInputReturn } from '@hooks/useInput/useInput'; + +import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; + +interface MeetingHostInfoProps { + hostNickNameInput: UseInputReturn; + hostPasswordInput: UseInputReturn; + isHostInfoInvalid: boolean; + onNextStep: () => void; +} + +export default function MeetingHostInfo({ + hostNickNameInput, + hostPasswordInput, + isHostInfoInvalid, + onNextStep, +}: MeetingHostInfoProps) { + const { + value: hostNickName, + onValueChange: handleHostNickNameChange, + errorMessage: hostNickNameErrorMessage, + } = hostNickNameInput; + const { + value: hostPassword, + onValueChange: handleHostPasswordChange, + errorMessage: hostPasswordErrorMessage, + } = hostPasswordInput; + + const resizedButtonHeight = useButtonOnKeyboard(); + + return ( + <> + + + + + + + + + + + + + + + 다음 + + + ); +} diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx new file mode 100644 index 000000000..98ec588b2 --- /dev/null +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx @@ -0,0 +1,52 @@ +import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; +import Field from '@components/_common/Field'; +import Input from '@components/_common/Input'; + +import useButtonOnKeyboard from '@hooks/useButtonOnKeyboard/useButtonOnKeyboard'; +import type { UseInputReturn } from '@hooks/useInput/useInput'; + +import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; + +interface MeetingNameProps { + meetingNameInput: UseInputReturn; + isMeetingNameInvalid: boolean; + onNextStep: () => void; +} + +export default function MeetingName({ + meetingNameInput, + isMeetingNameInvalid, + onNextStep, +}: MeetingNameProps) { + const { + value: meetingName, + onValueChange: handleMeetingNameChange, + errorMessage: meetingNameErrorMessage, + } = meetingNameInput; + + const resizedButtonHeight = useButtonOnKeyboard(); + + return ( + <> + + + + + + + + 다음 + + + ); +} diff --git a/frontend/src/pages/CreateMeetingPage/index.tsx b/frontend/src/pages/CreateMeetingPage/index.tsx index 0c3f503c7..397c4a295 100644 --- a/frontend/src/pages/CreateMeetingPage/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/index.tsx @@ -1,277 +1,55 @@ -import type { DateInfo } from 'types/calendar'; +import MeetingDateTime from '@pages/CreateMeetingPage/components/MeetingDateTime'; +import MeetingHostInfo from '@pages/CreateMeetingPage/components/MeetingHostInfo'; +import MeetingName from '@pages/CreateMeetingPage/components/MeetingName'; -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 TimeRangeSelector from '@components/TimeRangeSelector'; -import { Button } from '@components/_common/Buttons/Button'; -import Calendar from '@components/_common/Calendar'; -import Checkbox from '@components/_common/Checkbox'; -import Field from '@components/_common/Field'; -import Input from '@components/_common/Input'; -import ConfirmModal from '@components/_common/Modal/ConfirmModal'; +import useCreateMeeting from '@hooks/useCreateMeeting/useCreateMeeting'; +import useFunnel from '@hooks/useFunnel/useFunnel'; -import useConfirmModal from '@hooks/useConfirmModal/useConfirmModal'; -import useDateSelect from '@hooks/useDateSelect/useDateSelect'; -import useInput from '@hooks/useInput/useInput'; -import useMeetingType from '@hooks/useMeetingType/useMeetingType'; -import { INITIAL_END_TIME, INITIAL_START_TIME } from '@hooks/useTimeRangeDropdown/constants'; -import useTimeRangeDropdown from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown'; +import { CREATE_MEETING_STEPS, meetingStepValues } from '@constants/meeting'; -import { usePostMeetingMutation } from '@stores/servers/meeting/mutation'; - -import groupDates from '@utils/groupDates'; - -import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN } from '@constants/inputFields'; - -import { - s_availableDateDescription, - s_availableDatesContainer, - s_confirmContainer, - s_description, - s_descriptionContainer, - s_formContainer, -} from './CreateMeetingPage.styles'; +type Steps = typeof meetingStepValues; export default function CreateMeetingPage() { - const { isConfirmModalOpen, onToggleConfirmModal } = useConfirmModal(); - - const { mutation: postMeetingMutation } = usePostMeetingMutation(); - - const { - value: meetingName, - onValueChange: handleMeetingNameChange, - errorMessage: meetingNameErrorMessage, - } = useInput({ - pattern: INPUT_FIELD_PATTERN.meetingName, - errorMessage: FIELD_DESCRIPTIONS.meetingName, - }); - + const [setStep, Funnel] = useFunnel(meetingStepValues, '약속이름'); const { - value: hostName, - onValueChange: handleHostNameChange, - errorMessage: hostNameErrorMessage, - } = useInput({ - pattern: INPUT_FIELD_PATTERN.nickname, - errorMessage: FIELD_DESCRIPTIONS.nickname, - }); - - const { - value: hostPassword, - onValueChange: handleHostPasswordChange, - errorMessage: hostPasswordError, - } = useInput({ - pattern: INPUT_FIELD_PATTERN.password, - errorMessage: FIELD_DESCRIPTIONS.password, - }); - - const { - selectedDates, - handleSelectedDates, - hasDate, - dateSelectMode, - checkIsRangeStartDate, - checkIsRangeEndDate, - isAllRangeSelected, - toggleDateSelectMode, - } = useDateSelect(); - - const { startTime, endTime, handleStartTimeChange, handleEndTimeChange } = useTimeRangeDropdown(); - const { meetingType, isChecked, handleToggleIsChecked } = useMeetingType(); - - const isFormValid = () => { - const errorMessages = [meetingNameErrorMessage, hostNameErrorMessage, hostPasswordError]; - const hasErrors = errorMessages.some((errorMessage) => errorMessage !== null); - - if (hasErrors) { - return false; - } - - const requiredFields = [meetingName, hostName, hostPassword]; - const areRequiredFieldsFilled = requiredFields.every((field) => field !== ''); - const areDatesSelected = selectedDates.length > 0; - const isAllFieldsFilled = areRequiredFieldsFilled && areDatesSelected; - - return isAllFieldsFilled; - }; - - const handleMeetingCreateButtonClick = () => { - postMeetingMutation.mutate({ - hostName: hostName, - hostPassword: hostPassword, - meetingName: meetingName, - availableMeetingDates: selectedDates, - meetingStartTime: isChecked ? '00:00' : startTime.value, - // 시간상 24시는 존재하지 않기 때문에 백엔드에서 오류가 발생. 따라서 오전 12:00으로 표현하지만, 서버에 00:00으로 전송(@낙타) - 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' ? ( - - ) : ( - - ); - }; + meetingNameInput, + isMeetingNameInvalid, + hostNickNameInput, + hostPasswordInput, + isHostInfoInvalid, + meetingDateInput, + meetingTimeInput, + meetingTypeInput, + isCreateMeetingFormInvalid, + handleMeetingCreateButtonClick, + } = useCreateMeeting(); return ( -
- {/* 추후 form 태그로 수정 예정 (@Largopie) */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - ( - - )} - /> - } - /> - - - - - + + setStep(CREATE_MEETING_STEPS.meetingHostInfo)} /> - - {!isChecked && ( - - - - - )} - -
- -
-
- -
-

- 약속명 - {meetingName} -

-

- 주최자 - {hostName} -

- {!isChecked && ( -

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

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

- {year}년 {month}월 -

- {dates.join(', ')} -
- ); - })} -
-
-
-
+ + + setStep(CREATE_MEETING_STEPS.meetingDateTime)} + /> + + + + + ); } diff --git a/frontend/src/pages/MeetingTimePickPage/MeetingTimePickPage.styles.ts b/frontend/src/pages/MeetingTimePickPage/MeetingTimePickPage.styles.ts index a1cb5275d..5f941fcdd 100644 --- a/frontend/src/pages/MeetingTimePickPage/MeetingTimePickPage.styles.ts +++ b/frontend/src/pages/MeetingTimePickPage/MeetingTimePickPage.styles.ts @@ -3,6 +3,8 @@ import { css } from '@emotion/react'; export const s_container = css` display: flex; flex-direction: column; + gap: 0.8rem; + width: 100%; height: 100%; `; diff --git a/frontend/src/pages/MeetingTimePickPage/index.tsx b/frontend/src/pages/MeetingTimePickPage/index.tsx index cac09bcb8..0a667d06e 100644 --- a/frontend/src/pages/MeetingTimePickPage/index.tsx +++ b/frontend/src/pages/MeetingTimePickPage/index.tsx @@ -55,7 +55,7 @@ export default function MeetingTimePickPage() { } }; - const renderPicker = (meetingType: MeetingType) => { + const renderMeetingFrame = (meetingType: MeetingType) => { if (!meetingFrame) return; switch (meetingType) { @@ -117,7 +117,7 @@ export default function MeetingTimePickPage() { )} - {meetingFrame && renderPicker(meetingFrame.type)} + {meetingFrame && renderMeetingFrame(meetingFrame.type)} ); } diff --git a/frontend/src/styles/global.ts b/frontend/src/styles/global.ts index ae46b9fc6..25ade3c17 100644 --- a/frontend/src/styles/global.ts +++ b/frontend/src/styles/global.ts @@ -104,7 +104,7 @@ const globalStyles = css` justify-content: center; width: 100%; - min-height: 100vh; + height: 100dvh; font-family: Pretendard, sans-serif; font-size: 1.6rem; diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index 3be5150a8..9f806e5ea 100644 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -5,10 +5,12 @@ 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 { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); + +const bundleAnalyzer = + process.env.USE_BUNDLE_ANALYZER === 'true' ? new BundleAnalyzerPlugin() : null; module.exports = () => ({ entry: './src/index.tsx', @@ -88,12 +90,12 @@ module.exports = () => ({ // ? hidden-source-map을 사용해야 삭제가 되는 것인지는 아직 모름. }, }), - new BundleAnalyzerPlugin(), new FontPreloadPlugin({ index: 'index.html', extensions: ['woff2'], }), - ], + bundleAnalyzer, + ].filter(Boolean), devtool: 'source-map',