diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index 23699d190..1e8a97558 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -167,6 +167,11 @@ d="M20.2 4.683a1.013 1.013 0 0 1 0 1.438L9.008 17.317a1.013 1.013 0 0 1-1.437 0L1.8 11.55a1.017 1.017 0 0 1 1.437-1.437l5.045 5.045L18.758 4.683a1.013 1.013 0 0 1 1.437 0h.005z" /> + + + + + + + + + + + + +
diff --git a/src/components/Common/ImageUploader/ImageUploader.tsx b/src/components/Common/ImageUploader/ImageUploader.tsx index b139a1b4c..5b1e99a09 100644 --- a/src/components/Common/ImageUploader/ImageUploader.tsx +++ b/src/components/Common/ImageUploader/ImageUploader.tsx @@ -1,9 +1,13 @@ -import { Button, useToastActionContext } from '@fun-eat/design-system'; +import { useToastActionContext } from '@fun-eat/design-system'; import type { ChangeEventHandler } from 'react'; -import styled from 'styled-components'; + +import { container, deleteButton, image, imageWrapper, uploadInput, uploadLabel } from './imageUploader.css'; +import SvgIcon from '../Svg/SvgIcon'; +import Text from '../Text/Text'; import { IMAGE_MAX_SIZE } from '@/constants'; import { useEnterKeyDown } from '@/hooks/common'; +import { vars } from '@/styles/theme.css'; interface ReviewImageUploaderProps { previewImage: string; @@ -32,45 +36,32 @@ const ImageUploader = ({ previewImage, uploadImage, deleteImage }: ReviewImageUp }; return ( - <> - {previewImage ? ( - - 업로드한 사진 - - - ) : ( - - + - - +
+ + {previewImage && ( +
+ 업로드한 사진 + +
)} - +
); }; export default ImageUploader; - -const ImageUploadLabel = styled.label` - display: flex; - justify-content: center; - align-items: center; - width: 92px; - height: 95px; - border: 1px solid ${({ theme }) => theme.borderColors.disabled}; - border-radius: ${({ theme }) => theme.borderRadius.xs}; - background: ${({ theme }) => theme.colors.gray1}; - cursor: pointer; - - & > input { - display: none; - } -`; - -const PreviewImageWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; -`; diff --git a/src/components/Common/ImageUploader/imageUploader.css.ts b/src/components/Common/ImageUploader/imageUploader.css.ts new file mode 100644 index 000000000..1edc9ab8d --- /dev/null +++ b/src/components/Common/ImageUploader/imageUploader.css.ts @@ -0,0 +1,56 @@ +import { vars } from '@/styles/theme.css'; +import { style, styleVariants } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + gap: 8, +}); + +export const uploadLabelBase = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + width: 80, + height: 80, + borderRadius: 6, + cursor: 'pointer', +}); + +export const uploadLabel = styleVariants({ + default: [uploadLabelBase, { backgroundColor: vars.colors.background.category }], + uploaded: [ + uploadLabelBase, + { backgroundColor: vars.colors.background.default, border: `1px solid ${vars.colors.border.default}` }, + ], +}); + +export const uploadInput = style({ + display: 'none', +}); + +export const imageWrapper = style({ + position: 'relative', + width: 80, + height: 80, +}); + +export const image = style({ + objectFit: 'cover', + borderRadius: 6, +}); + +export const deleteButton = style({ + position: 'absolute', + top: 4, + right: 4, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: 18, + height: 18, + borderRadius: '50%', + backgroundColor: vars.colors.black, + opacity: 0.5, + cursor: 'pointer', +}); diff --git a/src/components/Common/Svg/SvgIcon.tsx b/src/components/Common/Svg/SvgIcon.tsx index 4b6c0796b..4d5c52b73 100644 --- a/src/components/Common/Svg/SvgIcon.tsx +++ b/src/components/Common/Svg/SvgIcon.tsx @@ -14,6 +14,7 @@ export const SVG_ICON_VARIANTS = [ 'star2', 'review2', 'check2', + 'picture', 'recipe', 'list', 'member', @@ -45,6 +46,7 @@ export const SVG_ICON_VARIANTS = [ 'heartFilled', 'close2', 'disk', + 'error', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/src/components/Common/Svg/SvgSprite.tsx b/src/components/Common/Svg/SvgSprite.tsx index 922b6ec46..a4420561f 100644 --- a/src/components/Common/Svg/SvgSprite.tsx +++ b/src/components/Common/Svg/SvgSprite.tsx @@ -161,6 +161,9 @@ const SvgSprite = () => { + + + @@ -333,6 +336,21 @@ const SvgSprite = () => { + + + + + + + + + + ); }; diff --git a/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx b/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx index 9f1a6e956..ee9749009 100644 --- a/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx +++ b/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx @@ -1,10 +1,18 @@ -import { Checkbox } from '@fun-eat/design-system'; import type { ChangeEventHandler } from 'react'; +import { check, checkbox, container } from './rebuyCheckbox.css'; +import { itemTitle } from '../ReviewRegisterForm/reviewRegisterForm.css'; + +import { SvgIcon, Text } from '@/components/Common'; import { useEnterKeyDown } from '@/hooks/common'; import { useReviewFormActionContext } from '@/hooks/context'; +import { vars } from '@/styles/theme.css'; + +interface RebuyCheckboxProps { + isRebuy: boolean; +} -const RebuyCheckbox = () => { +const RebuyCheckbox = ({ isRebuy }: RebuyCheckboxProps) => { const { handleReviewFormValue } = useReviewFormActionContext(); const { inputRef, labelRef, handleKeydown } = useEnterKeyDown(); @@ -13,11 +21,22 @@ const RebuyCheckbox = () => { }; return ( -

- - 재구매할 생각이 있으신가요? - -

+ <> +

+ 재구매 여부 +

+

+ +

+ ); }; diff --git a/src/components/Review/RebuyCheckbox/rebuyCheckbox.css.ts b/src/components/Review/RebuyCheckbox/rebuyCheckbox.css.ts new file mode 100644 index 000000000..efb6cd1c9 --- /dev/null +++ b/src/components/Review/RebuyCheckbox/rebuyCheckbox.css.ts @@ -0,0 +1,27 @@ +import { vars } from '@/styles/theme.css'; +import { style, styleVariants } from '@vanilla-extract/css'; + +export const container = style({ + display: 'inline-flex', + alignItems: 'center', + gap: 8, + cursor: 'pointer', +}); + +export const checkbox = style({ + display: 'none', +}); + +const checkBase = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: 17, + height: 17, + borderRadius: '50%', +}); + +export const check = styleVariants({ + default: [checkBase, { backgroundColor: vars.colors.icon.light }], + checked: [checkBase, { backgroundColor: vars.colors.black }], +}); diff --git a/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx b/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx index a2e684006..dbbc04559 100644 --- a/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx +++ b/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx @@ -1,60 +1,62 @@ -import { Button, Divider, Heading, Spacing, Text, theme, useToastActionContext } from '@fun-eat/design-system'; -import type { FormEventHandler, RefObject } from 'react'; -import styled from 'styled-components'; +import { Spacing, useToastActionContext } from '@fun-eat/design-system'; +import type { FormEventHandler } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { itemTitle, requiredMark, tagAddButton, tagButton, tagList } from './reviewRegisterForm.css'; +import ReviewTextarea from './ReviewTextarea/ReviewTextarea'; +import StarRate from './StarRate/StarRate'; import RebuyCheckbox from '../RebuyCheckbox/RebuyCheckbox'; -import ReviewTagList from '../ReviewTagList/ReviewTagList'; -import ReviewTextarea from '../ReviewTextarea/ReviewTextarea'; -import StarRate from '../StarRate/StarRate'; -import { ImageUploader, SvgIcon } from '@/components/Common'; -import { MIN_DISPLAYED_TAGS_LENGTH } from '@/constants'; -import { useFormData, useImageUploader, useScroll } from '@/hooks/common'; +import { ImageUploader, SvgIcon, Text } from '@/components/Common'; +import { MAX_DISPLAYED_TAGS_LENGTH, MIN_DISPLAYED_TAGS_LENGTH } from '@/constants'; +import type { TagValue } from '@/contexts/ReviewFormContext'; +import { useFormData, useImageUploader } from '@/hooks/common'; import { useReviewFormActionContext, useReviewFormValueContext } from '@/hooks/context'; -import { useProductDetailQuery } from '@/hooks/queries/product'; import { useReviewRegisterFormMutation } from '@/hooks/queries/review'; +import { vars } from '@/styles/theme.css'; import type { ReviewRequest } from '@/types/review'; const MIN_RATING_SCORE = 0; -const MIN_SELECTED_TAGS_COUNT = 1; const MIN_CONTENT_LENGTH = 0; interface ReviewRegisterFormProps { productId: number; - targetRef: RefObject; - closeReviewDialog: () => void; - initTabMenu: () => void; + openBottomSheet: () => void; } -const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMenu }: ReviewRegisterFormProps) => { - const { scrollToPosition } = useScroll(); +const ReviewRegisterForm = ({ productId, openBottomSheet }: ReviewRegisterFormProps) => { const { isImageUploading, previewImage, imageFile, uploadImage, deleteImage } = useImageUploader(); + const navigate = useNavigate(); const reviewFormValue = useReviewFormValueContext(); - const { resetReviewFormValue } = useReviewFormActionContext(); + const { handleReviewFormValue, resetReviewFormValue } = useReviewFormActionContext(); const { toast } = useToastActionContext(); - const { data: productDetail } = useProductDetailQuery(productId); - const { mutate, isLoading } = useReviewRegisterFormMutation(productId); + const { mutate } = useReviewRegisterFormMutation(productId); const isValid = reviewFormValue.rating > MIN_RATING_SCORE && - reviewFormValue.tagIds.length >= MIN_SELECTED_TAGS_COUNT && - reviewFormValue.tagIds.length <= MIN_DISPLAYED_TAGS_LENGTH && + reviewFormValue.tags.length >= MIN_DISPLAYED_TAGS_LENGTH && + reviewFormValue.tags.length <= MAX_DISPLAYED_TAGS_LENGTH && reviewFormValue.content.length > MIN_CONTENT_LENGTH && !isImageUploading; + const formValue: ReviewRequest = { ...reviewFormValue, tagIds: reviewFormValue.tags.map(({ id }) => id) }; + const formData = useFormData({ imageKey: 'image', imageFile: imageFile, formContentKey: 'reviewRequest', - formContent: reviewFormValue, + formContent: formValue, }); const resetAndCloseForm = () => { deleteImage(); resetReviewFormValue(); - closeReviewDialog(); + }; + + const handleTagSelect = (currentTag: TagValue) => () => { + handleReviewFormValue({ target: 'tags', value: currentTag }); }; const handleSubmit: FormEventHandler = async (event) => { @@ -63,9 +65,8 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMe mutate(formData, { onSuccess: () => { resetAndCloseForm(); - initTabMenu(); - scrollToPosition(targetRef); toast.success('📝 리뷰가 등록 됐어요'); + navigate(-1); }, onError: (error) => { resetAndCloseForm(); @@ -80,89 +81,43 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMe }; return ( - - 리뷰 작성 - - - - - - - - - 구매한 상품 사진이 있다면 올려주세요. - - - - (사진은 5MB 이하, 1장까지 업로드 할 수 있어요.) - - - - - - - - - - - - - - - [작성시 유의사항] 신뢰성 확보에 저해되는 게시물은 삭제하거나 보이지 않게 할 수 있습니다. - - - - {isValid ? '리뷰 등록하기' : '꼭 입력해야 하는 항목이 있어요'} - - - +
+
+

+ 사진 등록 +

+ +
+ + + +
+

+ 태그 + + * + +

+ +
    + {reviewFormValue.tags.map((tag) => ( +
  • + +
  • + ))} +
+
+ + + + + ); }; export default ReviewRegisterForm; - -const ReviewRegisterFormContainer = styled.div` - position: relative; - height: 100%; -`; - -const ReviewHeading = styled(Heading)` - height: 80px; - font-size: 2.4rem; - line-height: 80px; - text-align: center; -`; - -const CloseButton = styled(Button)` - position: absolute; - top: 24px; - right: 32px; -`; - -const ProductOverviewItemWrapper = styled.div` - margin: 15px 0; -`; - -const RegisterForm = styled.form` - padding: 50px 20px; -`; - -const ReviewImageUploaderContainer = styled.div` - display: flex; - flex-direction: column; - align-items: center; -`; - -const FormButton = styled(Button)` - color: ${({ theme, disabled }) => (disabled ? theme.colors.white : theme.colors.black)}; - background: ${({ theme, disabled }) => (disabled ? theme.colors.gray3 : theme.colors.primary)}; - cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; -`; diff --git a/src/components/Review/ReviewTextarea/ReviewTextarea.stories.tsx b/src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.stories.tsx similarity index 100% rename from src/components/Review/ReviewTextarea/ReviewTextarea.stories.tsx rename to src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.stories.tsx diff --git a/src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.tsx b/src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.tsx new file mode 100644 index 000000000..cde88ef9a --- /dev/null +++ b/src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import type { FocusEventHandler, ChangeEventHandler } from 'react'; + +import { + container, + currentLength, + errorMessage, + errorWrapper, + reviewTextarea, + statusWrapper, +} from './reviewTextarea.css'; +import { itemTitle, requiredMark } from '../reviewRegisterForm.css'; + +import { SvgIcon, Text } from '@/components/Common'; +import { useReviewFormActionContext } from '@/hooks/context'; + +const MIN_LENGTH = 10; +const MAX_LENGTH = 500; + +interface ReviewTextareaProps { + content: string; +} + +const ReviewTextarea = ({ content }: ReviewTextareaProps) => { + const { handleReviewFormValue } = useReviewFormActionContext(); + const [isTouched, setIsTouched] = useState(false); + + const handleReviewText: ChangeEventHandler = (event) => { + handleReviewFormValue({ target: 'content', value: event.currentTarget.value }); + }; + + const handleFocus: FocusEventHandler = () => { + setIsTouched(false); + }; + + const handleBlur: FocusEventHandler = () => { + setIsTouched(true); + }; + + const isValid = content.trim().length >= MIN_LENGTH || !isTouched; + + return ( +
+

+ 설명 + + * + +

+ +