diff --git a/public/icons/EditIcon.tsx b/public/icons/EditIcon.tsx new file mode 100644 index 00000000..da762f81 --- /dev/null +++ b/public/icons/EditIcon.tsx @@ -0,0 +1,26 @@ +import { SVGProps } from 'react'; + +interface EditIconProps extends SVGProps { + width?: number; + height?: number; +} + +function EditIcon({ width = 7, height = 9, ...props }: EditIconProps) { + return ( + + + + ); +} + +export default EditIcon; diff --git a/src/components/button/Button.tsx b/src/components/button/Button.tsx index 9f5e5478..bf36618c 100644 --- a/src/components/button/Button.tsx +++ b/src/components/button/Button.tsx @@ -2,13 +2,14 @@ import React from 'react'; import { twMerge } from 'tailwind-merge'; import { COLOR_SCHEMES, SIZE } from '@/constants'; -interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> { +export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> { text: string; size: 'large' | 'medium' | 'small' | 'modal'; fillType: 'solid' | 'outline' | 'lightSolid'; themeColor: keyof typeof COLOR_SCHEMES; lightColor?: keyof typeof COLOR_SCHEMES; isSubmitting?: boolean; + className?: string; } export default function Button({ @@ -18,6 +19,7 @@ export default function Button({ themeColor = 'green-normal-01', lightColor, isSubmitting, + className, ...buttonProps }: ButtonProps) { const { disabled } = buttonProps; @@ -54,6 +56,7 @@ export default function Button({ baseClasses, variantClasses, isButtonDisabled && 'cursor-not-allowed', + className, ); return ( diff --git a/src/components/modal/Modal.stories.tsx b/src/components/modal/Modal.stories.tsx new file mode 100644 index 00000000..7193e05e --- /dev/null +++ b/src/components/modal/Modal.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Modal from './Modal'; + +const meta: Meta = { + title: 'Components/Modal', + component: Modal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + isOpen: { + control: 'boolean', + description: '모달의 표시 여부를 제어합니다', + }, + title: { + control: 'text', + description: '모달의 제목', + }, + onClose: { action: '닫기 클릭' }, + onConfirm: { action: '확인 클릭' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true, + title: '모달 제목', + cancelText: '취소', + confirmText: '확인', + children: '모달에 넣을 children', + onClose: () => console.log('닫기 클릭'), + onConfirm: () => console.log('확인 클릭'), + cancelButtonProps: { + themeColor: 'gray-dark-01', + lightColor: 'gray-normal-01', + fillType: 'lightSolid', + }, + confirmButtonProps: { + themeColor: 'green-normal-01', + lightColor: 'green-light-03', + fillType: 'lightSolid', + }, + }, +}; diff --git a/src/components/modal/Modal.test.tsx b/src/components/modal/Modal.test.tsx new file mode 100644 index 00000000..defd2bf0 --- /dev/null +++ b/src/components/modal/Modal.test.tsx @@ -0,0 +1,56 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Modal from './Modal'; + +describe('Modal 컴포넌트', () => { + const defaultProps = { + isOpen: true, + onClose: jest.fn(), + title: '테스트 모달', + onConfirm: jest.fn(), + cancelText: '취소', + confirmText: '확인', + children:
모달 내용
, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('isOpen이 true일 때 모달이 렌더링되어야 함', () => { + render(); + + expect(screen.getByText('테스트 모달')).toBeInTheDocument(); + expect(screen.getByText('모달 내용')).toBeInTheDocument(); + expect(screen.getByText('취소')).toBeInTheDocument(); + expect(screen.getByText('확인')).toBeInTheDocument(); + }); + + it('isOpen이 false일 때 모달이 렌더링되지 않아야 함', () => { + render(); + + expect(screen.queryByText('테스트 모달')).not.toBeInTheDocument(); + }); + + it('닫기 버튼 클릭 시 onClose가 호출되어야 함', () => { + render(); + + const closeButton = screen.getByLabelText('닫기'); + fireEvent.click(closeButton); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it('취소 버튼 클릭 시 onClose가 호출되어야 함', () => { + render(); + + fireEvent.click(screen.getByText('취소')); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it('확인 버튼 클릭 시 onConfirm이 호출되어야 함', () => { + render(); + + fireEvent.click(screen.getByText('확인')); + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx new file mode 100644 index 00000000..8896b800 --- /dev/null +++ b/src/components/modal/Modal.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import Button, { ButtonProps } from '../button/Button'; +import { IcClose } from '../../../public/icons'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + title: string; + onConfirm: () => void; + cancelText: string; + confirmText: string; + cancelButtonProps?: Partial; + confirmButtonProps?: Partial; +} + +function Modal({ + isOpen, + onClose, + children, + title, + onConfirm, + cancelText, + confirmText, + cancelButtonProps, + confirmButtonProps, +}: ModalProps) { + if (!isOpen) return null; + + return ( +
+
+ +
+
+

{title}

+ +
+ +
{children}
+ +
+
+
+
+ ); +} + +export default Modal; diff --git a/src/constants/avatar.ts b/src/constants/avatar.ts index 991c7b9c..4e6f7028 100644 --- a/src/constants/avatar.ts +++ b/src/constants/avatar.ts @@ -2,6 +2,7 @@ export const AVATAR_SIZE = { sm: 'h-[29px] w-[29px]', md: 'h-[38px] w-[38px]', lg: 'h-[56px] w-[56px]', + xl: 'h-[74px] w-[71px]', } as const; export type AvatarSize = keyof typeof AVATAR_SIZE; diff --git a/src/features/profile/components/ProfileEditModal.tsx b/src/features/profile/components/ProfileEditModal.tsx new file mode 100644 index 00000000..361dfd57 --- /dev/null +++ b/src/features/profile/components/ProfileEditModal.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { useAuthStore } from '@/store/authStore'; +import Avatar from '@/components/avatar/Avatar'; +import EditIcon from '../../../../public/icons/EditIcon'; +import Modal from '@/components/modal/Modal'; + +interface ProfileData { + name: string; + companyName: string; + image?: string | null; +} + +interface ProfileEditModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (updatedData: ProfileData) => void; + profileData: ProfileData; +} + +function ProfileEditContent({ + formData, + handleChange, +}: { + formData: ProfileData; + handleChange: (e: React.ChangeEvent) => void; +}) { + return ( +
+
+
+
+ +
+ +
+ +
+
+ 닉네임 + +
+
+ 한 줄 소개 + +
+
+
+
+ ); +} + +function ProfileEditModal({ + isOpen, + onClose, + onConfirm, + profileData, +}: ProfileEditModalProps) { + const { user } = useAuthStore(); + const [formData, setFormData] = useState({ + name: profileData.name || user?.name || '', + companyName: profileData.companyName || user?.companyName || '', + image: profileData.image || user?.image || '/images/profile.png', + }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleConfirm = () => { + onConfirm(formData); + }; + + return ( + + + + ); +} + +export default ProfileEditModal; diff --git a/src/features/profile/components/WriteReviewModal.tsx b/src/features/profile/components/WriteReviewModal.tsx new file mode 100644 index 00000000..0f2ba14d --- /dev/null +++ b/src/features/profile/components/WriteReviewModal.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React, { useState } from 'react'; +import Modal from '@/components/modal/Modal'; +import RatingIcon from '../../../../public/icons/RatingIcon'; + +const INITIAL_RATING = 5; +const RATING_RANGE = [1, 2, 3, 4, 5] as const; + +interface WriteReviewContentProps { + rating: number; + setRating: (rating: number) => void; + review: string; + setReview: (review: string) => void; +} + +interface WriteReviewModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (rating: number, review: string) => void; +} +function WriteReviewContent({ + rating, + setRating, + review, + setReview, +}: WriteReviewContentProps) { + return ( +
+
+

+ 모임은 어떠셨나요? +

+
+ {RATING_RANGE.map((heart) => ( + + ))} +
+
+
+

+ 모임에 참여한 경험을 공유해주세요. +

+