diff --git a/design-system/stories/buttons/TertiaryButton.stories.tsx b/design-system/stories/buttons/TertiaryButton.stories.tsx index 3264a8cb..a199a1e7 100644 --- a/design-system/stories/buttons/TertiaryButton.stories.tsx +++ b/design-system/stories/buttons/TertiaryButton.stories.tsx @@ -31,7 +31,7 @@ const meta = { }, size: { control: 'radio', - options: ['small', 'medium', 'large'], // medium 추가 + options: ['small', 'medium', 'large', 'full'], description: '버튼 크기', defaultValue: 'large', }, @@ -88,3 +88,12 @@ export const Medium: Story = { size: 'medium', }, }; + +export const Full: Story = { + args: { + label: '가장 큰 버튼', + type: 'button', + color: 'pink', + size: 'full', + }, +}; diff --git a/design-system/ui/buttons/TertiaryButton.tsx b/design-system/ui/buttons/TertiaryButton.tsx index 03a432a9..cdb3e236 100644 --- a/design-system/ui/buttons/TertiaryButton.tsx +++ b/design-system/ui/buttons/TertiaryButton.tsx @@ -2,7 +2,7 @@ interface TertiaryButtonProps { label: string; type: 'button' | 'submit'; color: 'pink' | 'black'; - size: 'small' | 'medium' | 'large'; + size: 'small' | 'medium' | 'large' | 'full'; disabled?: boolean; onClick?: React.MouseEventHandler; className?: string; @@ -16,6 +16,7 @@ const TertiaryButton = ({ label, type, color, size, disabled, onClick, className medium: 'px-4 py-1 text-sm', large: 'text-sm sm:px-2.5 sm:py-2 sm:text-xs sm:rounded md:px-3 md:py-2.5 md:text-sm md:rounded-md lg:px-3 lg:py-2.5 lg:text-base lg:rounded-md', + full: 'w-full py-1 text-sm', }; const colorStyle = diff --git a/design-system/ui/textFields/DefaultTextField.tsx b/design-system/ui/textFields/DefaultTextField.tsx index d85bfe5d..2967cb66 100644 --- a/design-system/ui/textFields/DefaultTextField.tsx +++ b/design-system/ui/textFields/DefaultTextField.tsx @@ -14,9 +14,10 @@ interface DefaultTextFieldProps { onBlur?: (e: FocusEvent) => void; placeholder?: string; errorMessage?: string; + errorPosition?: 'bottom' | 'right'; className?: string; labelClassName?: string; - detailClassName?:string; + detailClassName?: string; disabled?: boolean; maxLength?: number; } @@ -37,7 +38,8 @@ const DefaultTextField = forwardRef( className = '', labelClassName = '', errorMessage, - detailClassName ='', + errorPosition = 'bottom', + detailClassName = '', disabled = false, maxLength, ...rest @@ -47,27 +49,43 @@ const DefaultTextField = forwardRef( return (
- -
+ +
{leftText &&
{leftText}
} - + +
+ + {errorMessage && errorPosition === 'right' && ( + <> + {/* 데스크탑일 때만 오른쪽에 보이기 */} +

+ {errorMessage} +

+ {/* 모바일에서는 아래에 표시 */} +

{errorMessage}

+ + )} +
+ {rightContent &&
{rightContent}
}
- {errorMessage &&

{errorMessage}

} + + {errorMessage && errorPosition !== 'right' &&

{errorMessage}

}
); } diff --git a/package.json b/package.json index b82ac661..6dc153f4 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@storybook/react": "^8.4.6", "@storybook/react-vite": "^8.4.6", "@storybook/test": "^8.4.6", + "@types/classnames": "^2.3.4", "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.13", "@types/node": "^22.10.5", diff --git a/public/assets/bottomBar/Arrow.svg b/public/assets/bottomBar/Arrow.svg new file mode 100644 index 00000000..fb5bcf27 --- /dev/null +++ b/public/assets/bottomBar/Arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/bottomBar/Bookmark.svg b/public/assets/bottomBar/Bookmark.svg new file mode 100644 index 00000000..83a8358c --- /dev/null +++ b/public/assets/bottomBar/Bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/bottomBar/Logout.svg b/public/assets/bottomBar/Logout.svg new file mode 100644 index 00000000..30479a16 --- /dev/null +++ b/public/assets/bottomBar/Logout.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/Layout.tsx b/src/app/Layout.tsx index 5dc72ffa..1208083a 100644 --- a/src/app/Layout.tsx +++ b/src/app/Layout.tsx @@ -1,7 +1,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { return (
-
+
{children}
diff --git a/src/entities/user/ui/BookmarkList.tsx b/src/entities/user/ui/BookmarkList.tsx new file mode 100644 index 00000000..83fc2d91 --- /dev/null +++ b/src/entities/user/ui/BookmarkList.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from 'react-router-dom'; +import bookmark from '../../../../public/assets/bottomBar/Bookmark.svg'; +import { useBookmarks } from '../../../features/bookmark/hook/useBookmarkHook'; +import { formatDate } from '../../../shared/lib/date'; +import EventInfo from './EventInfo'; + +const BookmarkList = () => { + const { data } = useBookmarks(); + const navigate = useNavigate(); + + const sortedData = data ? [...data].sort((a, b) => a.id - b.id) : []; + const visibleEvents = sortedData.slice(0, 2); + + return ( +
+
+
+ 북마크 아이콘 +

관심 이벤트

+
+ {visibleEvents.length > 0 ? ( + visibleEvents.map(event => ( + navigate(`/event-details/${event.id}`)} + /> + )) + ) : ( +

관심 있는 이벤트가 없습니다.

+ )} + navigate('/bookmark')} + className="text-center text-main cursor-pointer font-semibold text-xs md:text-sm" + > + 전체 관심 이벤트 보기 + +
+
+ ); +}; +export default BookmarkList; diff --git a/src/entities/user/ui/EventInfo.tsx b/src/entities/user/ui/EventInfo.tsx new file mode 100644 index 00000000..afcff1a9 --- /dev/null +++ b/src/entities/user/ui/EventInfo.tsx @@ -0,0 +1,21 @@ +interface EventInfoProps { + eventImageUrl: string; + title: string; + date?: string; + onClick?: () => void; +} + +const EventInfo = ({ eventImageUrl, title, date, onClick }: EventInfoProps) => { + return ( +
+ {`${title} +
+ + {title} + +
{date}
+
+
+ ); +}; +export default EventInfo; diff --git a/src/entities/user/ui/ProfileInfo.tsx b/src/entities/user/ui/ProfileInfo.tsx new file mode 100644 index 00000000..5670510e --- /dev/null +++ b/src/entities/user/ui/ProfileInfo.tsx @@ -0,0 +1,148 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { myPageSchema } from '../../../shared/lib/formValidation'; +import { formatPhoneNumber } from '../../../shared/utils/phoneFormatter'; +import ProfileCircle from '../../../../design-system/ui/Profile'; +import TertiaryButton from '../../../../design-system/ui/buttons/TertiaryButton'; +import DefaultTextField from '../../../../design-system/ui/textFields/DefaultTextField'; +import useAuthStore from '../../../app/provider/authStore'; +import { useUserInfo, useUserUpdate } from '../../../features/join/hooks/useUserHook'; +import { formatProfilName } from '../../../shared/lib/formatProfileName'; + +const ProfileInfo = () => { + const isLoggedIn = useAuthStore(state => state.isLoggedIn); + const { setName } = useAuthStore(); + const { data, isLoading, error, refetch } = useUserInfo(isLoggedIn); + const { mutate: updateUser } = useUserUpdate(); + const [isEditing, setIsEditing] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + } = useForm<{ name: string; phone: string }>({ + defaultValues: { name: data?.name || '', phone: data?.phoneNumber || '' }, + resolver: zodResolver(myPageSchema), + }); + + useEffect(() => { + if (data) { + setValue('name', data.name); + setValue('phone', data.phoneNumber); + } + }, [data, setValue]); + const handlePhoneChange = (e: React.ChangeEvent) => { + const formatted = formatPhoneNumber(e.target.value); + setValue('phone', formatted, { shouldValidate: true }); + }; + + const onSubmit: SubmitHandler<{ name: string; phone: string }> = formData => { + const { name, phone } = formData; + + if (!data?.id) { + alert('사용자 정보를 불러오는 데 실패했습니다. 다시 시도해주세요.'); + return; + } + + const updatedData = { + id: data.id, + name: name, + email: data.email, + phoneNumber: phone, + }; + + updateUser(updatedData, { + onSuccess: () => { + setName(name); + refetch(); + setIsEditing(false); + }, + onError: () => { + alert('정보 업데이트에 실패했습니다. 다시 시도해주세요.'); + }, + }); + }; + + if (isLoading) { + return
로딩 중...
; + } + if (error) { + return
정보를 불러오는데 실패했습니다. 다시 시도해주세요.
; + } + + return ( +
+
+
+
+

프로필 정보

+ {!isEditing ? ( + <> +
+ +
+ {data?.name} + {data?.phoneNumber} +
+
+ setIsEditing(true)} + /> + + ) : ( +
+
+ +
+ + +
+
+
+ { + setIsEditing(false); + setValue('name', data?.name || ''); + setValue('phone', data?.phoneNumber || ''); + }} + /> + +
+
+ )} +
+
+
+ ); +}; + +export default ProfileInfo; diff --git a/src/features/event/ui/ShareEventModal.tsx b/src/features/event/ui/ShareEventModal.tsx index f9d8031f..0e7bbe60 100644 --- a/src/features/event/ui/ShareEventModal.tsx +++ b/src/features/event/ui/ShareEventModal.tsx @@ -2,6 +2,7 @@ import link from '../../../../public/assets/event-manage/details/Link.svg'; import kakao from '../../../../public/assets/event-manage/details/KaKao.svg'; import { shareToKakao } from '../../../shared/lib/kakaoShare'; import stripHtml from '../lib/stripHtml'; +import EventInfo from '../../../entities/user/ui/EventInfo'; interface ShareEventModalProps { closeModal: () => void; @@ -71,8 +72,7 @@ const ShareEventModal = ({

공유하기

- 프로필 사진 - {title} +
diff --git a/src/pages/event/ui/AllEventsPage.tsx b/src/pages/event/ui/AllEventsPage.tsx index 156cb277..abdca6b9 100644 --- a/src/pages/event/ui/AllEventsPage.tsx +++ b/src/pages/event/ui/AllEventsPage.tsx @@ -9,6 +9,7 @@ import useAuthStore from '../../../app/provider/authStore'; import { AnimatePresence } from 'framer-motion'; import LoginModal from '../../../widgets/main/ui/LoginModal'; import ProfileCircle from '../../../../design-system/ui/Profile'; +import { formatProfilName } from '../../../shared/lib/formatProfileName'; const AllEventsPage = () => { const navigater = useNavigate(); @@ -30,7 +31,7 @@ const AllEventsPage = () => { leftButtonLabel="같이가요" rightContent={ isLoggedIn ? ( - + ) : ( ) diff --git a/src/pages/event/ui/CategoryPage.tsx b/src/pages/event/ui/CategoryPage.tsx index 89dc2c26..6e204188 100644 --- a/src/pages/event/ui/CategoryPage.tsx +++ b/src/pages/event/ui/CategoryPage.tsx @@ -9,6 +9,7 @@ import useAuthStore from '../../../app/provider/authStore'; import { AnimatePresence } from 'framer-motion'; import LoginModal from '../../../widgets/main/ui/LoginModal'; import ProfileCircle from '../../../../design-system/ui/Profile'; +import { formatProfilName } from '../../../shared/lib/formatProfileName'; const CategoryPage = () => { const navigater = useNavigate(); @@ -32,7 +33,7 @@ const CategoryPage = () => { leftButtonLabel="같이가요" rightContent={ isLoggedIn ? ( - + ) : ( ) diff --git a/src/pages/event/ui/EventDetailsPage.tsx b/src/pages/event/ui/EventDetailsPage.tsx index 8f401669..a3d9b6ba 100644 --- a/src/pages/event/ui/EventDetailsPage.tsx +++ b/src/pages/event/ui/EventDetailsPage.tsx @@ -137,26 +137,20 @@ const EventDetailsPage = () => { {event.result.referenceLinks && event.result.referenceLinks.length > 0 && ( -
-

관련 링크

-
- {event.result.referenceLinks.map((link: { title: string; url: string }, index: number) => ( - - ))} -
-
-)} - +
+

관련 링크

+
+ {event.result.referenceLinks.map((link: { title: string; url: string }) => ( + + ))} +
+
+ )}
) : ( diff --git a/src/pages/home/ui/MainPage.tsx b/src/pages/home/ui/MainPage.tsx index 9b107b11..8fd84184 100644 --- a/src/pages/home/ui/MainPage.tsx +++ b/src/pages/home/ui/MainPage.tsx @@ -17,6 +17,7 @@ import useEventList from '../../../entities/event/hook/useEventListHook'; import FloatingButton from '../../../shared/ui/FloatingButton'; import manualIcon from '../../../../public/assets/menu/help.svg'; import { USER_MANUAL_URL } from '../../../shared/types/menuType'; +import { formatProfilName } from '../../../shared/lib/formatProfileName'; const MainPage = () => { const navigate = useNavigate(); const { isModalOpen, openModal, closeModal, isLoggedIn, name } = useAuthStore(); @@ -50,7 +51,7 @@ const MainPage = () => { } onClick={() => navigate('/search')} - onChange={() => { }} + onChange={() => {}} placeholder="검색어를 입력해주세요" /> } @@ -59,7 +60,7 @@ const MainPage = () => { leftButtonLabel="같이가요" rightContent={ isLoggedIn ? ( - + ) : ( ) @@ -94,7 +95,7 @@ const MainPage = () => { 전체 이벤트 보러가기 >
- window.open(USER_MANUAL_URL, '_blank')} className='bottom-24'> + window.open(USER_MANUAL_URL, '_blank')} className="bottom-24"> 사용법
diff --git a/src/pages/menu/ui/MyPage.tsx b/src/pages/menu/ui/MyPage.tsx index fcd50ff4..08e0ff34 100644 --- a/src/pages/menu/ui/MyPage.tsx +++ b/src/pages/menu/ui/MyPage.tsx @@ -1,124 +1,77 @@ -import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; import Header from '../../../../design-system/ui/Header'; -import DefaultTextField from '../../../../design-system/ui/textFields/DefaultTextField'; -import TertiaryButton from '../../../../design-system/ui/buttons/TertiaryButton'; -import { SubmitHandler, useForm } from 'react-hook-form'; import BottomBar from '../../../widgets/main/ui/BottomBar'; -import { myPageSchema } from '../../../shared/lib/formValidation'; -import { useUserInfo, useUserUpdate } from '../../../features/join/hooks/useUserHook'; -import useAuthStore from '../../../app/provider/authStore'; +import ProfileInfo from '../../../entities/user/ui/ProfileInfo'; +import BookmarkList from '../../../entities/user/ui/BookmarkList'; +import arrow from '../../../../public/assets/bottomBar/Arrow.svg'; +import IconButton from '../../../../design-system/ui/buttons/IconButton'; +import logout from '../../../../public/assets/bottomBar/Logout.svg'; const MyPage = () => { - const { setName } = useAuthStore(); - const isLoggedIn = useAuthStore(state => state.isLoggedIn); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); - const { data, isLoading, error } = useUserInfo(isLoggedIn); - const { mutate: updateUser } = useUserUpdate(); + const handleToggle = () => setOpen(prev => !prev); + const handleLogout = () => navigate('/menu/logout'); - const [isChanged, setIsChanged] = useState(''); - - const { - register, - handleSubmit, - formState: { errors }, - setValue, - } = useForm({ - defaultValues: { name: data?.name || '', phone: data?.phoneNumber || '' }, - ...myPageSchema, - }); - useEffect(() => { - if (data) { - setValue('name', data.name); - setValue('phone', data.phoneNumber); - } - }, [data, setValue]); - - const onSubmit: SubmitHandler<{ name: string; phone: string }> = formData => { - const { name, phone } = formData; - const updatedData = { - id: data?.id || 0, - name: name || '', - email: data?.email || '', - phoneNumber: phone || '', - }; - updateUser(updatedData, { - onSuccess: () => { - setName(name); - alert('정보가 성공적으로 업데이트되었습니다.'); - }, - onError: err => { - alert('정보 업데이트에 실패했습니다. 다시 시도해주세요.'); - console.error(err); - }, - }); - // 이름과 전화번호가 변경되었는지 확인하고 메시지 설정 - let changeMessage = ''; - if (name !== data?.name && phone !== data?.phoneNumber) { - changeMessage = `${name}, ${phone}으로 변경되었습니다`; - } else if (name !== data?.name) { - changeMessage = `${name}으로 변경되었습니다`; - } else if (phone !== data?.phoneNumber) { - changeMessage = `${phone}으로 변경되었습니다`; - } - - setIsChanged(changeMessage); // 변경 상태 업데이트 - }; - if (isLoading) { - return
로딩 중...
; - } - if (error) { - return
정보를 불러오는데 실패했습니다. 다시 시도해주세요.
; - } return ( -
-
-
-
-

기본 정보

-
- 이메일 - {data?.email || '로그인이 필요합니다'} +
+
+
+ + + +
+
navigate('/menu/myTicket')} className="flex items-center justify-between cursor-pointer"> + 구매한 티켓 정보 + } + onClick={() => navigate('/menu/myTicket')} + />
-
-
-
-
- - {errors.name && {errors.name.message}} -
-
- + +
+
+ 문의하기 + + } + onClick={handleToggle} /> - {errors.phone && {errors.phone.message}}
+ {open && ( +
+ 이메일 주소로 문의 부탁드립니다 +
+ + gotogether@gmail.com + +
+ )}
- {isChanged && {isChanged}} - - +
- {!isLoggedIn &&

로그인 후 정보를 수정하실 수 있습니다.

} +
+ 로그아웃 + } onClick={handleLogout} /> +
+
- {/* */}
); diff --git a/src/shared/lib/formatProfileName.ts b/src/shared/lib/formatProfileName.ts new file mode 100644 index 00000000..f05e5b56 --- /dev/null +++ b/src/shared/lib/formatProfileName.ts @@ -0,0 +1,4 @@ +export const formatProfilName = (name: string | undefined): string => { + if (!name) return ''; + return name.length > 2 ? name.slice(1, 3) : name.slice(0, 2); +}; diff --git a/src/shared/ui/FloatingButton.tsx b/src/shared/ui/FloatingButton.tsx index 75530535..a8bee762 100644 --- a/src/shared/ui/FloatingButton.tsx +++ b/src/shared/ui/FloatingButton.tsx @@ -8,12 +8,7 @@ interface FloatingButtonProps { ariaLabel: string; } -const FloatingButton = ({ - onClick, - children, - className, - ariaLabel, -}: FloatingButtonProps) => { +const FloatingButton = ({ onClick, children, className, ariaLabel }: FloatingButtonProps) => { return ( {children} diff --git a/tailwind.config.js b/tailwind.config.js index 8482c013..f6d522ee 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -67,6 +67,7 @@ export default { gradation: '#FF7577', dropdown: 'rgba(255, 85, 147, 0.05)', // 드롭다운 선택배경 dashboardBg: '#FFFCFC', // 대시보드 배경 + myPageBg: '#F9FAFB', // 마이페이지 배경 text: '#000000', // 텍스트, 로그인과 구매하기 버튼, 메인 검색창 구분선 placeholderText: '#A1A1A1', // 텍스트(플레이스홀더), 이벤트정보카드 라인, 라인(작성칸) deDayBg: 'rgba(254, 183, 128, 0.60)', // 디데이(배경) diff --git a/yarn.lock b/yarn.lock index 02158b63..f3fae70b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1072,6 +1072,13 @@ dependencies: "@babel/types" "^7.20.7" +"@types/classnames@^2.3.4": + version "2.3.4" + resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.3.4.tgz#1a1fdf5023ef216219f13e702543f9ce9b394560" + integrity sha512-dwmfrMMQb9ujX1uYGvB5ERDlOzBNywnZAZBtOe107/hORWP05ESgU4QyaanZMWYYfd2BzrG78y13/Bju8IQcMQ== + dependencies: + classnames "*" + "@types/cookie@^0.6.0": version "0.6.0" resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz" @@ -1627,7 +1634,7 @@ chromatic@^11.15.0, chromatic@^11.22.0: resolved "https://registry.npmjs.org/chromatic/-/chromatic-11.27.0.tgz" integrity sha512-jQ2ufjS+ePpg+NtcPI9B2eOi+pAzlRd2nhd1LgNMsVCC9Bzf5t8mJtyd8v2AUuJS0LdX0QVBgkOnlNv9xviHzA== -classnames@^2.5.1: +classnames@*, classnames@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== @@ -3480,16 +3487,8 @@ storybook@^8.4.6: dependencies: "@storybook/core" "8.6.9" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3507,14 +3506,8 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + name strip-ansi-cjs version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==