diff --git a/frontend/jest.config.js b/frontend/jest.config.js index e57a1eff5..b1a6964e2 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -3,6 +3,7 @@ module.exports = { testEnvironment: 'jsdom', transform: { '^.+.tsx?$': ['ts-jest', {}], + '^.+\\.svg$': '/svgTransformer.js', }, testEnvironmentOptions: { customExportConditions: [''], @@ -13,5 +14,10 @@ module.exports = { moduleNameMapper: { '^@utils/(.*)$': '/src/utils/$1', '^@constants/(.*)$': '/src/constants/$1', + '^@contexts/(.*)$': '/src/contexts/$1', + '^@components/(.*)$': '/src/components/$1', + '^@hooks/(.*)$': '/src/hooks/$1', + '^@assets/(.*)$': '/src/assets/$1', + '^@styles/(.*)$': '/src/styles/$1', }, }; diff --git a/frontend/src/components/FloatingInput/FloatingLabelInput.stories.tsx b/frontend/src/components/FloatingInput/FloatingLabelInput.stories.tsx new file mode 100644 index 000000000..802c75d7d --- /dev/null +++ b/frontend/src/components/FloatingInput/FloatingLabelInput.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import FloatingLabelInput from '.'; + +const meta = { + title: 'Components/Inputs/FloatingLabelInput', + component: FloatingLabelInput, + argTypes: { + label: { control: 'text' }, + isError: { control: 'boolean' }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: '낙타해리빙봉', + placeholder: '송재석최현웅김윤경', + isError: false, + }, +}; diff --git a/frontend/src/components/FloatingInput/FloatingLabelInput.styles.ts b/frontend/src/components/FloatingInput/FloatingLabelInput.styles.ts new file mode 100644 index 000000000..5ec720e9f --- /dev/null +++ b/frontend/src/components/FloatingInput/FloatingLabelInput.styles.ts @@ -0,0 +1,42 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_floatingLabelContainer = (isError: boolean) => css` + position: relative; + display: inline-block; + width: 100%; + color: ${isError ? '#EB1E1E' : '#71717a'}; + + &:focus-within label { + color: ${isError ? '#EB1E1E' : theme.colors.pink.medium}; + } +`; + +export const s_floatingLabelInput = (isError: boolean) => css` + appearance: none; + box-shadow: ${isError ? `0 0 0 0.1rem #EB1E1E` : `0 0 0 0.1rem #71717a`}; + transition: box-shadow 0.3s; + + &::placeholder { + color: #71717a; + } + + &:focus { + box-shadow: ${isError + ? `0 0 0 0.1rem #EB1E1E` + : `0 0 0 0.1rem ${theme.colors.pink.mediumLight}`}; + } +`; + +export const s_floatingLabel = () => css` + position: absolute; + top: 0.4rem; + left: 1em; + + ${theme.typography.captionMedium}; + + background: transparent; + + transition: color 0.3s; +`; diff --git a/frontend/src/components/FloatingInput/index.tsx b/frontend/src/components/FloatingInput/index.tsx new file mode 100644 index 000000000..b7748fbcd --- /dev/null +++ b/frontend/src/components/FloatingInput/index.tsx @@ -0,0 +1,34 @@ +import type { InputProps } from '@components/_common/Input'; +import Input from '@components/_common/Input'; + +import { + s_floatingLabel, + s_floatingLabelContainer, + s_floatingLabelInput, +} from './FloatingLabelInput.styles'; + +interface FloatingLabelInputProps extends InputProps { + label: string; + isError: boolean; +} +export default function FloatingLabelInput({ + label, + placeholder, + isError, + ...props +}: FloatingLabelInputProps) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/MeetingCalendar/Date/Date.styles.ts b/frontend/src/components/MeetingCalendar/Date/Date.styles.ts index 4499c3b6b..4a704d6de 100644 --- a/frontend/src/components/MeetingCalendar/Date/Date.styles.ts +++ b/frontend/src/components/MeetingCalendar/Date/Date.styles.ts @@ -121,7 +121,7 @@ export const s_rangeStart = (isAllRangeSelected: boolean) => css` position: absolute; top: 0; - right: 0.4px; + right: 0.02rem; bottom: 0; width: 20%; diff --git a/frontend/src/components/ScrollBlock/index.tsx b/frontend/src/components/ScrollBlock/index.tsx new file mode 100644 index 000000000..2f670dae2 --- /dev/null +++ b/frontend/src/components/ScrollBlock/index.tsx @@ -0,0 +1,21 @@ +import type { PropsWithChildren } from 'react'; +import { useEffect, useRef } from 'react'; + +export default function ScrollBlock({ children }: PropsWithChildren) { + const contentRef = useRef(null); + + useEffect(() => { + const preventTouchMove = (e: TouchEvent) => { + e.preventDefault(); + }; + + // 터치 이벤트를 사용해서 스크롤을 할 경우, 해당 스크롤을 막는다는 것을 브라우저에게 명시적으로 알려주기 위해서 passive 속성 추가(@해리) + document.addEventListener('touchmove', preventTouchMove, { passive: false }); + + return () => { + document.removeEventListener('touchmove', preventTouchMove); + }; + }, []); + + return
{children}
; +} diff --git a/frontend/src/components/TimeRangeSelector/TimeRangeSelector.styles.ts b/frontend/src/components/TimeRangeSelector/TimeRangeSelector.styles.ts index 327128d45..65973e655 100644 --- a/frontend/src/components/TimeRangeSelector/TimeRangeSelector.styles.ts +++ b/frontend/src/components/TimeRangeSelector/TimeRangeSelector.styles.ts @@ -3,6 +3,6 @@ import { css } from '@emotion/react'; export const s_dropdownContainer = css` display: flex; gap: 1.2rem; - justify-content: flex-start; align-items: center; + justify-content: flex-start; `; diff --git a/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts b/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts index 319ff735f..664282be4 100644 --- a/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts +++ b/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts @@ -6,8 +6,10 @@ export const s_bottomFixedStyles = css` /* 버튼 컴포넌트의 full variants를 사용하려고 했으나 6rem보다 height값이 작아 직접 높이를 정의했어요(@해리) full 버튼에 이미 의존하고 있는 컴포넌트들이 많아서 높이를 full 스타일을 변경할 수는 없었습니다. + + 버튼의 높이가 너무 높다는 피드백을 반영하기 위해서 높이 수정 5.2rem(@해리) */ - height: 6rem; + height: 5.2rem; box-shadow: 0 -4px 4px rgb(0 0 0 / 25%); `; diff --git a/frontend/src/components/_common/Dropdown/Dropdown.styles.ts b/frontend/src/components/_common/Dropdown/Dropdown.styles.ts index 407608dbb..86630a8f5 100644 --- a/frontend/src/components/_common/Dropdown/Dropdown.styles.ts +++ b/frontend/src/components/_common/Dropdown/Dropdown.styles.ts @@ -1,8 +1,13 @@ import { css } from '@emotion/react'; +import theme from '@styles/theme'; + export const s_dropdown = css` width: fit-content; height: 2.8rem; padding: 0.4rem; + + color: ${theme.colors.black}; + border-radius: 0.4rem; `; diff --git a/frontend/src/components/_common/Field/Field.styles.ts b/frontend/src/components/_common/Field/Field.styles.ts index 389b86872..b2abed5a3 100644 --- a/frontend/src/components/_common/Field/Field.styles.ts +++ b/frontend/src/components/_common/Field/Field.styles.ts @@ -7,6 +7,7 @@ export const s_field = css` display: flex; flex-direction: column; gap: 0.8rem; + margin-bottom: 1.2rem; `; export const s_label = css` diff --git a/frontend/src/components/_common/Field/index.tsx b/frontend/src/components/_common/Field/index.tsx index 74fc3a0fa..9708ce096 100644 --- a/frontend/src/components/_common/Field/index.tsx +++ b/frontend/src/components/_common/Field/index.tsx @@ -1,5 +1,8 @@ import type { ReactNode } from 'react'; +import FloatingLabelInput from '@components/FloatingInput'; + +import Text from '../Text'; import { s_description, s_errorMessage, @@ -16,6 +19,14 @@ const Field = ({ children }: FieldProps) => { return
{children}
; }; +interface FieldTitleProps { + title: string; +} + +const FieldTitle = ({ title }: FieldTitleProps) => { + return {title}; +}; + interface FieldLabelProps { id: string; labelText: string; @@ -29,6 +40,7 @@ const FieldLabel = ({ id, labelText }: FieldLabelProps) => ( interface FieldDescriptionProps { description?: string; + accentText?: string; } const FieldDescription = ({ description }: FieldDescriptionProps) => @@ -44,8 +56,10 @@ const FieldErrorMessage = ({ errorMessage }: FieldErrorMessageProps) => ( ); +Field.Title = FieldTitle; Field.Label = FieldLabel; Field.Description = FieldDescription; Field.ErrorMessage = FieldErrorMessage; +Field.FloatingLabelInput = FloatingLabelInput; export default Field; diff --git a/frontend/src/components/_common/Input/Input.styles.ts b/frontend/src/components/_common/Input/Input.styles.ts index 884d2ffde..a6b68df3d 100644 --- a/frontend/src/components/_common/Input/Input.styles.ts +++ b/frontend/src/components/_common/Input/Input.styles.ts @@ -4,9 +4,8 @@ import theme from '@styles/theme'; const baseInputStyle = css` width: 100%; - height: 4.4rem; - padding: 0.8rem; - outline-color: ${theme.colors.primary}; + height: 4.8rem; + outline: none; ${theme.typography.bodyMedium} `; @@ -19,4 +18,11 @@ export const s_input = { border: none; outline: none; `, + floating: css` + padding-top: 1.6rem; /* 텍스트를 아래로 내리기 위해 top padding을 더 줌 (@해리) */ + padding-left: 1.2rem; + border: none; + ${baseInputStyle} + border-radius: 1.2rem; + `, }; diff --git a/frontend/src/components/_common/Input/index.tsx b/frontend/src/components/_common/Input/index.tsx index d39e79419..865437d30 100644 --- a/frontend/src/components/_common/Input/index.tsx +++ b/frontend/src/components/_common/Input/index.tsx @@ -2,12 +2,13 @@ import type { InputHTMLAttributes } from 'react'; import { s_input } from './Input.styles'; -export type InputVariant = 'default' | 'transparent'; +export type InputVariant = 'default' | 'transparent' | 'floating'; export interface InputProps extends InputHTMLAttributes { variant?: InputVariant; + isError?: boolean; } export default function Input({ variant = 'default', type = 'text', ...props }: InputProps) { - return ; + return ; } diff --git a/frontend/src/constants/buttons.ts b/frontend/src/constants/buttons.ts new file mode 100644 index 000000000..9fcd9f919 --- /dev/null +++ b/frontend/src/constants/buttons.ts @@ -0,0 +1,5 @@ +export const MEETING_BUTTON_TEXTS = { + create: '약속 생성하기', + next: '다음', + register: '등록하러 가기', +}; diff --git a/frontend/src/constants/inputFields.ts b/frontend/src/constants/inputFields.ts index 63c563616..8e1c37bbf 100644 --- a/frontend/src/constants/inputFields.ts +++ b/frontend/src/constants/inputFields.ts @@ -9,8 +9,35 @@ export const INPUT_RULES = { }; export const FIELD_DESCRIPTIONS = { + meetingName: '약속 이름은 1~10자 사이로 입력해 주세요.', + nickname: '1~5자 사이로 입력해 주세요.', + password: '4자리 숫자로 입력해 주세요.', + date: '날짜를 하나씩 클릭해 여러 날짜를 선택하거나\n시작일과 종료일을 클릭해 사이의 모든 날짜를 선택해 보세요', +}; + +export const FIELD_TITLES = { + meetingName: '약속 정보 입력', + meetingHostInfo: '약속 주최자 정보 입력', + meetingDateTime: '약속 후보 날짜 선택', + meetingTimeRange: '약속 시간 범위 선택', + attendeeLogin: '내 정보 입력', +}; + +export const FIELD_LABELS = { + meetingName: '약속 이름', + nickname: '닉네임', + password: '비밀번호', + onlyDate: '날짜만 선택할래요', +}; + +export const FIELD_PLACEHOLDERS = { + meetingName: '10자 이내의 약속 이름 입력', + nickname: '5자 이내의 약속 닉네임 입력', + password: '4자리 숫자 입력', +}; + +export const FIELD_ERROR_MESSAGES = { meetingName: '약속 이름은 1~10자 사이로 입력해 주세요.', nickname: '닉네임은 1~5자 사이로 입력해 주세요.', password: '비밀번호는 4자리 숫자로 입력해 주세요.', - date: '날짜를 하나씩 클릭해 여러 날짜를 선택하거나\n시작일과 종료일을 클릭해 사이의 모든 날짜를 선택해 보세요', }; diff --git a/frontend/src/constants/toasts.ts b/frontend/src/constants/toasts.ts new file mode 100644 index 000000000..af28336f5 --- /dev/null +++ b/frontend/src/constants/toasts.ts @@ -0,0 +1,3 @@ +export const TOAST_MESSAGES = { + OUT_OF_ONE_YEAR_RANGE: '최대 1년뒤의 약속만 생성할 수 있어요', +}; diff --git a/frontend/src/hooks/__test__/Providers.tsx b/frontend/src/hooks/__test__/Providers.tsx new file mode 100644 index 000000000..47b4fd510 --- /dev/null +++ b/frontend/src/hooks/__test__/Providers.tsx @@ -0,0 +1,8 @@ +import type { PropsWithChildren } from 'react'; + +import ToastProvider from '@contexts/ToastProvider'; + +// 필요한 _Provider 들은 유동적으로 추가해서 테스트 환경에서 사용할 수 있어요(@해리) +export default function Providers({ children }: PropsWithChildren) { + return {children}; +} diff --git a/frontend/src/hooks/__test__/renderHookWithProvider.tsx b/frontend/src/hooks/__test__/renderHookWithProvider.tsx new file mode 100644 index 000000000..ec2478da0 --- /dev/null +++ b/frontend/src/hooks/__test__/renderHookWithProvider.tsx @@ -0,0 +1,11 @@ +import type { RenderOptions } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; + +import Providers from './Providers'; + +export default function render(callback: () => T, options?: Omit) { + return renderHook(callback, { + wrapper: Providers, + ...options, + }); +} diff --git a/frontend/src/hooks/useAttendeeLogin/useAttendeeLogin.ts b/frontend/src/hooks/useAttendeeLogin/useAttendeeLogin.ts new file mode 100644 index 000000000..1a92a7101 --- /dev/null +++ b/frontend/src/hooks/useAttendeeLogin/useAttendeeLogin.ts @@ -0,0 +1,58 @@ +import { useContext } from 'react'; + +import { UuidContext } from '@contexts/UuidProvider'; + +import useInput from '@hooks/useInput/useInput'; + +import { usePostLoginMutation } from '@stores/servers/user/mutations'; + +import { FIELD_ERROR_MESSAGES, INPUT_FIELD_PATTERN } from '@constants/inputFields'; + +const useAttendeeLogin = () => { + const { uuid } = useContext(UuidContext); + const { mutate: postLoginMutate } = usePostLoginMutation(); + + const attendeeNameInput = useInput({ + pattern: INPUT_FIELD_PATTERN.nickname, + errorMessage: FIELD_ERROR_MESSAGES.nickname, + }); + const isAttendeeNameError = attendeeNameInput.errorMessage !== null; + const attendeeNameField = { ...attendeeNameInput, isError: isAttendeeNameError }; + + const attendeePasswordInput = useInput({ + pattern: INPUT_FIELD_PATTERN.password, + errorMessage: FIELD_ERROR_MESSAGES.password, + }); + const isAttendeePasswordError = attendeePasswordInput.errorMessage !== null; + const attendeePasswordField = { ...attendeePasswordInput, isError: isAttendeePasswordError }; + + const isFormValid = () => { + const hasLoginFormError = isAttendeeNameError || isAttendeePasswordError; + + if (hasLoginFormError) { + return false; + } + + const requiredFields = [attendeeNameInput.value, attendeePasswordInput.value]; + const isAllFieldsFilled = requiredFields.every((field) => field !== ''); + + return isAllFieldsFilled; + }; + + const handleLoginButtonClick = async () => { + postLoginMutate({ + uuid, + request: { attendeeName: attendeeNameInput.value, password: attendeePasswordInput.value }, + }); + }; + + return { + attendeeNameField, + attendeePasswordField, + isFormValid, + handleLoginButtonClick, + uuid, + }; +}; + +export default useAttendeeLogin; diff --git a/frontend/src/hooks/useCalendar/useCalendar.test.ts b/frontend/src/hooks/useCalendar/useCalendar.test.ts index 1935c6437..33f616e1a 100644 --- a/frontend/src/hooks/useCalendar/useCalendar.test.ts +++ b/frontend/src/hooks/useCalendar/useCalendar.test.ts @@ -1,10 +1,22 @@ -import { renderHook } from '@testing-library/react'; import { act } from 'react'; +import renderHookWithProvider from '@hooks/__test__/renderHookWithProvider'; + import { getFullDate } from '@utils/date'; +import { TOAST_MESSAGES } from '@constants/toasts'; + import useCalendar from './useCalendar'; +// 테스트 환경에서 useToast를 호출하면 mockAddToast 함수를 호출합니다.(@해리) +const mockAddToast = jest.fn(); +jest.mock('@hooks/useToast/useToast', () => ({ + __esModule: true, + default: () => ({ + addToast: mockAddToast, + }), +})); + describe('useCalendar', () => { const TEST_YEAR = 2024; const TEST_MONTH = 9; @@ -24,7 +36,7 @@ describe('useCalendar', () => { it('현재 년도, 월을 올바르게 계산해서 반환한다.', () => { jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { headers, isCurrentMonth } = result.current; const { currentYear, currentMonth } = headers; @@ -39,7 +51,7 @@ describe('useCalendar', () => { it('12월에서 다음 달로 이동하면 다음 년도로 변경되어야 한다.', () => { jest.setSystemTime(new Date(TEST_YEAR, LAST_MONTH_INDEX, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { view } = result.current; const { moveToNextMonth } = view; @@ -58,7 +70,7 @@ describe('useCalendar', () => { it('1월에서 이전 년도로 이동하면 이전 년도로 변경되어야 한다.', () => { jest.setSystemTime(new Date(TEST_YEAR, FIRST_MONTH_INDEX, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { view } = result.current; const { moveToPrevMonth } = view; @@ -79,7 +91,7 @@ describe('useCalendar', () => { it('이전 달로 이동하면, 달 데이터가 변경되어야 한다.', () => { jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { view } = result.current; const { moveToPrevMonth } = view; @@ -98,7 +110,7 @@ describe('useCalendar', () => { it('다음 달로 이동하면 달 데이터가 변경되어야 한다..', () => { jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { view } = result.current; const { moveToNextMonth } = view; @@ -117,7 +129,7 @@ describe('useCalendar', () => { describe('월 이동 시, 변경된 달력 데이터 계산', () => { it('현재 달의 마지막 주에 있는 current 상태의 날짜들이 다음 달로 이동했을 때 prev 상태로 변경되어야 한다.', () => { - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { body: { value: initialCalendarData }, view: { moveToNextMonth }, @@ -145,7 +157,7 @@ describe('useCalendar', () => { }); it('현재 달의 첫 주에 있는 current 상태의 날짜들이 이전 달로 이동했을 때 next 상태로 변경되어야 한다.', () => { - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { body: { value: initialCalendarData }, view: { moveToPrevMonth }, @@ -172,4 +184,30 @@ describe('useCalendar', () => { }); }); }); + + describe('1년 범위 이동 예외처리', () => { + const TEST_YEAR = 2024; + const TEST_MONTH = 9; // 10월 + const TEST_DATE = 4; + + beforeEach(() => { + jest.setSystemTime(new Date(TEST_YEAR + 1, TEST_MONTH, TEST_DATE)); + }); + + it('현재 월 기준, 약속 날짜 범위가 1년을 벗어나면 토스트 UI를 활용하여 사용자에게 예외 피드백을 전달한다', () => { + const { result } = renderHookWithProvider(useCalendar); + + const { view } = result.current; + const { moveToNextMonth } = view; + act(() => { + moveToNextMonth(); + }); + + expect(mockAddToast).toHaveBeenCalledWith({ + message: TOAST_MESSAGES.OUT_OF_ONE_YEAR_RANGE, + type: 'warning', + duration: 2000, + }); + }); + }); }); diff --git a/frontend/src/hooks/useCalendar/useCalendar.ts b/frontend/src/hooks/useCalendar/useCalendar.ts index 17448f554..62c4d876e 100644 --- a/frontend/src/hooks/useCalendar/useCalendar.ts +++ b/frontend/src/hooks/useCalendar/useCalendar.ts @@ -1,7 +1,11 @@ import { useState } from 'react'; import type { MonthlyDays } from 'types/calendar'; -import { getMonth, getYear } from '@utils/date'; +import useToast from '@hooks/useToast/useToast'; + +import { getFullDate, getMonth, getYear } from '@utils/date'; + +import { TOAST_MESSAGES } from '@constants/toasts'; import { getMonthlyCalendarDate } from './useCalendar.utils'; @@ -23,20 +27,39 @@ interface useCalendarReturn { } const TODAY = new Date(); +const ONE_YEAR_LATER = getFullDate(new Date(getYear(TODAY) + 1, getMonth(TODAY))); + +type MonthDelta = -1 | 1; const useCalendar = (): useCalendarReturn => { const [currentFullDate, setCurrentFullDate] = useState(new Date()); + const { addToast } = useToast(); const currentYear = getYear(currentFullDate); const currentMonth = getMonth(currentFullDate); const isCurrentMonth = getYear(TODAY) === currentYear && getMonth(TODAY) === currentMonth; + const moveMonth = (monthDelta: MonthDelta) => { + setCurrentFullDate(new Date(currentYear, currentMonth + monthDelta)); + }; + const moveToPrevMonth = () => { - setCurrentFullDate(new Date(currentYear, currentMonth - 1)); + moveMonth(-1); }; const moveToNextMonth = () => { - setCurrentFullDate(new Date(currentYear, currentMonth + 1)); + const fullDate = getFullDate(currentFullDate); + + if (fullDate >= ONE_YEAR_LATER) { + addToast({ + message: TOAST_MESSAGES.OUT_OF_ONE_YEAR_RANGE, + type: 'warning', + duration: 2000, + }); + return; + } + + moveMonth(1); }; const monthlyCalendarDate = getMonthlyCalendarDate(currentFullDate); diff --git a/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts b/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts index 7ffdc65fa..6a34fb913 100644 --- a/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts +++ b/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts @@ -6,7 +6,7 @@ import useTimeRangeDropdown from '@hooks/useTimeRangeDropdown/useTimeRangeDropdo import { usePostMeetingMutation } from '@stores/servers/meeting/mutations'; -import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN, INPUT_RULES } from '@constants/inputFields'; +import { FIELD_ERROR_MESSAGES, INPUT_FIELD_PATTERN, INPUT_RULES } from '@constants/inputFields'; const checkInputInvalid = (value: string, errorMessage: string | null) => value.length < INPUT_RULES.minimumLength || errorMessage !== null; @@ -14,7 +14,7 @@ const checkInputInvalid = (value: string, errorMessage: string | null) => const useCreateMeeting = () => { const meetingNameInput = useInput({ pattern: INPUT_FIELD_PATTERN.meetingName, - errorMessage: FIELD_DESCRIPTIONS.meetingName, + errorMessage: FIELD_ERROR_MESSAGES.meetingName, }); const isMeetingNameInvalid = checkInputInvalid( meetingNameInput.value, @@ -23,11 +23,11 @@ const useCreateMeeting = () => { const hostNickNameInput = useInput({ pattern: INPUT_FIELD_PATTERN.nickname, - errorMessage: FIELD_DESCRIPTIONS.nickname, + errorMessage: FIELD_ERROR_MESSAGES.nickname, }); const hostPasswordInput = useInput({ pattern: INPUT_FIELD_PATTERN.password, - errorMessage: FIELD_DESCRIPTIONS.password, + errorMessage: FIELD_ERROR_MESSAGES.password, }); const isHostInfoInvalid = checkInputInvalid(hostNickNameInput.value, hostNickNameInput.errorMessage) || diff --git a/frontend/src/pages/AttendeeLoginPage/AttendeeLoginPage.styles.ts b/frontend/src/pages/AttendeeLoginPage/AttendeeLoginPage.styles.ts index e86bad389..1c60d902a 100644 --- a/frontend/src/pages/AttendeeLoginPage/AttendeeLoginPage.styles.ts +++ b/frontend/src/pages/AttendeeLoginPage/AttendeeLoginPage.styles.ts @@ -3,22 +3,13 @@ import { css } from '@emotion/react'; export const s_container = css` display: flex; flex-direction: column; - gap: 2rem; - align-items: center; - justify-content: center; - - height: calc(100vh - 8.4rem); - padding: 0 2rem; + row-gap: 0.4rem; + height: 100%; `; export const s_inputContainer = css` display: flex; flex-direction: column; - gap: 1rem; - + justify-content: center; width: 100%; - padding: 1.6rem; - - background-color: #f7dacb; - border-radius: 0.5rem; `; diff --git a/frontend/src/pages/AttendeeLoginPage/index.tsx b/frontend/src/pages/AttendeeLoginPage/index.tsx index def8a836a..e70778b05 100644 --- a/frontend/src/pages/AttendeeLoginPage/index.tsx +++ b/frontend/src/pages/AttendeeLoginPage/index.tsx @@ -1,66 +1,36 @@ -import { useContext } from 'react'; - import ContentLayout from '@layouts/ContentLayout'; -import { UuidContext } from '@contexts/UuidProvider'; - +import ScrollBlock from '@components/ScrollBlock'; import BackButton from '@components/_common/Buttons/BackButton'; import { Button } from '@components/_common/Buttons/Button'; import Field from '@components/_common/Field'; import Header from '@components/_common/Header'; -import Input from '@components/_common/Input'; - -import useInput from '@hooks/useInput/useInput'; +import Text from '@components/_common/Text'; -import { usePostLoginMutation } from '@stores/servers/user/mutations'; +import useAttendeeLogin from '@hooks/useAttendeeLogin/useAttendeeLogin'; -import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN } from '@constants/inputFields'; +import { MEETING_BUTTON_TEXTS } from '@constants/buttons'; +import { FIELD_LABELS, FIELD_PLACEHOLDERS, FIELD_TITLES } from '@constants/inputFields'; import { s_container, s_inputContainer } from './AttendeeLoginPage.styles'; export default function AttendeeLoginPage() { - const { uuid } = useContext(UuidContext); - - const { mutate: postLoginMutate } = usePostLoginMutation(); + const { attendeeNameField, attendeePasswordField, handleLoginButtonClick, isFormValid, uuid } = + useAttendeeLogin(); const { value: attendeeName, onValueChange: handleAttendeeNameChange, errorMessage: attendeeNameErrorMessage, - } = useInput({ - pattern: INPUT_FIELD_PATTERN.nickname, - errorMessage: FIELD_DESCRIPTIONS.nickname, - }); + isError: isAttendeeNameError, + } = attendeeNameField; const { value: attendeePassword, onValueChange: handleAttendeePasswordChange, errorMessage: attendeePasswordErrorMessage, - } = useInput({ - pattern: INPUT_FIELD_PATTERN.password, - errorMessage: FIELD_DESCRIPTIONS.password, - }); - - const isFormValid = () => { - const errorMessages = [attendeeNameErrorMessage, attendeePasswordErrorMessage]; - const hasErrors = errorMessages.some((errorMessage) => errorMessage !== null); - - if (hasErrors) { - return false; - } - - const requiredFields = [attendeeName, attendeePassword]; - const isAllFieldsFilled = requiredFields.every((field) => field !== ''); - - return isAllFieldsFilled; - }; - - const handleLoginButtonClick = async () => { - postLoginMutate({ - uuid, - request: { attendeeName, password: attendeePassword }, - }); - }; + isError: isAttendeePasswordError, + } = attendeePasswordField; return ( <> @@ -69,43 +39,48 @@ export default function AttendeeLoginPage() { -
-
- - - - - - - - - - - - - + +
+
+ + + + + 약속에서 사용할 를 입력해 주세요 + + + + + +
+
- -
+ ); diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx index 9983c6a59..c01458866 100644 --- a/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx @@ -14,7 +14,8 @@ 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 { MEETING_BUTTON_TEXTS } from '@constants/buttons'; +import { FIELD_DESCRIPTIONS, FIELD_LABELS, FIELD_TITLES } from '@constants/inputFields'; import { s_container, s_dateCandidateSelector } from './MeetingDateTime.styles'; @@ -74,7 +75,7 @@ export default function MeetingDateTime({
- +
{!isChecked && ( - + - 약속 생성하기 + {MEETING_BUTTON_TEXTS.create}
); diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx index 77493d6c7..a74cff53a 100644 --- a/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx @@ -1,11 +1,13 @@ +import ScrollBlock from '@components/ScrollBlock'; import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; import Field from '@components/_common/Field'; -import Input from '@components/_common/Input'; +import Text from '@components/_common/Text'; import useButtonOnKeyboard from '@hooks/useButtonOnKeyboard/useButtonOnKeyboard'; import type { UseInputReturn } from '@hooks/useInput/useInput'; -import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; +import { MEETING_BUTTON_TEXTS } from '@constants/buttons'; +import { FIELD_LABELS, FIELD_PLACEHOLDERS, FIELD_TITLES } from '@constants/inputFields'; interface MeetingHostInfoProps { hostNickNameInput: UseInputReturn; @@ -32,36 +34,45 @@ export default function MeetingHostInfo({ } = hostPasswordInput; const resizedButtonHeight = useButtonOnKeyboard(); + const isHostNickNameError = hostNickNameErrorMessage !== null; + const isHostPasswordError = hostPasswordErrorMessage !== null; return ( - <> + - - - - - - - - - - + + 약속을 생성하면 + + 돼요 + + + - + - 다음 + {MEETING_BUTTON_TEXTS.next} - + ); } diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx index 98ec588b2..927ca277e 100644 --- a/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx @@ -1,11 +1,12 @@ +import ScrollBlock from '@components/ScrollBlock'; 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'; +import { MEETING_BUTTON_TEXTS } from '@constants/buttons'; +import { FIELD_LABELS, FIELD_PLACEHOLDERS, FIELD_TITLES } from '@constants/inputFields'; interface MeetingNameProps { meetingNameInput: UseInputReturn; @@ -24,18 +25,20 @@ export default function MeetingName({ errorMessage: meetingNameErrorMessage, } = meetingNameInput; + const isTextError = meetingNameErrorMessage !== null; const resizedButtonHeight = useButtonOnKeyboard(); return ( - <> + - - - + @@ -45,8 +48,8 @@ export default function MeetingName({ disabled={isMeetingNameInvalid} height={resizedButtonHeight} > - 다음 + {MEETING_BUTTON_TEXTS.next} - + ); } diff --git a/frontend/src/pages/CreateMeetingPage/index.tsx b/frontend/src/pages/CreateMeetingPage/index.tsx index 6d43f361f..f31f4d86d 100644 --- a/frontend/src/pages/CreateMeetingPage/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/index.tsx @@ -67,7 +67,7 @@ export default function CreateMeetingPage() { {/* BottomFixedButton이 요소를 가리는 현상이 있어 버튼 높이(6rem)와 같은 크기의 div 요소 배치 */} -
+