diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/package.json b/package.json index 76b2ac30..4334a658 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "api:logs": "docker-compose -f ../study-platform-mvp/docker-compose.yml logs -f mvp-app" }, "dependencies": { + "@hookform/resolvers": "^5.2.1", "@next/third-parties": "^15.3.3", "@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-dialog": "^1.1.10", @@ -37,10 +38,12 @@ "react": "^19.0.0", "react-day-picker": "9.4.3", "react-dom": "^19.0.0", + "react-hook-form": "^7.62.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.0.6", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", + "zod": "^4.0.17", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/public/profile-default.svg b/src/entities/user/ui/icon/profile-default.svg similarity index 74% rename from public/profile-default.svg rename to src/entities/user/ui/icon/profile-default.svg index 04ddfe2c..5e77401f 100644 --- a/public/profile-default.svg +++ b/src/entities/user/ui/icon/profile-default.svg @@ -1,12 +1,12 @@ - + - + - + - + diff --git a/src/features/study/ui/my-profile-card.tsx b/src/entities/user/ui/my-profile-card.tsx similarity index 100% rename from src/features/study/ui/my-profile-card.tsx rename to src/entities/user/ui/my-profile-card.tsx diff --git a/src/features/my-page/ui/user-profile-modal.tsx b/src/entities/user/ui/user-profile-modal.tsx similarity index 100% rename from src/features/my-page/ui/user-profile-modal.tsx rename to src/entities/user/ui/user-profile-modal.tsx diff --git a/src/features/auth/ui/login-modal.tsx b/src/features/auth/ui/login-modal.tsx index 0a53946d..fbdf95cd 100644 --- a/src/features/auth/ui/login-modal.tsx +++ b/src/features/auth/ui/login-modal.tsx @@ -36,7 +36,6 @@ export default function LoginModal({ ? '616205933420-b45d510q23togkaqo069j8igmsjhp9v0.apps.googleusercontent.com' : process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; - const NAVER_LOGIN_URL = ''; const KAKAO_LOGIN_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${API_BASE_URL}/api/v1/auth/kakao/redirect-uri&response_type=code&state=${state}`; const GOOGLE_LOGIN_URL = `https://accounts.google.com/o/oauth2/v2/auth?scope=openid%20profile&access_type=offline&prompt=consent&include_granted_scopes=true&response_type=code&redirect_uri=${API_BASE_URL}/api/v1/auth/google/redirect-uri&client_id=${GOOGLE_CLIENT_ID}&state=${state}`; diff --git a/src/features/auth/ui/sign-up-image-selector.tsx b/src/features/auth/ui/sign-up-image-selector.tsx index 8bd21034..8055000a 100644 --- a/src/features/auth/ui/sign-up-image-selector.tsx +++ b/src/features/auth/ui/sign-up-image-selector.tsx @@ -1,6 +1,6 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { PencilIcon } from 'lucide-react'; -import Image from 'next/image'; +import UserAvatar from '@/shared/ui/avatar'; export default function SignupImageSelector({ image, @@ -8,19 +8,17 @@ export default function SignupImageSelector({ fileInputRef, handleImageChange, }: { - image: string; - setImage: (image: string) => void; + image?: string; + setImage: (image?: string) => void; fileInputRef: React.RefObject; handleImageChange: (event: React.ChangeEvent) => void; }) { - const setDefaultImage = () => setImage('/profile-default.svg'); + const setDefaultImage = () => setImage(undefined); const openFileFolder = () => fileInputRef.current?.click(); return (
-
- 프로필 -
+
diff --git a/src/features/my-page/ui/profile-info-edit-modal.tsx b/src/features/my-page/ui/profile-info-edit-modal.tsx index f1ed5a2b..e3e856b5 100644 --- a/src/features/my-page/ui/profile-info-edit-modal.tsx +++ b/src/features/my-page/ui/profile-info-edit-modal.tsx @@ -1,14 +1,26 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import { sendGTMEvent } from '@next/third-parties/google'; import { XIcon } from 'lucide-react'; -import { useState } from 'react'; -import { MemberInfo } from '@/entities/user/api/types'; +import { useMemo, useState } from 'react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; + +import type { MemberInfo } from '@/entities/user/api/types'; import { hashValue } from '@/shared/lib/hash'; import Button from '@/shared/ui/button'; -import { FormField } from '@/shared/ui/form/form-field'; +import { MultiDropdown, SingleDropdown } from '@/shared/ui/dropdown'; +import FormField from '@/shared/ui/form/form-field'; +import { TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { UpdateUserProfileInfoRequest } from '../api/types'; +import { ToggleGroup } from '@/shared/ui/toggle'; + +import { + ProfileInfoFormSchema, + type ProfileInfoFormValues, + buildProfileInfoDefaultValues, + toUpdateUserProfileInfoRequest, +} from '../model/profile-info-form.schema'; import { useAvailableStudyTimesQuery, useStudySubjectsQuery, @@ -22,7 +34,7 @@ interface Props { } export default function ProfileInfoEditModal({ memberId, memberInfo }: Props) { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); return ( @@ -31,6 +43,7 @@ export default function ProfileInfoEditModal({ memberId, memberInfo }: Props) { 편집 + @@ -42,6 +55,7 @@ export default function ProfileInfoEditModal({ memberId, memberInfo }: Props) { + void; }) { - const [infoForm, setInfoForm] = useState({ - selfIntroduction: memberInfo.selfIntroduction ?? '', - studyPlan: memberInfo.studyPlan ?? '', - preferredStudySubjectId: memberInfo.preferredStudySubject?.studySubjectId, - availableStudyTimeIds: (memberInfo.availableStudyTimes ?? []).map( - (time) => time?.id ?? 0, - ), - techStackIds: (memberInfo.techStacks ?? []).map( - (tech) => tech?.techStackId ?? 0, - ), - }); - - const { data: availableStudyTimes } = useAvailableStudyTimesQuery(); - const { data: studySubjects } = useStudySubjectsQuery(); - const { data: techStacks } = useTechStacksQuery(); + const { data: availableStudyTimes = [] } = useAvailableStudyTimesQuery(); + const { data: studySubjects = [] } = useStudySubjectsQuery(); + const { data: techStacks = [] } = useTechStacksQuery(); const { mutate: updateProfileInfo } = useUpdateUserProfileInfoMutation(memberId); - const handleSubmit = () => { - const formData: UpdateUserProfileInfoRequest = { - selfIntroduction: infoForm.selfIntroduction, - studyPlan: infoForm.studyPlan, - preferredStudySubjectId: infoForm.preferredStudySubjectId, - availableStudyTimeIds: infoForm.availableStudyTimeIds - .filter((id) => id) - .map((id) => Number(id)), - techStackIds: infoForm.techStackIds - .filter((id) => id) - .map((id) => Number(id)), - }; + const methods = useForm({ + resolver: zodResolver(ProfileInfoFormSchema), + mode: 'onChange', + defaultValues: buildProfileInfoDefaultValues(memberInfo), + }); + + const { + handleSubmit, + control, + formState: { isValid, isSubmitting }, + } = methods; + + const preferredStudySubjectId = useWatch({ + control, + name: 'preferredStudySubjectId', + }); + const subjectOk = Boolean(preferredStudySubjectId); + const isDisabled = !isValid || isSubmitting || !subjectOk; + + const onValidSubmit = (values: ProfileInfoFormValues) => { + const formData = toUpdateUserProfileInfoRequest(values); updateProfileInfo(formData, { onSuccess: () => { - const selectedSkillNames = techStacks.filter((techStack) => - infoForm.techStackIds.includes(techStack.techStackId), - ); + const selectedNames = + techStacks + .filter((t) => values.techStackIds?.includes(String(t.techStackId))) + .map((t) => t.techStackName) ?? []; sendGTMEvent({ event: 'custom_member_card', dl_timestamp: new Date().toISOString(), dl_member_id: hashValue(String(memberId)), - dl_tags: selectedSkillNames, + dl_tags: selectedNames, }); onClose(); @@ -111,118 +123,122 @@ function ProfileInfoEditForm({ }); }; + const subjectOptions = useMemo( + () => + studySubjects.map(({ studySubjectId, name }) => ({ + value: String(studySubjectId), + label: name, + })), + [studySubjects], + ); + + const timeOptions = useMemo( + () => + availableStudyTimes.map(({ availableTimeId, display }) => ({ + value: String(availableTimeId), + label: display, + })), + [availableStudyTimes], + ); + + const techOptions = useMemo( + () => + techStacks.map(({ techStackId, techStackName }) => ({ + value: String(techStackId), + label: techStackName, + })), + [techStacks], + ); + return ( <> -
- - setInfoForm((prev) => ({ - ...prev, - selfIntroduction: value, - })) - } - direction="vertical" - maxLength={500} - /> - - setInfoForm((prev) => ({ - ...prev, - studyPlan: value, - })) - } - direction="vertical" - maxLength={500} - required - /> - - setInfoForm((prev) => ({ - ...prev, - preferredStudySubjectId: value, - })) - } - direction="vertical" - required - options={ - studySubjects?.map(({ studySubjectId, name }) => ({ - value: studySubjectId, - label: name, - })) ?? [] - } - /> - ({ - value: availableTimeId.toString(), - label: display, - })) ?? [] - } - onChange={(availableStudyTimeIds) => - setInfoForm((prev) => ({ - ...prev, - availableStudyTimeIds: availableStudyTimeIds.map(Number), - })) - } - required - /> - - setInfoForm((prev) => ({ - ...prev, - techStackIds: value, - })) - } - direction="vertical" - required - options={(techStacks ?? []).map( - ({ techStackId, techStackName }) => ({ - value: techStackId, - label: techStackName, - }), - )} - /> -
+ +
+ + name="selfIntroduction" + label="자기소개" + description="간단한 자기소개를 입력해 주세요." + direction="vertical" + showCounterRight + counterMax={500} + > + + + + + name="studyPlan" + label="공부 주제 및 계획" + description="스터디에서 다루고 싶은 주제와 학습 목표를 알려주세요." + direction="vertical" + showCounterRight + counterMax={500} + required + > + + + + + name="preferredStudySubjectId" + label="선호하는 스터디 주제" + description="관심있는 스터디 유형을 선택해주세요." + direction="vertical" + required + > + + + + + name="availableStudyTimeIds" + label="가능 시간대" + helper="스터디 참여가 가능한 시간대를 모두 선택해 주세요." + direction="vertical" + required + > + + + + + name="techStackIds" + label="사용 가능한 기술 스택" + helper="현재 본인이 사용할 수 있는 기술 스택을 모두 선택해 주세요." + direction="vertical" + required + > + + + +
+
-
diff --git a/src/features/study/model/interview.schema.ts b/src/features/study/model/interview.schema.ts new file mode 100644 index 00000000..4f8bef55 --- /dev/null +++ b/src/features/study/model/interview.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { UrlSchema } from '@/shared/util/zod-schema'; +import type { DailyStudyDetail, StudyProgressStatus } from '../api/types'; +import { STUDY_PROGRESS_OPTIONS } from '../consts/study-const'; + +// 스터디 준비 스키마 +export const StudyReadyFormSchema = z.object({ + subject: z.string().trim().min(1, '면접 주제를 입력해 주세요.'), + link: UrlSchema, +}); + +export type StudyReadyFormValues = z.infer; + +export function buildStudyReadyDefaults( + d: DailyStudyDetail, +): StudyReadyFormValues { + return { + subject: d.subject ?? '', + link: d.link ?? '', + }; +} + +// 스터디 완료 스키마 +const STUDY_PROGRESS_VALUES = STUDY_PROGRESS_OPTIONS.map((o) => o.value) as [ + string, + ...string[], +]; + +export const StudyDoneFormSchema = z.object({ + progressStatus: z.enum(STUDY_PROGRESS_VALUES, { + message: '진행 현황을 선택해 주세요.', + }), + feedback: z + .string() + .trim() + .min(1, '피드백을 입력해 주세요.') + .max(100, '최대 100자까지 입력 가능합니다.'), +}); + +export type StudyDoneFormValues = z.infer; + +export function buildStudyDoneDefaults( + d: DailyStudyDetail, +): StudyDoneFormValues { + return { + progressStatus: (d.progressStatus ?? 'PENDING') as StudyProgressStatus, + feedback: d.feedback ?? '', + }; +} diff --git a/src/features/study/participation/api/get-participation-data.ts b/src/features/study/participation/api/get-participation-data.ts new file mode 100644 index 00000000..82791ff4 --- /dev/null +++ b/src/features/study/participation/api/get-participation-data.ts @@ -0,0 +1,44 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { + WeeklyReservationRequest, + WeeklyReservationResponse, + ReservationUserItem, + Participant, +} from './participation-types'; + +export function mapReservation(user: ReservationUserItem): Participant { + const original = user.profileImage?.resizedImages.find( + (img) => img.imageSizeType.imageTypeName === 'ORIGINAL', + )?.resizedImageUrl; + + return { + id: user.memberId, + name: user.memberName, + avatarUrl: original ?? null, + simpleIntroduction: user.simpleIntroduction, + }; +} + +export const getReservationMembers = async ( + params: WeeklyReservationRequest, +): Promise => { + const { cursor, pageSize = 50, firstMemberId } = params; + + const res = await axiosInstance.get('/members/study-reservation', { + params: { + ...(cursor !== null ? { cursor } : {}), + 'page-size': pageSize, + ...(firstMemberId !== null ? { 'first-member-id': firstMemberId } : {}), + }, + }); + + return res.data.content; +}; + +export type StudyStatus = 'RECRUITING' | 'STUDYING'; + +export const getStudyStatus = async (): Promise => { + const res = await axiosInstance.get('/matching/system-status'); + + return res.data.content.status as StudyStatus; +}; diff --git a/src/features/study/participation/api/participation-types.ts b/src/features/study/participation/api/participation-types.ts new file mode 100644 index 00000000..0a5eb343 --- /dev/null +++ b/src/features/study/participation/api/participation-types.ts @@ -0,0 +1,39 @@ +export interface ReservationUserItem { + memberId: number; + memberName: string; + profileImage?: { + imageId: number; + resizedImages: { + resizedImageId: number; + resizedImageUrl: string; + imageSizeType: { + imageTypeName: string; + width?: number; + height?: number; + }; + }[]; + }; + simpleIntroduction?: string; +} + +export interface WeeklyReservationResponse { + totalMemberCount: number; + members: { + nextCursor?: number; + hasNext: boolean; + items: ReservationUserItem[]; + }; +} + +export interface Participant { + id: number; + name: string; + avatarUrl?: string; + simpleIntroduction?: string; +} + +export interface WeeklyReservationRequest { + cursor?: number; + pageSize?: number; + firstMemberId?: number; +} diff --git a/src/features/study/participation/model/start-study-form.schema.ts b/src/features/study/participation/model/start-study-form.schema.ts new file mode 100644 index 00000000..5f30714c --- /dev/null +++ b/src/features/study/participation/model/start-study-form.schema.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { UrlSchema } from '@/shared/util/zod-schema'; +import { JoinStudyRequest } from '../../api/types'; + +export const StartStudyFormSchema = z.object({ + selfIntroduction: z + .string() + .trim() + .min(1, '자기소개를 입력해 주세요.') + .max(500, '최대 500자까지 입력 가능합니다.'), + studyPlan: z + .string() + .trim() + .min(1, '공부 계획을 입력해 주세요.') + .max(500, '최대 500자까지 입력 가능합니다.'), + tel: z + .string() + .trim() + .regex( + /^\d{2,3}-\d{3,4}-\d{4}$/, + '연락처 형식이 올바르지 않습니다. (예: 010-1234-5678)', + ), + githubLink: UrlSchema.optional().transform((v) => (v === '' ? undefined : v)), + blogOrSnsLink: UrlSchema.optional().transform((v) => + v === '' ? undefined : v, + ), + + preferredStudySubjectId: z + .string() + .min(1, '선호하는 스터디 주제를 선택해 주세요.'), + + availableStudyTimeIds: z + .array(z.string()) + .min(1, '가능 시간대를 1개 이상 선택해 주세요.'), + techStackIds: z + .array(z.string()) + .min(1, '기술 스택을 1개 이상 선택해 주세요.'), +}); + +export type StartStudyFormValues = z.infer; + +export function buildStartStudyDefaultValues(): StartStudyFormValues { + return { + selfIntroduction: '', + studyPlan: '', + tel: '', + githubLink: '', + blogOrSnsLink: '', + preferredStudySubjectId: '', + availableStudyTimeIds: [], + techStackIds: [], + }; +} + +export function toJoinStudyRequest( + memberId: number, + v: StartStudyFormValues, +): JoinStudyRequest { + const github = v.githubLink?.trim(); + const blog = v.blogOrSnsLink?.trim(); + + return { + memberId, + selfIntroduction: v.selfIntroduction.trim(), + studyPlan: v.studyPlan.trim(), + tel: v.tel.trim(), + githubLink: github ? github : undefined, + blogOrSnsLink: blog ? blog : undefined, + preferredStudySubjectId: v.preferredStudySubjectId, + availableStudyTimeIds: v.availableStudyTimeIds.map(Number), + techStackIds: v.techStackIds.map(Number), + }; +} diff --git a/src/features/study/participation/model/use-participation-query.ts b/src/features/study/participation/model/use-participation-query.ts new file mode 100644 index 00000000..c4d46719 --- /dev/null +++ b/src/features/study/participation/model/use-participation-query.ts @@ -0,0 +1,54 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { + getReservationMembers, + getStudyStatus, + mapReservation, + StudyStatus, +} from '../api/get-participation-data'; +import { WeeklyReservationResponse } from '../api/participation-types'; + +export function useInfiniteReservation(firstMemberId?: number, pageSize = 50) { + return useInfiniteQuery({ + queryKey: ['weeklyReservationMembers', { firstMemberId, pageSize }], + initialPageParam: { cursor: null as number | null }, + queryFn: async ({ pageParam }) => { + return getReservationMembers({ + cursor: pageParam?.cursor ?? null, + pageSize, + firstMemberId, + }); + }, + + getNextPageParam: (lastPage?: WeeklyReservationResponse) => { + if (lastPage?.members?.hasNext) { + return { cursor: lastPage.members.nextCursor }; + } + + return undefined; + }, + select: (data) => { + const pages = data?.pages ?? []; + const items = pages.flatMap((p) => + (p?.members?.items ?? []).map(mapReservation), + ); + const total = pages[0]?.totalMemberCount ?? items.length; + const last = pages[pages.length - 1]; + const hasNextPage = last?.members?.hasNext ?? false; + + return { + items, + total, + hasNextPage, + }; + }, + staleTime: 60 * 1000, + }); +} + +export const useStudyStatusQuery = () => { + return useQuery({ + queryKey: ['studyStatus'], + queryFn: getStudyStatus, + staleTime: 60 * 1000, + }); +}; diff --git a/src/features/study/participation/ui/reservation-list.tsx b/src/features/study/participation/ui/reservation-list.tsx new file mode 100644 index 00000000..284e9206 --- /dev/null +++ b/src/features/study/participation/ui/reservation-list.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { ChevronRight } from 'lucide-react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + usePatchAutoMatchingMutation, + useUserProfileQuery, +} from '@/entities/user/model/use-user-profile-query'; +import ProfileDefault from '@/entities/user/ui/icon/profile-default.svg'; +import { getCookie } from '@/shared/tanstack-query/cookie'; +import ReservationCard from './reservation-user-card'; +import StartStudyModal from '../../ui/start-study-modal'; +import { useInfiniteReservation } from '../model/use-participation-query'; + +interface ReservationListProps { + isParticipation?: boolean; + pageSize?: number; + month: number; + week: number; +} + +export default function ReservationList({ + isParticipation = false, + pageSize = 50, + month, + week, +}: ReservationListProps) { + const sentinelRef = useRef(null); + const calledRef = useRef(false); + const [memberId, setMemberId] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + const id = getCookie('memberId'); + setMemberId(id ? Number(id) : null); + }, []); + + const firstMemberId = useMemo( + () => (isParticipation && memberId !== null ? memberId : null), + [isParticipation, memberId], + ); + + const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteReservation(firstMemberId, pageSize); + + const { data: userProfile } = useUserProfileQuery(memberId ?? 0); + + const { mutate: patchAutoMatching, isPending } = + usePatchAutoMatchingMutation(); + + useEffect(() => { + if (!memberId || isParticipation || !userProfile) return; + + const { studyApplied, autoMatching } = userProfile; + + if (studyApplied && !autoMatching && !calledRef.current) { + calledRef.current = true; + patchAutoMatching( + { memberId, autoMatching: true }, + { + onError: () => { + calledRef.current = false; + }, + }, + ); + } + }, [memberId, isParticipation, userProfile, patchAutoMatching]); + + useEffect(() => { + if (!hasNextPage) return; + + const targetElement = sentinelRef.current; + if (!targetElement) return; + + const observer = new IntersectionObserver( + async (entries) => { + const isVisible = entries.some((entry) => entry.isIntersecting); + if (isVisible && hasNextPage && !isFetchingNextPage) { + await fetchNextPage(); + } + }, + { rootMargin: '200px 0px' }, + ); + + observer.observe(targetElement); + + return () => { + observer.disconnect(); + }; + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const items = data?.items ?? []; + const studyApplied = userProfile?.studyApplied ?? false; + + const handleApplyClick = () => { + if (!memberId) return; + + if (studyApplied) { + if (isPending) return; + patchAutoMatching({ memberId, autoMatching: true }); + } else { + setIsModalOpen(true); + } + }; + + if (isLoading) { + return ( +
+ 불러오는 중… +
+ ); + } + + return ( +
+
+
+ {`${month}월 ${week}주차 스터디 신청 목록`} +
+
+ 총 {data?.total}명 +
+
+ +
+ {items.map((p) => ( + + ))} + + {!isParticipation && ( +
+ +
+ + {`${month}월 ${week}주차 스터디 신청하기`} + + + 지금 신청하면 함께할 수 있어요 + +
+ +
+ )} +
+ +
+ {isFetchingNextPage && ( +
더 불러오는 중…
+ )} +
+ + {memberId && ( + + )} +
+ ); +} diff --git a/src/features/study/participation/ui/reservation-user-card.tsx b/src/features/study/participation/ui/reservation-user-card.tsx new file mode 100644 index 00000000..4e23e6b3 --- /dev/null +++ b/src/features/study/participation/ui/reservation-user-card.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import UserAvatar from '@/shared/ui/avatar'; +import Badge from '@/shared/ui/badge'; +import { Participant } from '../api/participation-types'; + +interface ReservationCardProps { + participant: Participant; + isCurrentUser?: boolean; +} + +export default function ReservationCard({ + participant, + isCurrentUser = false, +}: ReservationCardProps) { + return ( +
+ +
+
+
{participant.name}
+ {isCurrentUser && 본인} +
+
+ {participant.simpleIntroduction} +
+
+ + 프로필 +
+ } + /> + + ); +} diff --git a/src/features/study/ui/start-study-modal.tsx b/src/features/study/ui/start-study-modal.tsx index 4c3a190e..1330e84d 100644 --- a/src/features/study/ui/start-study-modal.tsx +++ b/src/features/study/ui/start-study-modal.tsx @@ -1,34 +1,39 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import { XIcon } from 'lucide-react'; -import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useMemo } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + import { useAvailableStudyTimesQuery, useStudySubjectsQuery, useTechStacksQuery, } from '@/features/my-page/model/use-update-user-profile-mutation'; -import { cn } from '@/shared/shadcn/lib/utils'; + import Button from '@/shared/ui/button'; import { SingleDropdown, MultiDropdown } from '@/shared/ui/dropdown'; -import { BaseInput } from '@/shared/ui/input'; +import FormField from '@/shared/ui/form/form-field'; +import { BaseInput, TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { ToggleButton } from '@/shared/ui/toggle'; -import { JoinStudyRequest } from '../api/types'; + +import { ToggleGroup } from '@/shared/ui/toggle'; import { studySteps } from '../consts/study-const'; + import { useJoinStudyMutation } from '../model/use-study-query'; +import { + StartStudyFormSchema, + type StartStudyFormValues, + buildStartStudyDefaultValues, + toJoinStudyRequest, +} from '../participation/model/start-study-form.schema'; interface StartStudyModalProps { memberId: number; -} - -interface LabeledFieldProps { - label: string; - required?: boolean; - description?: string; - children: React.ReactNode; - className?: string; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } interface NumberedBulletSectionProps { @@ -42,7 +47,7 @@ function NumberedBulletSection({ title, items }: NumberedBulletSectionProps) {
{title}
    - {items.map((item: string, idx: number) => ( + {items.map((item, idx) => (
  • {item}
  • @@ -53,51 +58,15 @@ function NumberedBulletSection({ title, items }: NumberedBulletSectionProps) { ); } -export function LabeledField({ - label, - required, - description, - children, - className, -}: LabeledFieldProps) { - return ( -
    - - {description && ( - - {description} - - )} - {children} -
    - ); -} - -type JoinStudyFormError = { - [K in keyof Omit< - JoinStudyRequest, - 'memberId' | 'githubLink' | 'blogOrSnsLink' - >]: boolean; -}; - -export default function StartStudyModal({ memberId }: StartStudyModalProps) { +export default function StartStudyModal({ + memberId, + trigger, + open, + onOpenChange, +}: StartStudyModalProps) { return ( - - - 스터디 시작 버튼 - + + {trigger ? {trigger} : null} @@ -110,100 +79,77 @@ export default function StartStudyModal({ memberId }: StartStudyModalProps) { - + onOpenChange?.(false)} + /> ); } -function StartStudyForm({ memberId }: StartStudyModalProps) { - const [form, setForm] = useState>({ - selfIntroduction: '', - studyPlan: '', - tel: '', - githubLink: '', - blogOrSnsLink: '', - preferredStudySubjectId: undefined, - availableStudyTimeIds: [], - techStackIds: [], - }); +function StartStudyForm({ + memberId, + onClose, +}: { + memberId: number; + onClose: () => void; +}) { + const router = useRouter(); + const { data: availableStudyTimes = [] } = useAvailableStudyTimesQuery(); + const { data: studySubjects = [] } = useStudySubjectsQuery(); + const { data: techStacks = [] } = useTechStacksQuery(); + const { mutate: joinStudy } = useJoinStudyMutation(); - const [error, setError] = useState({ - selfIntroduction: false, - studyPlan: false, - tel: false, - preferredStudySubjectId: false, - availableStudyTimeIds: false, - techStackIds: false, + const methods = useForm({ + resolver: zodResolver(StartStudyFormSchema), + mode: 'onChange', + defaultValues: buildStartStudyDefaultValues(), }); - const { - selfIntroduction, - studyPlan, - tel, - githubLink, - blogOrSnsLink, - preferredStudySubjectId, - availableStudyTimeIds, - } = form; + const { handleSubmit } = methods; - const { data: availableStudyTimes } = useAvailableStudyTimesQuery(); - const { data: studySubjects } = useStudySubjectsQuery(); - const { data: techStacks } = useTechStacksQuery(); - const router = useRouter(); - - const { mutate: joinStudy } = useJoinStudyMutation(); - - const toggleStudyTime = (id: number) => { - setForm((prev) => - prev.availableStudyTimeIds.includes(id) - ? { - ...prev, - availableStudyTimeIds: prev.availableStudyTimeIds.filter( - (item) => item !== id, - ), - } - : { - ...prev, - availableStudyTimeIds: [...prev.availableStudyTimeIds, id], - }, - ); - }; + const subjectOptions = useMemo( + () => + studySubjects.map(({ studySubjectId, name }) => ({ + value: String(studySubjectId), + label: name, + })), + [studySubjects], + ); - const handleSubmit = () => { - const newError: JoinStudyFormError = { - selfIntroduction: selfIntroduction.trim() === '', - studyPlan: studyPlan.trim() === '', - tel: !/^\d{2,3}-\d{3,4}-\d{4}$/.test(tel), - preferredStudySubjectId: preferredStudySubjectId === undefined, - availableStudyTimeIds: availableStudyTimeIds.length === 0, - techStackIds: form.techStackIds.length === 0, - }; + const timeOptions = useMemo( + () => + availableStudyTimes.map(({ availableTimeId, display }) => ({ + value: String(availableTimeId), + label: display, + })), + [availableStudyTimes], + ); - if (Object.values(newError).some(Boolean)) { - setError(newError); + const techOptions = useMemo( + () => + techStacks.map(({ techStackId, techStackName }) => ({ + value: String(techStackId), + label: techStackName, + })), + [techStacks], + ); - return; - } + const onValidSubmit = (values: StartStudyFormValues) => { + const body = toJoinStudyRequest(memberId, values); - joinStudy( - { - ...form, - memberId, - githubLink: githubLink.trim() || undefined, - blogOrSnsLink: blogOrSnsLink.trim() || undefined, + joinStudy(body, { + onSuccess: () => { + alert('스터디 신청이 완료되었습니다!'); + onClose(); + router.refresh(); }, - { - onSuccess: () => { - alert('스터디 신청이 완료되었습니다!'); - router.refresh(); - }, - onError: () => { - alert('스터디 신청 중 오류가 발생했습니다. 다시 시도해 주세요.'); - }, + onError: () => { + alert('스터디 신청 중 오류가 발생했습니다. 다시 시도해 주세요.'); }, - ); + }); }; return ( @@ -220,177 +166,105 @@ function StartStudyForm({ memberId }: StartStudyModalProps) {
    -
    - +
    - { - setForm((prev) => ({ - ...prev, - selfIntroduction: e.target.value, - })); - setError((prev) => ({ - ...prev, - selfIntroduction: e.target.value.trim() === '', - })); - }} - /> - + + name="selfIntroduction" + label="자기 소개" + helper="간단한 자기소개를 입력해 주세요." + direction="vertical" + required + > + + - - { - setForm((prev) => ({ - ...prev, - studyPlan: e.target.value, - })); - setError((prev) => ({ - ...prev, - studyPlan: e.target.value.trim() === '', - })); - }} - /> - + + name="studyPlan" + label="공부 주제 및 계획" + helper="스터디에서 다루고 싶은 주제와 학습 목표를 알려주세요." + direction="vertical" + required + > + + - - ({ - value: studySubjectId, - label: name, - }), - )} - placeholder="선택하세요" - onChange={(value) => { - setForm((prev) => ({ - ...prev, - preferredStudySubjectId: value.toString(), - })); - setError((prev) => ({ - ...prev, - preferredStudySubjectId: value === undefined, - })); - }} - /> - + + name="tel" + label="연락처" + helper="스터디 진행을 위해 연락 가능한 정보를 입력해 주세요. 입력하신 정보는 매칭된 스터디원에게만 제공되며, 외부에는 노출되지 않습니다." + direction="vertical" + required + > + + - -
    - {(availableStudyTimes ?? []).map( - ({ availableTimeId, display }) => ( - toggleStudyTime(availableTimeId)} - > - {display} - - ), - )} -
    -
    + + name="preferredStudySubjectId" + label="선호하는 스터디 주제" + helper="관심 있는 스터디 유형을 선택해 주세요." + direction="vertical" + required + > + + - - ({ - value: techStackId, - label: techStackName, - }), - )} - onChange={(newSelected) => { - setForm((prev) => ({ - ...prev, - techStackIds: newSelected as number[], - })); - setError((prev) => ({ - ...prev, - techStackIds: newSelected.length === 0, - })); - }} - placeholder="기술을 선택해주세요" - /> - + + name="availableStudyTimeIds" + label="가능 시간대" + helper="스터디 참여가 가능한 시간대를 모두 선택해 주세요." + direction="vertical" + required + > + + - - { - setForm((prev) => ({ - ...prev, - tel: e.target.value, - })); - setError((prev) => ({ - ...prev, - tel: !/^\d{2,3}-\d{3,4}-\d{4}$/.test(e.target.value), - })); - }} - /> - + + name="techStackIds" + label="사용 가능한 기술 스택" + helper="현재 본인이 사용할 수 있는 기술 스택을 모두 선택해 주세요." + direction="vertical" + required + > + + - - - setForm((prev) => ({ ...prev, githubLink: e.target.value })) - } - /> - + + name="githubLink" + label="GitHub" + helper="본인의 활동을 확인할 수 있는 GitHub 링크를 입력해 주세요." + direction="vertical" + > + + - - - setForm((prev) => ({ - ...prev, - blogOrSnsLink: e.target.value, - })) - } - /> - -
    + + name="blogOrSnsLink" + label="블로그/SNS 등 링크" + helper="본인의 활동을 확인할 수 있는 외부 링크가 있다면 입력해 주세요." + direction="vertical" + > + + + + @@ -399,8 +273,16 @@ function StartStudyForm({ memberId }: StartStudyModalProps) { 취소 - diff --git a/src/features/study/ui/study-card.tsx b/src/features/study/ui/study-card.tsx index 2c8d5352..0edb1e8d 100644 --- a/src/features/study/ui/study-card.tsx +++ b/src/features/study/ui/study-card.tsx @@ -6,6 +6,8 @@ import DateSelector from './data-selector'; import TodayStudyCard from './today-study-card'; import StudyListSection from '../../../widgets/home/study-list-table'; import { useWeeklyParticipation } from '../model/use-study-query'; +import { useStudyStatusQuery } from '../participation/model/use-participation-query'; +import ReservationList from '../participation/ui/reservation-list'; // 스터디 주차 구하는 함수 function getWeekly(date: Date): { month: number; week: number } { @@ -59,6 +61,8 @@ export default function StudyCard() { const studyDate = dateOffset.toISOString().split('T')[0]; + const { data: status } = useStudyStatusQuery(); + const { data: participationData } = useWeeklyParticipation(studyDate); const isParticipate = participationData?.isParticipate ?? false; @@ -66,14 +70,26 @@ export default function StudyCard() { return ( <> -
    -
    {`${month}월 ${week}주차 스터디`}
    - -
    -
    - {isParticipate && } - -
    + {status === 'RECRUITING' && ( + + )} + {status === 'STUDYING' && ( + <> +
    +
    {`${month}월 ${week}주차 스터디`}
    + +
    +
    + {isParticipate && } + +
    + + )} ); } diff --git a/src/features/study/ui/study-done-modal.tsx b/src/features/study/ui/study-done-modal.tsx index 6224f2d3..b3825ca6 100644 --- a/src/features/study/ui/study-done-modal.tsx +++ b/src/features/study/ui/study-done-modal.tsx @@ -1,17 +1,27 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import { XIcon } from 'lucide-react'; import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + import Button from '@/shared/ui/button'; import { SingleDropdown } from '@/shared/ui/dropdown'; +import FormField from '@/shared/ui/form/form-field'; import { TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { + +import type { CompleteStudyRequest, DailyStudyDetail, StudyProgressStatus, } from '../api/types'; import { STUDY_PROGRESS_OPTIONS } from '../consts/study-const'; +import { + StudyDoneFormSchema, + type StudyDoneFormValues, + buildStudyDoneDefaults, +} from '../model/interview.schema'; import { useUpdateDailyStudyMutation } from '../model/use-study-query'; interface StudyDoneModalProps { @@ -56,27 +66,33 @@ export default function StudyDoneModal({ ); } -interface StudyDoneFormProps { +function StudyDoneForm({ + data, + studyDate, + onClose, +}: { data: DailyStudyDetail; studyDate: string; onClose: () => void; -} +}) { + const { mutate, isPending } = useUpdateDailyStudyMutation(); -function StudyDoneForm({ data, studyDate, onClose }: StudyDoneFormProps) { - const [form, setForm] = useState({ - feedback: data.feedback ?? '', - progressStatus: data.progressStatus ?? 'PENDING', + const methods = useForm({ + resolver: zodResolver(StudyDoneFormSchema), + mode: 'onChange', + defaultValues: buildStudyDoneDefaults(data), }); - const { mutate, isPending } = useUpdateDailyStudyMutation(); - const { feedback, progressStatus } = form; + const { + handleSubmit, + formState: { isValid, isSubmitting }, + } = methods; - const handleChange = (key: keyof CompleteStudyRequest) => (value: string) => { - setForm((prev) => ({ ...prev, [key]: value })); - }; - - const handleSubmit = async () => { - if (!feedback.trim() || !progressStatus) return; + const onSubmit = (values: StudyDoneFormValues) => { + const form: CompleteStudyRequest = { + progressStatus: values.progressStatus as StudyProgressStatus, + feedback: values.feedback, + }; mutate( { @@ -98,49 +114,42 @@ function StudyDoneForm({ data, studyDate, onClose }: StudyDoneFormProps) { return ( <> -
    -
    - - - 면접 완료 후 해당 지원자의 상태를 업데이트해 주세요. - -
    - - - handleChange('progressStatus')(value as StudyProgressStatus) - } - /> -
    - -
    -
    - - - 면접 결과에 대한 간단한 피드백을 입력해 주세요. - -
    - - handleChange('feedback')(value)} - /> -
    + +
    + + name="progressStatus" + label="진행 현황" + helper="면접 완료 후 해당 지원자의 상태를 업데이트해 주세요." + required + direction="vertical" + > + + + + + name="feedback" + label="피드백" + helper="면접 결과에 대한 간단한 피드백을 입력해 주세요." + required + direction="vertical" + showCounterRight + counterMax={100} + > + + + +
    @@ -150,9 +159,10 @@ function StudyDoneForm({ data, studyDate, onClose }: StudyDoneFormProps) { diff --git a/src/features/study/ui/study-ready-modal.tsx b/src/features/study/ui/study-ready-modal.tsx index 2926eb22..56631828 100644 --- a/src/features/study/ui/study-ready-modal.tsx +++ b/src/features/study/ui/study-ready-modal.tsx @@ -1,11 +1,21 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import { XIcon } from 'lucide-react'; import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + import Button from '@/shared/ui/button'; +import FormField from '@/shared/ui/form/form-field'; import { BaseInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { DailyStudyDetail, PrepareStudyRequest } from '../api/types'; + +import type { DailyStudyDetail, PrepareStudyRequest } from '../api/types'; +import { + StudyReadyFormSchema, + type StudyReadyFormValues, + buildStudyReadyDefaults, +} from '../model/interview.schema'; import { useUpdateDailyStudyMutation } from '../model/use-study-query'; interface StudyReadyModalProps { @@ -46,27 +56,33 @@ export default function StudyReadyModal({ ); } -interface StudyReadyFormProps { +function StudyReadyForm({ + data, + studyDate, + onClose, +}: { data: DailyStudyDetail; studyDate: string; onClose: () => void; -} +}) { + const { mutate, isPending } = useUpdateDailyStudyMutation(); -function StudyReadyForm({ data, studyDate, onClose }: StudyReadyFormProps) { - const [form, setForm] = useState({ - subject: data.subject ?? '', - link: data.link ?? '', + const methods = useForm({ + resolver: zodResolver(StudyReadyFormSchema), + mode: 'onChange', + defaultValues: buildStudyReadyDefaults(data), }); - const { mutate, isPending } = useUpdateDailyStudyMutation(); - const { subject, link } = form; + const { + handleSubmit, + formState: { isValid, isSubmitting }, + } = methods; - const handleChange = (key: keyof PrepareStudyRequest) => (value: string) => { - setForm((prev) => ({ ...prev, [key]: value })); - }; - - const handleSubmit = async () => { - if (!subject.trim()) return; + const onSubmit = (values: StudyReadyFormValues) => { + const form: PrepareStudyRequest = { + subject: values.subject, + link: values.link ?? undefined, + }; mutate( { @@ -88,42 +104,33 @@ function StudyReadyForm({ data, studyDate, onClose }: StudyReadyFormProps) { return ( <> -
    -
    - - - 이번 스터디에서 다룰 면접 주제를 입력하세요 - -
    - - handleChange('subject')(e.target.value)} - /> -
    - -
    -
    - - - 참고할 링크나 자료가 있다면 입력해 주세요 - -
    - - handleChange('link')(e.target.value)} - /> -
    + +
    + + name="subject" + label="면접 주제" + helper="이번 스터디에서 다룰 면접 주제 또는 질문 유형을 간단히 작성해 주세요." + required + direction="vertical" + > + + + + + name="link" + label="참고 자료" + helper="함께 참고할 문서나 링크가 있다면 입력해 주세요" + required + direction="vertical" + > + + + +
    @@ -133,9 +140,10 @@ function StudyReadyForm({ data, studyDate, onClose }: StudyReadyFormProps) { diff --git a/src/features/study/ui/today-study-card.tsx b/src/features/study/ui/today-study-card.tsx index ffc87692..a1816bb4 100644 --- a/src/features/study/ui/today-study-card.tsx +++ b/src/features/study/ui/today-study-card.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import UserProfileModal from '@/features/my-page/ui/user-profile-modal'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { getStatusBadge } from '@/features/study/ui/status-badge-map'; import { getCookie } from '@/shared/tanstack-query/cookie'; // TODO: FSD 의 import 바운더리를 넘어서 import 해야하는데, diff --git a/src/shared/ui/avatar/index.tsx b/src/shared/ui/avatar/index.tsx index 3f8817bc..ded4874a 100644 --- a/src/shared/ui/avatar/index.tsx +++ b/src/shared/ui/avatar/index.tsx @@ -1,27 +1,66 @@ 'use client'; -import { Avatar, AvatarImage } from '@/shared/shadcn/ui/avatar'; +import { useMemo, useState } from 'react'; +import ProfileDefault from '@/entities/user/ui/icon/profile-default.svg'; +import { Avatar, AvatarImage, AvatarFallback } from '@/shared/shadcn/ui/avatar'; + +type ProfileImageSrc = string | undefined; + +function getValidImageUrl(src: ProfileImageSrc) { + const trimedSrc = (src ?? '').trim(); + if (!trimedSrc || trimedSrc.toLowerCase() === 'default') return undefined; + + return trimedSrc; +} interface UserAvatarProps { - image?: string; + image?: ProfileImageSrc; alt?: string; size?: number; + accentColor?: string; + className?: string; } export default function UserAvatar({ image, alt = 'user profile', size = 32, - ref, + accentColor = '#FAB0D5', + className, ...props -}: React.RefAttributes & UserAvatarProps) { +}: UserAvatarProps) { + const [isImageError, setImageError] = useState(false); + + const resolvedImageUrl = useMemo(() => { + setImageError(false); + + return getValidImageUrl(image); + }, [image]); + + const showImage = !!resolvedImageUrl && !isImageError; + return ( - - {image ? ( - - ) : ( - + + {showImage && ( + setImageError(true)} + /> )} + + + + ); } diff --git a/src/shared/ui/button/index.tsx b/src/shared/ui/button/index.tsx index e7673056..793b7ce3 100644 --- a/src/shared/ui/button/index.tsx +++ b/src/shared/ui/button/index.tsx @@ -9,29 +9,26 @@ interface ButtonProps extends React.ComponentProps<'button'> { iconPosition?: 'left' | 'right'; } -const buttonVariants = cva( - 'rounded-100 flex items-center justify-center cursor-pointer', - { - variants: { - color: { - primary: - 'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover active:bg-fill-brand-default-pressed disabled:bg-background-disabled disabled:text-text-disabled', - secondary: - 'bg-fill-neutral-default-default text-text-default hover:bg-fill-neutral-default-hover active:bg-fill-neutral-default-pressed disabled:bg-background-disabled disabled:text-text-disabled', - }, - size: { - xsmall: 'px-75 py-25 font-designer-13b', - small: 'px-75 py-50 font-designer-14b', - medium: 'px-100 py-75 font-designer-16b', - large: 'px-150 py-100 font-designer-16b', - }, +const buttonVariants = cva('flex items-center justify-center cursor-pointer', { + variants: { + color: { + primary: + 'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover active:bg-fill-brand-default-pressed disabled:bg-background-disabled disabled:text-text-disabled', + secondary: + 'bg-fill-neutral-default-default text-text-default hover:bg-fill-neutral-default-hover active:bg-fill-neutral-default-pressed disabled:bg-background-disabled disabled:text-text-disabled', }, - defaultVariants: { - color: 'primary', - size: 'medium', + size: { + xsmall: 'px-75 py-25 font-designer-13b rounded-75', + small: 'px-75 py-50 font-designer-14b rounded-75', + medium: 'px-100 py-75 font-designer-16b rounded-100', + large: 'px-150 py-100 font-designer-16b rounded-100', }, }, -); + defaultVariants: { + color: 'primary', + size: 'medium', + }, +}); function Button({ color = 'primary', diff --git a/src/shared/ui/dropdown/multi.tsx b/src/shared/ui/dropdown/multi.tsx index 7b228088..fc1fc0ca 100644 --- a/src/shared/ui/dropdown/multi.tsx +++ b/src/shared/ui/dropdown/multi.tsx @@ -1,7 +1,7 @@ 'use client'; import { XIcon, ChevronDown, ChevronUp } from 'lucide-react'; -import { useState } from 'react'; +import { useMemo, useState, useCallback } from 'react'; import { DropdownMenu, DropdownMenuContent, @@ -11,107 +11,130 @@ import { interface Option { label: string; - value: string | number; + value: string; } interface MultiDropdownProps { - options: Option[]; - defaultValue?: (string | number)[]; - error?: boolean; - onChange?: (selected: (string | number)[]) => void; + options: ReadonlyArray