Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat[#85]: 리뷰 작성 모달 컴포넌트 구현, TextField 글자 수 에러 처리 추가 #169

Merged
merged 7 commits into from
Mar 21, 2024
3 changes: 3 additions & 0 deletions public/svgs/ic-star-error.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 11 additions & 3 deletions src/components/commons/StarRating/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@ const { star } = SVGS;
type StarRatingProps = {
size: 'small' | 'medium' | 'large';
rating: number;
currentRating?: number;
readonly?: boolean;
onChange?: (arg: number) => void | undefined;
};

const StarRating = ({ size, onChange, rating = 0, readonly = false }: StarRatingProps) => {
const StarRating = ({ size, currentRating, onChange, rating = 0, readonly = false }: StarRatingProps) => {
const TOTAL_RATING = 5;
const OFFSET = 1;
const isEmptyPickStar = currentRating === undefined;

const handleStarClick = (starId: number) => {
if (!onChange) return;
onChange(starId + OFFSET);
const newRating = starId + OFFSET;
onChange(newRating);
};

return (
Expand All @@ -41,7 +44,12 @@ const StarRating = ({ size, onChange, rating = 0, readonly = false }: StarRating
</div>
) : (
<button type='button' onClick={() => handleStarClick(index)} className={cx(`star-size-${size}`)}>
<Image src={url} alt={alt} className={cx('star-icon')} fill></Image>
<Image
src={isEmptyPickStar ? star.error.url : url}
alt={isEmptyPickStar ? star.error.alt : alt}
className={cx('star-icon')}
fill
></Image>
</button>
)}
</li>
Expand Down
10 changes: 8 additions & 2 deletions src/components/commons/inputs/TextField.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,14 @@
@include text-style(12, $gray50);
}

.active {
color: $gray10;
&-current-num {
&.error {
color: $red;
}

&.active:not(.error) {
color: $gray10;
}
}
}
}
Expand Down
34 changes: 23 additions & 11 deletions src/components/commons/inputs/TextField.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { ChangeEvent, useState } from 'react';
import { useState } from 'react';

import classNames from 'classnames/bind';
import { useFormContext } from 'react-hook-form';

import { REGEX } from '@/constants';

import styles from './TextField.module.scss';

const cx = classNames.bind(styles);

const MIN_LENGTH_TEXTAREA = 5;

type TextFieldProps = {
name: string;
label?: string;
Expand All @@ -19,12 +23,17 @@ export const TextField = ({ name, label, maxLength = 700, ...props }: TextFieldP
const {
register,
formState: { errors },
watch,
} = useFormContext();
const [textCount, setTextCount] = useState(0);
const [isFocused, setIsFocused] = useState(false);

const contentValue = watch('content');
const textLength = contentValue ? contentValue.replace(REGEX.textarea, '').length : 0;
const isBelowMinLength = textLength < MIN_LENGTH_TEXTAREA;
const isValidLength = textLength >= MIN_LENGTH_TEXTAREA;
const isError = !!errors[name]?.message;

const [isFocused, setIsFocused] = useState(false);

const handleClick = () => {
setIsFocused(true);
};
Expand All @@ -33,10 +42,6 @@ export const TextField = ({ name, label, maxLength = 700, ...props }: TextFieldP
setIsFocused(false);
};

const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setTextCount(event.target.textLength);
};

return (
<div className={cx('text-field')}>
<label className={cx('text-field-label', { 'non-label': !label })}>{label}</label>
Expand All @@ -49,13 +54,20 @@ export const TextField = ({ name, label, maxLength = 700, ...props }: TextFieldP
>
<textarea
className={cx('text-field-text-group-textarea')}
{...register(name, {
onChange: (event) => handleChange(event),
})}
{...register(name)}
maxLength={maxLength}
{...props}
/>
<div className={cx('text-field-text-group-footer')}>
<span className={cx('text-field-text-group-footer-current-num', { active: textCount > 0 })}>{textCount}</span>
<span
className={cx(
'text-field-text-group-footer-current-num',
{ active: isValidLength },
{ error: isBelowMinLength },
)}
>
{textLength}
</span>
<span className={cx('text-field-text-group-footer-total-num')}>/{maxLength}</span>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.review-form {
&-header {
@include column-flexbox(start, start, 0.4rem);
}

&-title {
@include text-style(16, $gray10, bold);
}

&-date {
@include flexbox($gap: 0.4rem);

&-text {
@include text-style(14, $gray40);
}
}

&-rating {
padding: 1.6rem 0;
border-bottom: 0.1rem solid $black60;
}

&-content {
padding: 1.6rem 0;
}

&-buttons {
@include flexbox($gap: 1.2rem);

button {
@include responsive(M) {
flex-grow: 1;
}
}
}
}
108 changes: 108 additions & 0 deletions src/components/mypage/MyReservations/ReviewModalContent/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Image from 'next/image';

import { useState } from 'react';

import { zodResolver } from '@hookform/resolvers/zod';
// import { useMutation, useQueryClient } from '@tanstack/react-query';
import classNames from 'classnames/bind';
import { FormProvider, useForm } from 'react-hook-form';
import { z } from 'zod';

// import { MyReservations } from '@/apis/myReservations';
import { SVGS, ERROR_MESSAGE, REGEX } from '@/constants';

import { BaseButton } from '@/components/commons/buttons';
import { TextField } from '@/components/commons/inputs';
import StarRating from '@/components/commons/StarRating';

import styles from './ReviewModalContent.module.scss';

const cx = classNames.bind(styles);

const { url, alt } = SVGS.calendar.default;
const { review, rating } = ERROR_MESSAGE;
const MIN_LENGTH_TEXTAREA = 5;

type ReviewModalContentProps = {
reservationId: number;
title: string;
date: string;
handleModalClose: (arg: string) => void;
};

const ReviewModalContent = ({ reservationId, title, date, handleModalClose }: ReviewModalContentProps) => {
const ReviewModalSchema = z.object({
rating: z.number().min(1, { message: rating.min }),
content: z
.string()
.refine((content) => content.replace(REGEX.textarea, '').length >= MIN_LENGTH_TEXTAREA, { message: review.min }),
});

const methods = useForm({
mode: 'all',
resolver: zodResolver(ReviewModalSchema),
});

const { handleSubmit, setValue, watch } = methods;
const selectedRating = watch('rating');
const [currentRating, setCurrentRating] = useState(0);

/*
const queryClient = useQueryClient();
const { mutate: createReviewMutation } = useMutation({
mutationFn: MyReservations.createReview,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['myReservations', reservationId, 'reviews'] });
},
});
*/

const handleCurrentRating = () => setCurrentRating(selectedRating);

const handleStarClick = (rating: number) => {
setValue('rating', rating);
setCurrentRating(rating);
};

const onSubmit = (data: object) => {
console.log(data);
console.log(reservationId);
handleModalClose('submitReviewModal');
// createReviewMutation(reservationId, data);
};

return (
<FormProvider {...methods}>
<form className={cx('review-form')} onSubmit={handleSubmit(onSubmit)}>
<header className={cx('review-form-header')}>
<h2 className={cx('review-form-title')}>{title}</h2>
<div className={cx('review-form-date')}>
<Image src={url} alt={alt} width={20} height={20} />
<span className={cx('review-form-date-text')}>{date}</span>
</div>
</header>

<fieldset>
<legend>별점 및 리뷰 등록</legend>
<div className={cx('review-form-rating')}>
<StarRating size='large' rating={selectedRating} onChange={handleStarClick} currentRating={currentRating} />
</div>
<div className={cx('review-form-content')}>
<TextField name='content' maxLength={200} placeholder={review.placeholder} />
</div>
</fieldset>

<footer className={cx('review-form-buttons')}>
<BaseButton type='button' theme='outline' size='medium' onClick={() => handleModalClose('submitReviewModal')}>
취소
</BaseButton>
<BaseButton type='submit' theme='fill' size='medium' onClick={handleCurrentRating}>
등록
</BaseButton>
</footer>
</form>
</FormProvider>
);
};

export default ReviewModalContent;
4 changes: 4 additions & 0 deletions src/constants/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export const SVGS = {
url: '/svgs/ic-star-filled.svg',
alt: '채워진 별 아이콘',
},
error: {
url: '/svgs/ic-star-error.svg',
alt: '에러 별 아이콘',
},
},

arrow: {
Expand Down
8 changes: 8 additions & 0 deletions src/constants/inputValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ export const ERROR_MESSAGE = {
nickname: {
min: '10자 이내로 입력해주세요',
},
review: {
min: '최소 5자 이상 입력해 주세요',
placeholder: '리뷰를 입력해 주세요',
},
rating: {
min: '별점 1점 이상 등록해 주세요',
},
};

export const REGEX = {
password: /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d]{8,15}$/,
textarea: /\n/g,
};