From 39e788f77bccde727417a3e648baf22f0a33d735 Mon Sep 17 00:00:00 2001 From: aken-you Date: Sat, 30 Aug 2025 15:07:32 +0900 Subject: [PATCH 001/128] =?UTF-8?q?refactor:=20profile-default.svg=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/ui/sign-up-modal.tsx | 4 +--- src/features/my-page/consts/my-page-const.ts | 2 -- src/features/my-page/ui/profile-edit-modal.tsx | 12 ++++-------- src/widgets/home/header.tsx | 2 +- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/features/auth/ui/sign-up-modal.tsx b/src/features/auth/ui/sign-up-modal.tsx index e2b8e15b..c1fe8691 100644 --- a/src/features/auth/ui/sign-up-modal.tsx +++ b/src/features/auth/ui/sign-up-modal.tsx @@ -21,9 +21,7 @@ export default function SignupModal({ }) { const [name, setName] = useState(''); const [checked, setChecked] = useState(false); - const [image, setImage] = useState( - getCookie('socialImageURL') || 'profile-default.svg', - ); + const [image, setImage] = useState(getCookie('socialImageURL') || undefined); const fileInputRef = useRef(null); const signUp = useSignUpMutation(); diff --git a/src/features/my-page/consts/my-page-const.ts b/src/features/my-page/consts/my-page-const.ts index 5f909266..15b20a72 100644 --- a/src/features/my-page/consts/my-page-const.ts +++ b/src/features/my-page/consts/my-page-const.ts @@ -29,5 +29,3 @@ export const MBTI_OPTIONS = [ { label: 'ENFJ', value: 'ENFJ' }, { label: 'ENTJ', value: 'ENTJ' }, ]; - -export const DEFAULT_PROFILE_IMAGE_URL = '/profile-default.svg'; diff --git a/src/features/my-page/ui/profile-edit-modal.tsx b/src/features/my-page/ui/profile-edit-modal.tsx index d6126b95..8ef4d06a 100644 --- a/src/features/my-page/ui/profile-edit-modal.tsx +++ b/src/features/my-page/ui/profile-edit-modal.tsx @@ -16,11 +16,7 @@ import MultiItemSelector from '@/shared/ui/form/multi-item-selector'; import { BaseInput, TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { - DEFAULT_OPTIONS, - DEFAULT_PROFILE_IMAGE_URL, - MBTI_OPTIONS, -} from '../consts/my-page-const'; +import { DEFAULT_OPTIONS, MBTI_OPTIONS } from '../consts/my-page-const'; import { ProfileFormSchema, type ProfileFormInput, @@ -83,7 +79,7 @@ function ProfileEditForm({ const [image, setImage] = useState( memberProfile.profileImage?.resizedImages?.[0]?.resizedImageUrl ?? - DEFAULT_PROFILE_IMAGE_URL, + undefined, ); const methods = useForm({ @@ -100,7 +96,7 @@ function ProfileEditForm({ const onValidSubmit = async (values: ProfileFormValues) => { const file = fileInputRef.current?.files?.[0]; const profileImageExtension = - image === DEFAULT_PROFILE_IMAGE_URL ? 'jpg' : file?.name.split('.').pop(); + image === undefined ? 'jpg' : file?.name.split('.').pop(); const payload = toUpdateProfilePayload(values, { profileImageExtension }); const updatedProfile = await updateProfile(payload); @@ -111,7 +107,7 @@ function ProfileEditForm({ if (file) { imageFormData.append('file', file); - } else if (image === DEFAULT_PROFILE_IMAGE_URL) { + } else if (image === undefined) { const defaultProfileImage = 'profile-default.jpg'; const response = await fetch(defaultProfileImage); const blob = await response.blob(); diff --git a/src/widgets/home/header.tsx b/src/widgets/home/header.tsx index bf25eca6..7e52f949 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -15,7 +15,7 @@ export default async function Header() { const userInfo = isLogin ? await getUserProfile(Number(memberId)) : null; const userImg = isLogin ? userInfo.memberProfile.profileImage?.resizedImages[0].resizedImageUrl - : 'profile-default.svg'; + : undefined; return (
From 68e8eb53c7368cad23a2b25ad0a6db0f4ac42ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 16:53:36 +0900 Subject: [PATCH 002/128] =?UTF-8?q?refactor:=20const=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/interview/const/interview-const.ts | 6 ++++++ .../const/participation-const.ts} | 7 ------- 2 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 src/features/study/interview/const/interview-const.ts rename src/features/study/{consts/study-const.ts => participation/const/participation-const.ts} (87%) diff --git a/src/features/study/interview/const/interview-const.ts b/src/features/study/interview/const/interview-const.ts new file mode 100644 index 00000000..69c334e1 --- /dev/null +++ b/src/features/study/interview/const/interview-const.ts @@ -0,0 +1,6 @@ +export const STUDY_PROGRESS_OPTIONS = [ + { label: '시작 전', value: 'PENDING' }, + { label: '불참', value: 'ABSENT' }, + { label: '진행중', value: 'IN_PROGRESS' }, + { label: '완료', value: 'COMPLETE' }, +]; diff --git a/src/features/study/consts/study-const.ts b/src/features/study/participation/const/participation-const.ts similarity index 87% rename from src/features/study/consts/study-const.ts rename to src/features/study/participation/const/participation-const.ts index 854748db..4171ccf3 100644 --- a/src/features/study/consts/study-const.ts +++ b/src/features/study/participation/const/participation-const.ts @@ -1,10 +1,3 @@ -export const STUDY_PROGRESS_OPTIONS = [ - { label: '시작 전', value: 'PENDING' }, - { label: '불참', value: 'ABSENT' }, - { label: '진행중', value: 'IN_PROGRESS' }, - { label: '완료', value: 'COMPLETE' }, -]; - export const studySteps = [ { title: '1. 면접 준비', From dba7562d3df7f05e1842e8c532a52b67e2a5d352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 16:54:55 +0900 Subject: [PATCH 003/128] =?UTF-8?q?refactor:=20lib=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/user/ui/my-profile-card.tsx | 4 ++-- .../study/{ => interview}/lib/use-reminder-review.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/features/study/{ => interview}/lib/use-reminder-review.tsx (93%) diff --git a/src/entities/user/ui/my-profile-card.tsx b/src/entities/user/ui/my-profile-card.tsx index cd2eac19..b1b90435 100644 --- a/src/entities/user/ui/my-profile-card.tsx +++ b/src/entities/user/ui/my-profile-card.tsx @@ -3,8 +3,8 @@ import Link from 'next/link'; import React, { useState } from 'react'; import { usePatchAutoMatchingMutation } from '@/entities/user/model/use-user-profile-query'; -import { useReviewReminder } from '@/features/study/lib/use-reminder-review'; -import StudyReviewModal from '@/features/study/ui/study-review-modal'; +import { useReviewReminder } from '@/features/study/interview/lib/use-reminder-review'; +import StudyReviewModal from '@/features/study/interview/ui/study-review-modal'; import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; import { cn } from '@/shared/shadcn/lib/utils'; import UserAvatar from '@/shared/ui/avatar'; diff --git a/src/features/study/lib/use-reminder-review.tsx b/src/features/study/interview/lib/use-reminder-review.tsx similarity index 93% rename from src/features/study/lib/use-reminder-review.tsx rename to src/features/study/interview/lib/use-reminder-review.tsx index 4ab66b54..076af1e4 100644 --- a/src/features/study/lib/use-reminder-review.tsx +++ b/src/features/study/interview/lib/use-reminder-review.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { getKoreaDate } from '@/shared/lib/time'; -import { useShouldReviewPartnerQuery } from '../model/use-review-query'; +import { useShouldReviewPartnerQuery } from '../../model/use-review-query'; export const useReviewReminder = () => { const { data: shouldReview, isFetching } = useShouldReviewPartnerQuery(); From 4b112dc45d0993d01e50f430e3513ab29248d456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:10:33 +0900 Subject: [PATCH 004/128] =?UTF-8?q?refactor:=20study=20api=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/interview/api/get-interview.ts | 28 ++++++++++ .../study/{ => interview}/api/get-review.ts | 2 +- .../api/get-participation-data.ts | 24 ++++++++- .../api/get-study-schedule.tsx} | 54 ++----------------- 4 files changed, 54 insertions(+), 54 deletions(-) create mode 100644 src/features/study/interview/api/get-interview.ts rename src/features/study/{ => interview}/api/get-review.ts (98%) rename src/features/study/{api/get-study-data.ts => schedule/api/get-study-schedule.tsx} (50%) diff --git a/src/features/study/interview/api/get-interview.ts b/src/features/study/interview/api/get-interview.ts new file mode 100644 index 00000000..21336563 --- /dev/null +++ b/src/features/study/interview/api/get-interview.ts @@ -0,0 +1,28 @@ +import type { + CompleteStudyRequest, + PrepareStudyRequest, +} from '@/features/study/interview/api/interview-types'; +import { axiosInstance } from '@/shared/tanstack-query/axios'; + +// 면접 준비 시작 +export const putStudyDaily = async ( + dailyId: number, + body: PrepareStudyRequest, +) => { + const res = await axiosInstance.put(`/study/daily/${dailyId}/prepare`, body); + + return res.data; +}; + +// 면접 완료 및 회고 작성 +export const completeStudy = async ( + dailyStudyId: number, + body: CompleteStudyRequest, +) => { + const res = await axiosInstance.post( + `/study/daily/${dailyStudyId}/complete`, + body, + ); + + return res.data; +}; \ No newline at end of file diff --git a/src/features/study/api/get-review.ts b/src/features/study/interview/api/get-review.ts similarity index 98% rename from src/features/study/api/get-review.ts rename to src/features/study/interview/api/get-review.ts index 58b3a4cb..ff521549 100644 --- a/src/features/study/api/get-review.ts +++ b/src/features/study/interview/api/get-review.ts @@ -9,7 +9,7 @@ import type { MyReviewsResponse, MyReviewsRequest, ShouldReviewPartnerResponse, -} from './types'; +} from '../../api/types'; export const getPartnerStudyReview = async (): Promise => { diff --git a/src/features/study/participation/api/get-participation-data.ts b/src/features/study/participation/api/get-participation-data.ts index 82791ff4..57ab27fd 100644 --- a/src/features/study/participation/api/get-participation-data.ts +++ b/src/features/study/participation/api/get-participation-data.ts @@ -1,11 +1,13 @@ -import { axiosInstance } from '@/shared/tanstack-query/axios'; import { WeeklyReservationRequest, WeeklyReservationResponse, ReservationUserItem, Participant, -} from './participation-types'; + JoinStudyRequest, +} from '@/features/study/participation/api/participation-types'; +import { axiosInstance } from '@/shared/tanstack-query/axios'; +// 스터디 신청 목록 서버데이터 -> UI 매핑 함수 export function mapReservation(user: ReservationUserItem): Participant { const original = user.profileImage?.resizedImages.find( (img) => img.imageSizeType.imageTypeName === 'ORIGINAL', @@ -19,6 +21,7 @@ export function mapReservation(user: ReservationUserItem): Participant { }; } +// 스터디 신청 목록 export const getReservationMembers = async ( params: WeeklyReservationRequest, ): Promise => { @@ -37,8 +40,25 @@ export const getReservationMembers = async ( 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; }; + +// CS 스터디 매칭 신청 +export const postJoinStudy = async (payload: JoinStudyRequest) => { + const cleanPayload = Object.fromEntries( + Object.entries(payload).filter( + ([_, value]) => + value !== undefined && + value !== '' && + !(Array.isArray(value) && value.length === 0), + ), + ); + + const res = await axiosInstance.post('/matching/apply', cleanPayload); + + return res.data; +}; diff --git a/src/features/study/api/get-study-data.ts b/src/features/study/schedule/api/get-study-schedule.tsx similarity index 50% rename from src/features/study/api/get-study-data.ts rename to src/features/study/schedule/api/get-study-schedule.tsx index 89f1c674..f16093ac 100644 --- a/src/features/study/api/get-study-data.ts +++ b/src/features/study/schedule/api/get-study-schedule.tsx @@ -1,15 +1,12 @@ -import type { - CompleteStudyRequest, +// 매칭 결과 목록, 오늘의 스터디 상세 정보, 나의 스터디 캘린더 +import { DailyStudyDetail, GetDailyStudiesParams, GetDailyStudiesResponse, GetMonthlyCalendarParams, - JoinStudyRequest, MonthlyCalendarResponse, - PostDailyRetrospectRequest, - PrepareStudyRequest, WeeklyParticipationResponse, -} from '@/features/study/api/types'; +} from '@/features/study/schedule/api/schedule-types'; import { axiosInstance } from '@/shared/tanstack-query/axios'; // 스터디 상세 조회 @@ -39,51 +36,6 @@ export const getMonthlyStudyCalendar = async ( return res.data.content; }; -export const postDailyRetrospect = async (body: PostDailyRetrospectRequest) => { - const res = await axiosInstance.post('/study/daily/retrospect', body); - - return res.data; -}; - -// 면접 준비 시작 -export const putStudyDaily = async ( - dailyId: number, - body: PrepareStudyRequest, -) => { - const res = await axiosInstance.put(`/study/daily/${dailyId}/prepare`, body); - - return res.data; -}; - -// 면접 완료 및 회고 작성 -export const completeStudy = async ( - dailyStudyId: number, - body: CompleteStudyRequest, -) => { - const res = await axiosInstance.post( - `/study/daily/${dailyStudyId}/complete`, - body, - ); - - return res.data; -}; - -// CS 스터디 매칭 신청 -export const postJoinStudy = async (payload: JoinStudyRequest) => { - const cleanPayload = Object.fromEntries( - Object.entries(payload).filter( - ([_, value]) => - value !== undefined && - value !== '' && - !(Array.isArray(value) && value.length === 0), - ), - ); - - const res = await axiosInstance.post('/matching/apply', cleanPayload); - - return res.data; -}; - // 스터디 참여 유무 확인 export const getWeeklyParticipation = async ( studyDate: string, From 19f5c222a36a0946700b64f6213b1f1025efde5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:40:41 +0900 Subject: [PATCH 005/128] =?UTF-8?q?refactor:=20study=20type=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/interview-types.ts} | 69 ++----------------- .../participation/api/participation-types.ts | 14 ++++ .../study/schedule/api/schedule-types.ts | 58 ++++++++++++++++ 3 files changed, 76 insertions(+), 65 deletions(-) rename src/features/study/{api/types.ts => interview/api/interview-types.ts} (67%) create mode 100644 src/features/study/schedule/api/schedule-types.ts diff --git a/src/features/study/api/types.ts b/src/features/study/interview/api/interview-types.ts similarity index 67% rename from src/features/study/api/types.ts rename to src/features/study/interview/api/interview-types.ts index 70fd16c9..f508a71c 100644 --- a/src/features/study/api/types.ts +++ b/src/features/study/interview/api/interview-types.ts @@ -4,20 +4,7 @@ export type StudyProgressStatus = | 'COMPLETE' | 'ABSENT'; -export interface DailyStudy { - interviewer: string; - interviewerImage: string; - interviewee: string; - intervieweeImage: string; - dailyStudyId: number; - subject: string; - description: string; - link: string; - progressStatus: StudyProgressStatus; - studyDate: string; - feedback: string | undefined; -} - +// 오늘의 스터디 상세조회 관련 타입 export interface DailyStudyDetail { dailyStudyId: number; interviewerId: number; @@ -34,67 +21,19 @@ export interface DailyStudyDetail { feedback: string; } -export interface GetDailyStudiesParams { - cursor?: number; - pageSize?: number; - studyDate?: string; -} - -export interface GetDailyStudiesResponse { - items: DailyStudy[]; - nextCursor: number; - hasNext: boolean; -} - -export interface GetMonthlyCalendarParams { - year: number; - month: number; -} - -export interface StudyCalendarDay { - day: number; - hasStudy: boolean; - status: StudyProgressStatus | undefined; -} - -export interface MonthlyCalendarResponse { - calendar: StudyCalendarDay[]; - monthlyCompletedCount?: number; - totalCompletedCount?: number; -} - -export interface PostDailyRetrospectRequest { - description: string; - parentId: number; -} - +// 스터디 면접 준비 타입 export interface PrepareStudyRequest { subject: string; link: string; } -export interface JoinStudyRequest { - memberId: number; - selfIntroduction?: string; - studyPlan?: string; - preferredStudySubjectId?: string; - availableStudyTimeIds?: number[]; - techStackIds?: number[]; - tel?: string; - githubLink?: string; - blogOrSnsLink?: string; -} - -export interface WeeklyParticipationResponse { - memberId: number; - isParticipate: boolean; -} - +// 스터디 면접 완료 타입 export interface CompleteStudyRequest { feedback: string; progressStatus: StudyProgressStatus; } +// 리뷰 관련 타입 export interface EvalKeyword { id: number; keyword: string; diff --git a/src/features/study/participation/api/participation-types.ts b/src/features/study/participation/api/participation-types.ts index 0a5eb343..01fe8178 100644 --- a/src/features/study/participation/api/participation-types.ts +++ b/src/features/study/participation/api/participation-types.ts @@ -1,3 +1,4 @@ +// 다음주 신청 리스트 조회 관련 타입 export interface ReservationUserItem { memberId: number; memberName: string; @@ -37,3 +38,16 @@ export interface WeeklyReservationRequest { pageSize?: number; firstMemberId?: number; } + +// 스터디 참여 신청 타입 +export interface JoinStudyRequest { + memberId: number; + selfIntroduction?: string; + studyPlan?: string; + preferredStudySubjectId?: string; + availableStudyTimeIds?: number[]; + techStackIds?: number[]; + tel?: string; + githubLink?: string; + blogOrSnsLink?: string; +} \ No newline at end of file diff --git a/src/features/study/schedule/api/schedule-types.ts b/src/features/study/schedule/api/schedule-types.ts new file mode 100644 index 00000000..fbc248ef --- /dev/null +++ b/src/features/study/schedule/api/schedule-types.ts @@ -0,0 +1,58 @@ +export type StudyProgressStatus = + | 'PENDING' + | 'IN_PROGRESS' + | 'COMPLETE' + | 'ABSENT'; + +// 스터디 시작/종료 유무 타입 +export type StudyStatus = 'RECRUITING' | 'STUDYING'; + +// 스터디 매칭 리스트 관련 타입 +export interface GetDailyStudiesParams { + cursor?: number; + pageSize?: number; + studyDate?: string; +} + +export interface DailyStudy { + interviewer: string; + interviewerImage: string; + interviewee: string; + intervieweeImage: string; + dailyStudyId: number; + subject: string; + description: string; + link: string; + progressStatus: StudyProgressStatus; + studyDate: string; + feedback: string | undefined; +} + +export interface GetDailyStudiesResponse { + items: DailyStudy[]; + nextCursor: number; + hasNext: boolean; +} + +// 캘린더 관련 타입 +export interface GetMonthlyCalendarParams { + year: number; + month: number; +} + +export interface StudyCalendarDay { + day: number; + hasStudy: boolean; + status: StudyProgressStatus | undefined; +} + +export interface MonthlyCalendarResponse { + calendar: StudyCalendarDay[]; + monthlyCompletedCount?: number; + totalCompletedCount?: number; +} + +export interface WeeklyParticipationResponse { + memberId: number; + isParticipate: boolean; +} From f3638916bd6d09ee6094afef78904ba6edbbf1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:42:09 +0900 Subject: [PATCH 006/128] =?UTF-8?q?fix:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=9C=A0=EB=AC=B4,=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/interview/api/get-interview.ts | 12 +++++++++++- .../api/get-participation-data.ts | 9 --------- .../study/schedule/api/get-study-schedule.tsx | 18 ++++++++---------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/features/study/interview/api/get-interview.ts b/src/features/study/interview/api/get-interview.ts index 21336563..c5feef00 100644 --- a/src/features/study/interview/api/get-interview.ts +++ b/src/features/study/interview/api/get-interview.ts @@ -1,9 +1,19 @@ import type { CompleteStudyRequest, + DailyStudyDetail, PrepareStudyRequest, } from '@/features/study/interview/api/interview-types'; import { axiosInstance } from '@/shared/tanstack-query/axios'; +// 스터디 상세 조회 +export const getDailyStudyDetail = async ( + params: string, +): Promise => { + const res = await axiosInstance.get(`/study/daily/mine/${params}`); + + return res.data.content; +}; + // 면접 준비 시작 export const putStudyDaily = async ( dailyId: number, @@ -25,4 +35,4 @@ export const completeStudy = async ( ); return res.data; -}; \ No newline at end of file +}; diff --git a/src/features/study/participation/api/get-participation-data.ts b/src/features/study/participation/api/get-participation-data.ts index 57ab27fd..ea71e138 100644 --- a/src/features/study/participation/api/get-participation-data.ts +++ b/src/features/study/participation/api/get-participation-data.ts @@ -38,15 +38,6 @@ export const getReservationMembers = async ( 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; -}; - // CS 스터디 매칭 신청 export const postJoinStudy = async (payload: JoinStudyRequest) => { const cleanPayload = Object.fromEntries( diff --git a/src/features/study/schedule/api/get-study-schedule.tsx b/src/features/study/schedule/api/get-study-schedule.tsx index f16093ac..b2345f0d 100644 --- a/src/features/study/schedule/api/get-study-schedule.tsx +++ b/src/features/study/schedule/api/get-study-schedule.tsx @@ -1,23 +1,14 @@ // 매칭 결과 목록, 오늘의 스터디 상세 정보, 나의 스터디 캘린더 import { - DailyStudyDetail, GetDailyStudiesParams, GetDailyStudiesResponse, GetMonthlyCalendarParams, MonthlyCalendarResponse, + StudyStatus, WeeklyParticipationResponse, } from '@/features/study/schedule/api/schedule-types'; import { axiosInstance } from '@/shared/tanstack-query/axios'; -// 스터디 상세 조회 -export const getDailyStudyDetail = async ( - params: string, -): Promise => { - const res = await axiosInstance.get(`/study/daily/mine/${params}`); - - return res.data.content; -}; - // 스터디 전체 조회 export const getDailyStudies = async ( params?: GetDailyStudiesParams, @@ -46,3 +37,10 @@ export const getWeeklyParticipation = async ( return res.data.content; }; + +// 스터디 시작/종료 유무 확인 +export const getStudyStatus = async (): Promise => { + const res = await axiosInstance.get('/matching/system-status'); + + return res.data.content.status as StudyStatus; +}; From 99faeaebb6a0f21df83eb0b9913adc3ec011e459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:43:05 +0900 Subject: [PATCH 007/128] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20query=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/use-interview-query.ts} | 50 +----------------- .../{ => interview}/model/use-review-query.ts | 8 +-- .../model/use-participation-query.ts | 28 +++++----- .../schedule/model/use-schedule-query.ts | 52 +++++++++++++++++++ 4 files changed, 73 insertions(+), 65 deletions(-) rename src/features/study/{model/use-study-query.ts => interview/model/use-interview-query.ts} (52%) rename src/features/study/{ => interview}/model/use-review-query.ts (97%) create mode 100644 src/features/study/schedule/model/use-schedule-query.ts diff --git a/src/features/study/model/use-study-query.ts b/src/features/study/interview/model/use-interview-query.ts similarity index 52% rename from src/features/study/model/use-study-query.ts rename to src/features/study/interview/model/use-interview-query.ts index 8976355c..4a3b32e1 100644 --- a/src/features/study/model/use-study-query.ts +++ b/src/features/study/interview/model/use-interview-query.ts @@ -1,31 +1,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { completeStudy, - getDailyStudies, getDailyStudyDetail, - getMonthlyStudyCalendar, - getWeeklyParticipation, - postJoinStudy, putStudyDaily, -} from '@/features/study/api/get-study-data'; +} from '@/features/study/interview/api/get-interview'; import { CompleteStudyRequest, - GetDailyStudiesParams, - GetMonthlyCalendarParams, - JoinStudyRequest, - MonthlyCalendarResponse, PrepareStudyRequest, -} from '../api/types'; - -// 스터디 주간 참여 유무 확인 query -export const useWeeklyParticipation = (params: string) => { - return useQuery({ - queryKey: ['weeklyParticipation', params], - queryFn: () => getWeeklyParticipation(params), - staleTime: 60 * 1000, - enabled: !!params, - }); -}; +} from '@/features/study/interview/api/interview-types'; // 스터디 상세 조회 query export const useDailyStudyDetailQuery = (params: string) => { @@ -37,34 +19,6 @@ export const useDailyStudyDetailQuery = (params: string) => { }); }; -// 스터디 전체 조회 query -export const useDailyStudiesQuery = (params?: GetDailyStudiesParams) => { - return useQuery({ - queryKey: ['dailyStudies', params], - queryFn: () => getDailyStudies(params), - staleTime: 60 * 1000, - }); -}; - -// 스터디 캘린더 조회 query -export const useMonthlyStudyCalendarQuery = ( - params: GetMonthlyCalendarParams, -) => { - return useQuery({ - queryKey: ['monthlyStudyCalendar', params], - queryFn: () => getMonthlyStudyCalendar(params), - staleTime: 60 * 1000, - enabled: !!params?.year && !!params?.month, - }); -}; - -// 스터디 신청 mutation -export const useJoinStudyMutation = () => { - return useMutation({ - mutationFn: (payload: JoinStudyRequest) => postJoinStudy(payload), - }); -}; - // 스터디 상세 & 리스트 업데이트 interface UpdateDailyStudyVariables { dailyStudyId: number; diff --git a/src/features/study/model/use-review-query.ts b/src/features/study/interview/model/use-review-query.ts similarity index 97% rename from src/features/study/model/use-review-query.ts rename to src/features/study/interview/model/use-review-query.ts index fe2fbfc7..984e518d 100644 --- a/src/features/study/model/use-review-query.ts +++ b/src/features/study/interview/model/use-review-query.ts @@ -4,6 +4,10 @@ import { useQuery, useSuspenseQuery, } from '@tanstack/react-query'; +import { + MyNegativeKeywordsRequest, + UserPositiveKeywordsRequest, +} from '@/features/study/interview/api/interview-types'; import { getKoreaDate } from '@/shared/lib/time'; import { addStudyReview, @@ -13,10 +17,6 @@ import { getMyReviews, getShouldReviewPartner, } from '../api/get-review'; -import { - MyNegativeKeywordsRequest, - UserPositiveKeywordsRequest, -} from '../api/types'; export const usePartnerStudyReviewQuery = () => { return useSuspenseQuery({ diff --git a/src/features/study/participation/model/use-participation-query.ts b/src/features/study/participation/model/use-participation-query.ts index c4d46719..b976e555 100644 --- a/src/features/study/participation/model/use-participation-query.ts +++ b/src/features/study/participation/model/use-participation-query.ts @@ -1,12 +1,22 @@ -import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useMutation } from '@tanstack/react-query'; import { getReservationMembers, - getStudyStatus, mapReservation, - StudyStatus, -} from '../api/get-participation-data'; -import { WeeklyReservationResponse } from '../api/participation-types'; + postJoinStudy, +} from '@/features/study/participation/api/get-participation-data'; +import { + JoinStudyRequest, + WeeklyReservationResponse, +} from '@/features/study/participation/api/participation-types'; + +// 스터디 신청 mutation +export const useJoinStudyMutation = () => { + return useMutation({ + mutationFn: (payload: JoinStudyRequest) => postJoinStudy(payload), + }); +}; +// 다음주차 스터디 신청 무한 스크롤 export function useInfiniteReservation(firstMemberId?: number, pageSize = 50) { return useInfiniteQuery({ queryKey: ['weeklyReservationMembers', { firstMemberId, pageSize }], @@ -44,11 +54,3 @@ export function useInfiniteReservation(firstMemberId?: number, pageSize = 50) { staleTime: 60 * 1000, }); } - -export const useStudyStatusQuery = () => { - return useQuery({ - queryKey: ['studyStatus'], - queryFn: getStudyStatus, - staleTime: 60 * 1000, - }); -}; diff --git a/src/features/study/schedule/model/use-schedule-query.ts b/src/features/study/schedule/model/use-schedule-query.ts new file mode 100644 index 00000000..68c9d28a --- /dev/null +++ b/src/features/study/schedule/model/use-schedule-query.ts @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query'; +import { + getDailyStudies, + getMonthlyStudyCalendar, + getStudyStatus, + getWeeklyParticipation, +} from '@/features/study/schedule/api/get-study-schedule'; +import { + GetDailyStudiesParams, + GetMonthlyCalendarParams, + MonthlyCalendarResponse, + StudyStatus, +} from '@/features/study/schedule/api/schedule-types'; + +// 스터디 주간 참여 유무 확인 query +export const useWeeklyParticipation = (params: string) => { + return useQuery({ + queryKey: ['weeklyParticipation', params], + queryFn: () => getWeeklyParticipation(params), + staleTime: 60 * 1000, + enabled: !!params, + }); +}; + +// 스터디 매칭 결과 조회 query +export const useDailyStudiesQuery = (params?: GetDailyStudiesParams) => { + return useQuery({ + queryKey: ['dailyStudies', params], + queryFn: () => getDailyStudies(params), + staleTime: 60 * 1000, + }); +}; + +// 스터디 캘린더 조회 query +export const useMonthlyStudyCalendarQuery = ( + params: GetMonthlyCalendarParams, +) => { + return useQuery({ + queryKey: ['monthlyStudyCalendar', params], + queryFn: () => getMonthlyStudyCalendar(params), + staleTime: 60 * 1000, + enabled: !!params?.year && !!params?.month, + }); +}; + +export const useStudyStatusQuery = () => { + return useQuery({ + queryKey: ['studyStatus'], + queryFn: getStudyStatus, + staleTime: 60 * 1000, + }); +}; From 17f2a87d3e22d194ba0988de93f56d46e6d4c841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:43:21 +0900 Subject: [PATCH 008/128] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/{ => interview}/model/interview.schema.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) rename src/features/study/{ => interview}/model/interview.schema.ts (85%) diff --git a/src/features/study/model/interview.schema.ts b/src/features/study/interview/model/interview.schema.ts similarity index 85% rename from src/features/study/model/interview.schema.ts rename to src/features/study/interview/model/interview.schema.ts index 4f8bef55..041d01c1 100644 --- a/src/features/study/model/interview.schema.ts +++ b/src/features/study/interview/model/interview.schema.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; +import type { + DailyStudyDetail, + StudyProgressStatus, +} from '@/features/study/interview/api/interview-types'; +import { STUDY_PROGRESS_OPTIONS } from '@/features/study/interview/const/interview-const'; 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({ From 247a4adfa350f4ef951659dd9fde214b0a37f834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:44:03 +0900 Subject: [PATCH 009/128] =?UTF-8?q?refactor:=20=EC=9D=B8=ED=84=B0=EB=B7=B0?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20UI=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => interview}/ui/status-badge-map.tsx | 2 +- .../{ => interview}/ui/study-done-modal.tsx | 17 ++++++++--------- .../{ => interview}/ui/study-ready-modal.tsx | 9 ++++++--- .../{ => interview}/ui/study-review-modal.tsx | 5 ++++- 4 files changed, 19 insertions(+), 14 deletions(-) rename src/features/study/{ => interview}/ui/status-badge-map.tsx (85%) rename src/features/study/{ => interview}/ui/study-done-modal.tsx (93%) rename src/features/study/{ => interview}/ui/study-ready-modal.tsx (93%) rename src/features/study/{ => interview}/ui/study-review-modal.tsx (99%) diff --git a/src/features/study/ui/status-badge-map.tsx b/src/features/study/interview/ui/status-badge-map.tsx similarity index 85% rename from src/features/study/ui/status-badge-map.tsx rename to src/features/study/interview/ui/status-badge-map.tsx index 19cbaa63..e786831e 100644 --- a/src/features/study/ui/status-badge-map.tsx +++ b/src/features/study/interview/ui/status-badge-map.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; +import { StudyProgressStatus } from '@/features/study/interview/api/interview-types'; import Badge from '@/shared/ui/badge'; -import { StudyProgressStatus } from '../api/types'; export function getStatusBadge(status: StudyProgressStatus): ReactNode { switch (status) { diff --git a/src/features/study/ui/study-done-modal.tsx b/src/features/study/interview/ui/study-done-modal.tsx similarity index 93% rename from src/features/study/ui/study-done-modal.tsx rename to src/features/study/interview/ui/study-done-modal.tsx index b3825ca6..f73bc34d 100644 --- a/src/features/study/ui/study-done-modal.tsx +++ b/src/features/study/interview/ui/study-done-modal.tsx @@ -5,24 +5,23 @@ import { XIcon } from 'lucide-react'; import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; +import type { + CompleteStudyRequest, + DailyStudyDetail, + StudyProgressStatus, +} from '@/features/study/interview/api/interview-types'; +import { STUDY_PROGRESS_OPTIONS } from '@/features/study/interview/const/interview-const'; +import { useUpdateDailyStudyMutation } from '@/features/study/interview/model/use-interview-query'; 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 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'; +} from '@/features/study/interview/model/interview.schema'; interface StudyDoneModalProps { data: DailyStudyDetail; diff --git a/src/features/study/ui/study-ready-modal.tsx b/src/features/study/interview/ui/study-ready-modal.tsx similarity index 93% rename from src/features/study/ui/study-ready-modal.tsx rename to src/features/study/interview/ui/study-ready-modal.tsx index 56631828..02d98ea6 100644 --- a/src/features/study/ui/study-ready-modal.tsx +++ b/src/features/study/interview/ui/study-ready-modal.tsx @@ -5,18 +5,21 @@ import { XIcon } from 'lucide-react'; import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; +import type { + DailyStudyDetail, + PrepareStudyRequest, +} from '@/features/study/interview/api/interview-types'; +import { useUpdateDailyStudyMutation } from '@/features/study/interview/model/use-interview-query'; 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 type { DailyStudyDetail, PrepareStudyRequest } from '../api/types'; import { StudyReadyFormSchema, type StudyReadyFormValues, buildStudyReadyDefaults, -} from '../model/interview.schema'; -import { useUpdateDailyStudyMutation } from '../model/use-study-query'; +} from '@/features/study/interview/model/interview.schema'; interface StudyReadyModalProps { data: DailyStudyDetail; diff --git a/src/features/study/ui/study-review-modal.tsx b/src/features/study/interview/ui/study-review-modal.tsx similarity index 99% rename from src/features/study/ui/study-review-modal.tsx rename to src/features/study/interview/ui/study-review-modal.tsx index 4bfd7ace..aa7f9666 100644 --- a/src/features/study/ui/study-review-modal.tsx +++ b/src/features/study/interview/ui/study-review-modal.tsx @@ -3,13 +3,16 @@ import { XIcon } from 'lucide-react'; import Image from 'next/image'; import { useState } from 'react'; +import { + EvalKeyword, + StudyEvaluationResponse, +} from '@/features/study/interview/api/interview-types'; import UserAvatar from '@/shared/ui/avatar'; import Button from '@/shared/ui/button'; import Checkbox from '@/shared/ui/checkbox'; import { TextAreaInput } from '@/shared/ui/input'; import ListItem from '@/shared/ui/list-item'; import { Modal } from '@/shared/ui/modal'; -import { EvalKeyword, StudyEvaluationResponse } from '../api/types'; import { useAddStudyReviewMutation, usePartnerStudyReviewQuery, From 8479243f2eaf6fb02ea0a5e0f5d2be8182db5d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:44:36 +0900 Subject: [PATCH 010/128] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BC=80=EC=A5=B4?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20UI=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/{ => schedule}/ui/data-selector.tsx | 0 .../study/{ => schedule}/ui/study-card.tsx | 14 ++++++++------ .../study/{ => schedule}/ui/today-study-card.tsx | 10 ++++------ 3 files changed, 12 insertions(+), 12 deletions(-) rename src/features/study/{ => schedule}/ui/data-selector.tsx (100%) rename src/features/study/{ => schedule}/ui/study-card.tsx (87%) rename src/features/study/{ => schedule}/ui/today-study-card.tsx (89%) diff --git a/src/features/study/ui/data-selector.tsx b/src/features/study/schedule/ui/data-selector.tsx similarity index 100% rename from src/features/study/ui/data-selector.tsx rename to src/features/study/schedule/ui/data-selector.tsx diff --git a/src/features/study/ui/study-card.tsx b/src/features/study/schedule/ui/study-card.tsx similarity index 87% rename from src/features/study/ui/study-card.tsx rename to src/features/study/schedule/ui/study-card.tsx index 453ccd4c..50f27367 100644 --- a/src/features/study/ui/study-card.tsx +++ b/src/features/study/schedule/ui/study-card.tsx @@ -2,17 +2,19 @@ import { getMonth, getDay, startOfWeek, getDate } from 'date-fns'; import { useMemo, useState } from 'react'; +import ReservationList from '@/features/study/participation/ui/reservation-list'; +import { + useStudyStatusQuery, + useWeeklyParticipation, +} from '@/features/study/schedule/model/use-schedule-query'; +import DateSelector from '@/features/study/schedule/ui/data-selector'; +import TodayStudyCard from '@/features/study/schedule/ui/today-study-card'; import { formatKoreaYMD, getKoreaDate, getKoreaDisplayMonday, } from '@/shared/lib/time'; -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'; +import StudyListSection from '../../../../widgets/home/study-list-table'; // 스터디 주차 구하는 함수 function getWeekly(date: Date): { month: number; week: number } { diff --git a/src/features/study/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx similarity index 89% rename from src/features/study/ui/today-study-card.tsx rename to src/features/study/schedule/ui/today-study-card.tsx index a1816bb4..9bb89839 100644 --- a/src/features/study/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -2,14 +2,12 @@ import { useEffect, useState } from 'react'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; -import { getStatusBadge } from '@/features/study/ui/status-badge-map'; +import { useDailyStudyDetailQuery } from '@/features/study/interview/model/use-interview-query'; +import { getStatusBadge } from '@/features/study/interview/ui/status-badge-map'; +import StudyDoneModal from '@/features/study/interview/ui/study-done-modal'; +import StudyReadyModal from '@/features/study/interview/ui/study-ready-modal'; import { getCookie } from '@/shared/tanstack-query/cookie'; -// TODO: FSD 의 import 바운더리를 넘어서 import 해야하는데, -// 해당 UI를 shared 등으로 빼던지 수정 필요 import UserAvatar from '@/shared/ui/avatar'; -import StudyDoneModal from './study-done-modal'; -import StudyReadyModal from './study-ready-modal'; -import { useDailyStudyDetailQuery } from '../model/use-study-query'; export default function TodayStudyCard({ studyDate }: { studyDate: string }) { const [memberId, setMemberId] = useState(null); From 1bae87e687105235a0c6fdc3b6a4011b0cc4b622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:45:00 +0900 Subject: [PATCH 011/128] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=B0=B8=EC=97=AC=20=EA=B4=80=EB=A0=A8=20UI=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/participation/ui/reservation-list.tsx | 4 ++-- .../ui/start-study-modal.tsx | 17 ++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) rename src/features/study/{ => participation}/ui/start-study-modal.tsx (97%) diff --git a/src/features/study/participation/ui/reservation-list.tsx b/src/features/study/participation/ui/reservation-list.tsx index 912afdef..92c6fb13 100644 --- a/src/features/study/participation/ui/reservation-list.tsx +++ b/src/features/study/participation/ui/reservation-list.tsx @@ -7,9 +7,9 @@ import { useUserProfileQuery, } from '@/entities/user/model/use-user-profile-query'; import ProfileDefault from '@/entities/user/ui/icon/profile-default.svg'; +import ReservationCard from '@/features/study/participation/ui/reservation-user-card'; +import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; 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 { diff --git a/src/features/study/ui/start-study-modal.tsx b/src/features/study/participation/ui/start-study-modal.tsx similarity index 97% rename from src/features/study/ui/start-study-modal.tsx rename to src/features/study/participation/ui/start-study-modal.tsx index 1330e84d..af53ff0d 100644 --- a/src/features/study/ui/start-study-modal.tsx +++ b/src/features/study/participation/ui/start-study-modal.tsx @@ -12,6 +12,14 @@ import { useTechStacksQuery, } from '@/features/my-page/model/use-update-user-profile-mutation'; +import { studySteps } from '@/features/study/participation/const/participation-const'; +import { + StartStudyFormSchema, + type StartStudyFormValues, + buildStartStudyDefaultValues, + toJoinStudyRequest, +} from '@/features/study/participation/model/start-study-form.schema'; +import { useJoinStudyMutation } from '@/features/study/participation/model/use-participation-query'; import Button from '@/shared/ui/button'; import { SingleDropdown, MultiDropdown } from '@/shared/ui/dropdown'; import FormField from '@/shared/ui/form/form-field'; @@ -19,15 +27,6 @@ import { BaseInput, TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; 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; From bf21c3930abc495743e7e5d36e9d0ff00e3397c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Thu, 4 Sep 2025 17:46:10 +0900 Subject: [PATCH 012/128] =?UTF-8?q?fix:=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(my)/my-study-review/page.tsx | 8 ++++---- app/page.tsx | 2 +- src/entities/user/ui/user-profile-modal.tsx | 2 +- src/features/study/interview/lib/use-reminder-review.tsx | 2 +- src/widgets/home/calendar.tsx | 2 +- src/widgets/home/sidebar.tsx | 2 +- src/widgets/home/study-list-table.tsx | 6 +++--- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/(my)/my-study-review/page.tsx b/app/(my)/my-study-review/page.tsx index 21cffeda..04dea53b 100644 --- a/app/(my)/my-study-review/page.tsx +++ b/app/(my)/my-study-review/page.tsx @@ -4,14 +4,14 @@ import Image from 'next/image'; import { useEffect, useRef, useState } from 'react'; import KeywordReview from '@/entities/user/ui/keyword-review'; import MoreKeywordReviewModal from '@/entities/user/ui/more-keyword-review-modal'; -import { MyReviewItem } from '@/features/study/api/types'; +import { MyReviewItem } from '@/features/study/interview/api/interview-types'; +import { formatKoreaRelativeTime } from '@/shared/lib/time'; +import UserAvatar from '@/shared/ui/avatar'; import { useMyNegativeKeywordsQuery, useMyReviewsInfinityQuery, useUserPositiveKeywordsQuery, -} from '@/features/study/model/use-review-query'; -import { formatKoreaRelativeTime } from '@/shared/lib/time'; -import UserAvatar from '@/shared/ui/avatar'; +} from '@/features/study/interview/model/use-review-query'; export default function MyStudyReview() { const { data: positiveKeywordsData } = useUserPositiveKeywordsQuery({ diff --git a/app/page.tsx b/app/page.tsx index 7c83339d..d3208bfd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { redirect } from 'next/navigation'; -import StudyCard from '@/features/study/ui/study-card'; +import StudyCard from '@/features/study/schedule/ui/study-card'; import { getLoginUserId } from '@/shared/lib/get-login-user'; import Banner from '@/widgets/home/banner'; import Sidebar from '@/widgets/home/sidebar'; diff --git a/src/entities/user/ui/user-profile-modal.tsx b/src/entities/user/ui/user-profile-modal.tsx index 041aaa84..d1d8ab70 100644 --- a/src/entities/user/ui/user-profile-modal.tsx +++ b/src/entities/user/ui/user-profile-modal.tsx @@ -9,11 +9,11 @@ import CakeIcon from '@/features/my-page/ui/icon/cake.svg'; import GithubIcon from '@/features/my-page/ui/icon/github-logo.svg'; import GlobeIcon from '@/features/my-page/ui/icon/globe-simple.svg'; import PhoneIcon from '@/features/my-page/ui/icon/phone.svg'; -import { useUserPositiveKeywordsQuery } from '@/features/study/model/use-review-query'; import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; import UserAvatar from '@/shared/ui/avatar'; import Badge from '@/shared/ui/badge'; import { Modal } from '@/shared/ui/modal'; +import { useUserPositiveKeywordsQuery } from '@/features/study/interview/model/use-review-query'; interface UserProfileModalProps { memberId: number; diff --git a/src/features/study/interview/lib/use-reminder-review.tsx b/src/features/study/interview/lib/use-reminder-review.tsx index 076af1e4..4ab66b54 100644 --- a/src/features/study/interview/lib/use-reminder-review.tsx +++ b/src/features/study/interview/lib/use-reminder-review.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { getKoreaDate } from '@/shared/lib/time'; -import { useShouldReviewPartnerQuery } from '../../model/use-review-query'; +import { useShouldReviewPartnerQuery } from '../model/use-review-query'; export const useReviewReminder = () => { const { data: shouldReview, isFetching } = useShouldReviewPartnerQuery(); diff --git a/src/widgets/home/calendar.tsx b/src/widgets/home/calendar.tsx index 402fd09d..9a0ba2a8 100644 --- a/src/widgets/home/calendar.tsx +++ b/src/widgets/home/calendar.tsx @@ -7,7 +7,7 @@ import { type CalendarDay as DayPickerDay, type Modifiers, } from 'react-day-picker'; -import { useMonthlyStudyCalendarQuery } from '@/features/study/model/use-study-query'; +import { useMonthlyStudyCalendarQuery } from '@/features/study/schedule/model/use-schedule-query'; import { cn } from '@/shared/shadcn/lib/utils'; import { Calendar as ShadcnCalendar } from '@/shared/shadcn/ui/calendar'; diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index d6713d28..d9d9c113 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -2,7 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { getUserProfile } from '@/entities/user/api/get-user-profile'; import MyProfileCard from '@/entities/user/ui/my-profile-card'; -import StartStudyModal from '@/features/study/ui/start-study-modal'; +import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; import { getLoginUserId } from '@/shared/lib/get-login-user'; import Calendar from '@/widgets/home/calendar'; import TodoList from '@/widgets/home/todo-list'; diff --git a/src/widgets/home/study-list-table.tsx b/src/widgets/home/study-list-table.tsx index 12ec3d98..2e502322 100644 --- a/src/widgets/home/study-list-table.tsx +++ b/src/widgets/home/study-list-table.tsx @@ -1,9 +1,9 @@ -import { useDailyStudiesQuery } from '@/features/study/model/use-study-query'; -import { getStatusBadge } from '@/features/study/ui/status-badge-map'; +import { getStatusBadge } from '@/features/study/interview/ui/status-badge-map'; +import { DailyStudy } from '@/features/study/schedule/api/schedule-types'; +import { useDailyStudiesQuery } from '@/features/study/schedule/model/use-schedule-query'; import UserAvatar from '@/shared/ui/avatar'; import TableList from '@/shared/ui/table'; import LinkIcon from 'public/icons/Link.svg'; -import { DailyStudy } from '../../features/study/api/types'; const headers = [ '조', From 9a2f2d50fe4201dea032c4ccb543b368916d8d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=AF=BC=EC=A3=BC?= Date: Fri, 5 Sep 2025 21:30:23 +0900 Subject: [PATCH 013/128] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/interview/api/get-review.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/study/interview/api/get-review.ts b/src/features/study/interview/api/get-review.ts index ff521549..0bdeb90b 100644 --- a/src/features/study/interview/api/get-review.ts +++ b/src/features/study/interview/api/get-review.ts @@ -1,4 +1,3 @@ -import { axiosInstance } from '@/shared/tanstack-query/axios'; import type { AddStudyReviewRequest, UserPositiveKeywordsResponse, @@ -9,7 +8,8 @@ import type { MyReviewsResponse, MyReviewsRequest, ShouldReviewPartnerResponse, -} from '../../api/types'; +} from '@/features/study/interview/api/interview-types'; +import { axiosInstance } from '@/shared/tanstack-query/axios'; export const getPartnerStudyReview = async (): Promise => { From 206653a1503507b1acda3d2d7ed9dee81e14ee1e Mon Sep 17 00:00:00 2001 From: yeun38 Date: Sat, 13 Sep 2025 18:19:47 +0900 Subject: [PATCH 014/128] =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83,?= =?UTF-8?q?=20=EB=A1=9C=EA=B3=A0,=20=ED=8C=8C=EB=B9=84=EC=BD=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 14 +++++++++++--- app/page.tsx | 2 +- public/favicon.ico | Bin 0 -> 307 bytes public/icons/logo.svg | 3 +++ public/icons/logo_title.svg | 11 +++++++++++ src/widgets/home/header.tsx | 27 +++++++++++++++++++-------- 6 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 public/favicon.ico create mode 100644 public/icons/logo.svg create mode 100644 public/icons/logo_title.svg diff --git a/app/layout.tsx b/app/layout.tsx index 47ab1f7f..3034103a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,10 +5,14 @@ import type { Metadata } from 'next'; import localFont from 'next/font/local'; import MainProvider from '@/app/provider'; import Header from '@/widgets/home/header'; +import { clsx } from 'clsx'; export const metadata: Metadata = { title: 'ZERO-ONE', description: '매일 아침을 함께 시작하는 1:1 기상 스터디 플랫폼, ZERO-ONE', + icons: { + icon: '/favicon.ico', + }, }; const pretendard = localFont({ @@ -27,10 +31,14 @@ export default function RootLayout({ return ( {GTM_ID && } - + -
-
{children}
+
+
+
{children}
+
diff --git a/app/page.tsx b/app/page.tsx index 80303a27..954a7e2b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,7 +10,7 @@ export const metadata: Metadata = { export default async function Home() { return ( -
+
diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..20bbeb24d829942c3921585ee8e66064f2b413d5 GIT binary patch literal 307 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?3oVGw3ym^DWND9BhG zm2}pBDXB*51W%aQSiDJ8Sr^#lO|$TAm`y$o? + + diff --git a/public/icons/logo_title.svg b/public/icons/logo_title.svg new file mode 100644 index 00000000..c39766b3 --- /dev/null +++ b/public/icons/logo_title.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/widgets/home/header.tsx b/src/widgets/home/header.tsx index bf629d3c..a089337a 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -5,6 +5,7 @@ import LoginModal from '@/features/auth/ui/login-modal'; import { getServerCookie } from '@/shared/lib/server-cookie'; import { isNumeric } from '@/shared/lib/validation'; import Button from '@/shared/ui/button'; +import Image from 'next/image'; export default async function Header() { const memberIdStr = await getServerCookie('memberId'); @@ -24,10 +25,21 @@ export default async function Header() { : 'profile-default.svg'; return ( -
-
-
- ZERO-ONE +
+
+
+ Logo + + Logo-title + + + BETA +
{/* 1차 MVP에선 사용하지 않아 제외 */} @@ -36,12 +48,11 @@ export default async function Header() { 마이스터디 */} -
- {/* 알림 기능을 구현하지 못해 주석 처리 */} - {/*
+ {/* 알림 기능을 구현하지 못해 주석 처리 */} + {/*
*/} - +
{isLoggedIn ? ( ) : ( From f920fa959bb92b7e394884fb71f1eca43a7a1869 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 14 Sep 2025 16:34:37 +0900 Subject: [PATCH 015/128] =?UTF-8?q?refactor:=20review=20feature=20->=20ent?= =?UTF-8?q?ities=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(my)/my-study-review/page.tsx | 4 +- .../review}/api/get-review.ts | 2 +- src/entities/review/api/review-types.ts | 91 ++++++++++++++++++ .../review}/lib/use-reminder-review.tsx | 0 .../review}/model/use-review-query.ts | 2 +- .../review}/ui/study-review-modal.tsx | 4 +- src/entities/user/ui/my-profile-card.tsx | 4 +- src/entities/user/ui/user-profile-modal.tsx | 2 +- .../study/interview/api/interview-types.ts | 92 ------------------- 9 files changed, 100 insertions(+), 101 deletions(-) rename src/{features/study/interview => entities/review}/api/get-review.ts (97%) create mode 100644 src/entities/review/api/review-types.ts rename src/{features/study/interview => entities/review}/lib/use-reminder-review.tsx (100%) rename src/{features/study/interview => entities/review}/model/use-review-query.ts (97%) rename src/{features/study/interview => entities/review}/ui/study-review-modal.tsx (99%) diff --git a/app/(my)/my-study-review/page.tsx b/app/(my)/my-study-review/page.tsx index 04dea53b..23c2bf88 100644 --- a/app/(my)/my-study-review/page.tsx +++ b/app/(my)/my-study-review/page.tsx @@ -2,16 +2,16 @@ import Image from 'next/image'; import { useEffect, useRef, useState } from 'react'; +import { MyReviewItem } from '@/entities/review/api/review-types'; import KeywordReview from '@/entities/user/ui/keyword-review'; import MoreKeywordReviewModal from '@/entities/user/ui/more-keyword-review-modal'; -import { MyReviewItem } from '@/features/study/interview/api/interview-types'; import { formatKoreaRelativeTime } from '@/shared/lib/time'; import UserAvatar from '@/shared/ui/avatar'; import { useMyNegativeKeywordsQuery, useMyReviewsInfinityQuery, useUserPositiveKeywordsQuery, -} from '@/features/study/interview/model/use-review-query'; +} from '@/entities/review/model/use-review-query'; export default function MyStudyReview() { const { data: positiveKeywordsData } = useUserPositiveKeywordsQuery({ diff --git a/src/features/study/interview/api/get-review.ts b/src/entities/review/api/get-review.ts similarity index 97% rename from src/features/study/interview/api/get-review.ts rename to src/entities/review/api/get-review.ts index 0bdeb90b..a53c290e 100644 --- a/src/features/study/interview/api/get-review.ts +++ b/src/entities/review/api/get-review.ts @@ -8,7 +8,7 @@ import type { MyReviewsResponse, MyReviewsRequest, ShouldReviewPartnerResponse, -} from '@/features/study/interview/api/interview-types'; +} from '@/entities/review/api/review-types'; import { axiosInstance } from '@/shared/tanstack-query/axios'; export const getPartnerStudyReview = diff --git a/src/entities/review/api/review-types.ts b/src/entities/review/api/review-types.ts new file mode 100644 index 00000000..20d478cc --- /dev/null +++ b/src/entities/review/api/review-types.ts @@ -0,0 +1,91 @@ +// 리뷰 관련 타입 +export interface EvalKeyword { + id: number; + keyword: string; + satisfactionId: number; + satisfactionLabel: string; +} + +interface Partner { + memberId: number; + memberName: string; + profileImageUrl: string; +} + +export interface StudyEvaluationResponse { + studySpaceId: number; + targetMembers: Partner[]; + studySubject: string; + startDate: string; // "yyyy-MM-dd" 형식 + endDate: string; // "yyyy-MM-dd" 형식 + satisfiedEvalKeywords: EvalKeyword[]; + notBadEvalKeywords: EvalKeyword[]; + unsatisfiedEvalKeywords: EvalKeyword[]; +} + +export interface AddStudyReviewRequest { + studySpaceId: number; + targetMemberId: number; + satisfactionId: 10 | 20 | 30; + keywordIds: number[]; + content?: string; +} + +interface Keyword { + id: number; + content: string; + count: number; +} + +export interface UserPositiveKeywordsRequest { + memberId?: number; + pageSize?: number; +} + +export interface UserPositiveKeywordsResponse { + totalCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null + reviewerCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null + keywords: Keyword[]; +} + +export interface MyNegativeKeywordsRequest { + pageSize?: number; +} + +export interface MyNegativeKeywordsResponse { + totalCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null + reviewerCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null + keywords: Keyword[]; +} + +export interface MyReviewWriter { + memberId: number; + memberName: string; + profileImageUrl: string; +} + +export interface MyReviewItem { + id: number; + writer: MyReviewWriter; + reviewedAt: string; // ISO 날짜 문자열 + content: string; + studySpaceId: number; + startDate: string; // YYYY-MM-DD + endDate: string; // YYYY-MM-DD + studySubjects: string[]; +} + +export interface MyReviewsRequest { + cursor: number | null; +} + +export interface MyReviewsResponse { + totalCount: number; + reviews: { + items: MyReviewItem[]; + nextCursor: number; + hasNext: boolean; + }; +} + +export type ShouldReviewPartnerResponse = boolean; diff --git a/src/features/study/interview/lib/use-reminder-review.tsx b/src/entities/review/lib/use-reminder-review.tsx similarity index 100% rename from src/features/study/interview/lib/use-reminder-review.tsx rename to src/entities/review/lib/use-reminder-review.tsx diff --git a/src/features/study/interview/model/use-review-query.ts b/src/entities/review/model/use-review-query.ts similarity index 97% rename from src/features/study/interview/model/use-review-query.ts rename to src/entities/review/model/use-review-query.ts index 984e518d..26d994a0 100644 --- a/src/features/study/interview/model/use-review-query.ts +++ b/src/entities/review/model/use-review-query.ts @@ -7,7 +7,7 @@ import { import { MyNegativeKeywordsRequest, UserPositiveKeywordsRequest, -} from '@/features/study/interview/api/interview-types'; +} from '@/entities/review/api/review-types'; import { getKoreaDate } from '@/shared/lib/time'; import { addStudyReview, diff --git a/src/features/study/interview/ui/study-review-modal.tsx b/src/entities/review/ui/study-review-modal.tsx similarity index 99% rename from src/features/study/interview/ui/study-review-modal.tsx rename to src/entities/review/ui/study-review-modal.tsx index aa7f9666..e5f7a0f8 100644 --- a/src/features/study/interview/ui/study-review-modal.tsx +++ b/src/entities/review/ui/study-review-modal.tsx @@ -6,7 +6,7 @@ import { useState } from 'react'; import { EvalKeyword, StudyEvaluationResponse, -} from '@/features/study/interview/api/interview-types'; +} from '@/entities/review/api/review-types'; import UserAvatar from '@/shared/ui/avatar'; import Button from '@/shared/ui/button'; import Checkbox from '@/shared/ui/checkbox'; @@ -16,7 +16,7 @@ import { Modal } from '@/shared/ui/modal'; import { useAddStudyReviewMutation, usePartnerStudyReviewQuery, -} from '../model/use-review-query'; +} from '@/entities/review/model/use-review-query'; interface FormState { studySpaceId: number; diff --git a/src/entities/user/ui/my-profile-card.tsx b/src/entities/user/ui/my-profile-card.tsx index b1b90435..606920eb 100644 --- a/src/entities/user/ui/my-profile-card.tsx +++ b/src/entities/user/ui/my-profile-card.tsx @@ -2,9 +2,9 @@ import Link from 'next/link'; import React, { useState } from 'react'; +import { useReviewReminder } from '@/entities/review/lib/use-reminder-review'; +import StudyReviewModal from '@/entities/review/ui/study-review-modal'; import { usePatchAutoMatchingMutation } from '@/entities/user/model/use-user-profile-query'; -import { useReviewReminder } from '@/features/study/interview/lib/use-reminder-review'; -import StudyReviewModal from '@/features/study/interview/ui/study-review-modal'; import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; import { cn } from '@/shared/shadcn/lib/utils'; import UserAvatar from '@/shared/ui/avatar'; diff --git a/src/entities/user/ui/user-profile-modal.tsx b/src/entities/user/ui/user-profile-modal.tsx index d1d8ab70..9385fbe2 100644 --- a/src/entities/user/ui/user-profile-modal.tsx +++ b/src/entities/user/ui/user-profile-modal.tsx @@ -13,7 +13,7 @@ import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-pr import UserAvatar from '@/shared/ui/avatar'; import Badge from '@/shared/ui/badge'; import { Modal } from '@/shared/ui/modal'; -import { useUserPositiveKeywordsQuery } from '@/features/study/interview/model/use-review-query'; +import { useUserPositiveKeywordsQuery } from '@/entities/review/model/use-review-query'; interface UserProfileModalProps { memberId: number; diff --git a/src/features/study/interview/api/interview-types.ts b/src/features/study/interview/api/interview-types.ts index f508a71c..1e4a678e 100644 --- a/src/features/study/interview/api/interview-types.ts +++ b/src/features/study/interview/api/interview-types.ts @@ -32,95 +32,3 @@ export interface CompleteStudyRequest { feedback: string; progressStatus: StudyProgressStatus; } - -// 리뷰 관련 타입 -export interface EvalKeyword { - id: number; - keyword: string; - satisfactionId: number; - satisfactionLabel: string; -} - -interface Partner { - memberId: number; - memberName: string; - profileImageUrl: string; -} - -export interface StudyEvaluationResponse { - studySpaceId: number; - targetMembers: Partner[]; - studySubject: string; - startDate: string; // "yyyy-MM-dd" 형식 - endDate: string; // "yyyy-MM-dd" 형식 - satisfiedEvalKeywords: EvalKeyword[]; - notBadEvalKeywords: EvalKeyword[]; - unsatisfiedEvalKeywords: EvalKeyword[]; -} - -export interface AddStudyReviewRequest { - studySpaceId: number; - targetMemberId: number; - satisfactionId: 10 | 20 | 30; - keywordIds: number[]; - content?: string; -} - -interface Keyword { - id: number; - content: string; - count: number; -} - -export interface UserPositiveKeywordsRequest { - memberId?: number; - pageSize?: number; -} - -export interface UserPositiveKeywordsResponse { - totalCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null - reviewerCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null - keywords: Keyword[]; -} - -export interface MyNegativeKeywordsRequest { - pageSize?: number; -} - -export interface MyNegativeKeywordsResponse { - totalCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null - reviewerCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null - keywords: Keyword[]; -} - -export interface MyReviewWriter { - memberId: number; - memberName: string; - profileImageUrl: string; -} - -export interface MyReviewItem { - id: number; - writer: MyReviewWriter; - reviewedAt: string; // ISO 날짜 문자열 - content: string; - studySpaceId: number; - startDate: string; // YYYY-MM-DD - endDate: string; // YYYY-MM-DD - studySubjects: string[]; -} - -export interface MyReviewsRequest { - cursor: number | null; -} - -export interface MyReviewsResponse { - totalCount: number; - reviews: { - items: MyReviewItem[]; - nextCursor: number; - hasNext: boolean; - }; -} - -export type ShouldReviewPartnerResponse = boolean; From 144b52201618608185d1aeac3cb33f0124ee6487 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 14 Sep 2025 16:35:10 +0900 Subject: [PATCH 016/128] =?UTF-8?q?fix:=20=EB=B3=80=EA=B2=BD=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20import=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/participation/model/start-study-form.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/study/participation/model/start-study-form.schema.ts b/src/features/study/participation/model/start-study-form.schema.ts index 5f30714c..a08e6e93 100644 --- a/src/features/study/participation/model/start-study-form.schema.ts +++ b/src/features/study/participation/model/start-study-form.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { JoinStudyRequest } from '@/features/study/participation/api/participation-types'; import { UrlSchema } from '@/shared/util/zod-schema'; -import { JoinStudyRequest } from '../../api/types'; export const StartStudyFormSchema = z.object({ selfIntroduction: z From 17c3134ef6bf7d467d2f2e857dd52e66380f368f Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 14 Sep 2025 16:40:48 +0900 Subject: [PATCH 017/128] =?UTF-8?q?fix:=20warning=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/user/model/use-user-profile-query.ts | 2 +- src/features/my-page/model/profile-form.schema.ts | 1 - src/features/my-page/ui/profile-edit-modal.tsx | 2 +- src/features/study/interview/ui/study-done-modal.tsx | 10 +++++----- src/features/study/interview/ui/study-ready-modal.tsx | 11 +++++------ 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/entities/user/model/use-user-profile-query.ts b/src/entities/user/model/use-user-profile-query.ts index d31a5f19..38d00741 100644 --- a/src/entities/user/model/use-user-profile-query.ts +++ b/src/entities/user/model/use-user-profile-query.ts @@ -42,7 +42,7 @@ export const usePatchAutoMatchingMutation = () => { }); } - return { prev }; + return { prev }; }, onError: (_err, { memberId }, ctx) => { diff --git a/src/features/my-page/model/profile-form.schema.ts b/src/features/my-page/model/profile-form.schema.ts index 51eee97d..5323a581 100644 --- a/src/features/my-page/model/profile-form.schema.ts +++ b/src/features/my-page/model/profile-form.schema.ts @@ -1,4 +1,3 @@ -import { FieldNamesMarkedBoolean } from 'react-hook-form'; import { z } from 'zod'; import type { MemberProfile } from '@/entities/user/api/types'; import { UrlSchema } from '@/shared/util/zod-schema'; diff --git a/src/features/my-page/ui/profile-edit-modal.tsx b/src/features/my-page/ui/profile-edit-modal.tsx index d6126b95..7d14986c 100644 --- a/src/features/my-page/ui/profile-edit-modal.tsx +++ b/src/features/my-page/ui/profile-edit-modal.tsx @@ -94,7 +94,7 @@ function ProfileEditForm({ const { handleSubmit, - formState: { isValid, isSubmitting, dirtyFields }, + formState: { isValid, isSubmitting }, } = methods; const onValidSubmit = async (values: ProfileFormValues) => { diff --git a/src/features/study/interview/ui/study-done-modal.tsx b/src/features/study/interview/ui/study-done-modal.tsx index f73bc34d..48583647 100644 --- a/src/features/study/interview/ui/study-done-modal.tsx +++ b/src/features/study/interview/ui/study-done-modal.tsx @@ -11,17 +11,17 @@ import type { StudyProgressStatus, } from '@/features/study/interview/api/interview-types'; import { STUDY_PROGRESS_OPTIONS } from '@/features/study/interview/const/interview-const'; +import { + StudyDoneFormSchema, + type StudyDoneFormValues, + buildStudyDoneDefaults, +} from '@/features/study/interview/model/interview.schema'; import { useUpdateDailyStudyMutation } from '@/features/study/interview/model/use-interview-query'; 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 { - StudyDoneFormSchema, - type StudyDoneFormValues, - buildStudyDoneDefaults, -} from '@/features/study/interview/model/interview.schema'; interface StudyDoneModalProps { data: DailyStudyDetail; diff --git a/src/features/study/interview/ui/study-ready-modal.tsx b/src/features/study/interview/ui/study-ready-modal.tsx index 02d98ea6..46019b5f 100644 --- a/src/features/study/interview/ui/study-ready-modal.tsx +++ b/src/features/study/interview/ui/study-ready-modal.tsx @@ -9,17 +9,16 @@ import type { DailyStudyDetail, PrepareStudyRequest, } from '@/features/study/interview/api/interview-types'; -import { useUpdateDailyStudyMutation } from '@/features/study/interview/model/use-interview-query'; -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 { StudyReadyFormSchema, type StudyReadyFormValues, buildStudyReadyDefaults, } from '@/features/study/interview/model/interview.schema'; +import { useUpdateDailyStudyMutation } from '@/features/study/interview/model/use-interview-query'; +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'; interface StudyReadyModalProps { data: DailyStudyDetail; From 22af899e842aa3c190c02bfed2bc2f2eb5374507 Mon Sep 17 00:00:00 2001 From: yeun38 Date: Fri, 19 Sep 2025 12:35:50 +0900 Subject: [PATCH 018/128] =?UTF-8?q?refator:=20=EC=BB=A8=ED=85=90=EC=B8=A0?= =?UTF-8?q?=201400px=EB=A1=9C=20=ED=99=94=EB=A9=B4=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(my)/layout.tsx | 6 +++--- app/layout.tsx | 13 +++++++------ app/page.tsx | 2 +- src/widgets/home/banner.tsx | 2 +- src/widgets/home/header.tsx | 8 +++++++- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/app/(my)/layout.tsx b/app/(my)/layout.tsx index c4c054fd..2c5c2b20 100644 --- a/app/(my)/layout.tsx +++ b/app/(my)/layout.tsx @@ -12,10 +12,10 @@ export default function MyLayout({ children: React.ReactNode; }>) { return ( -
+
-
- {children} +
+
{children}
); diff --git a/app/layout.tsx b/app/layout.tsx index 3034103a..7fda55f8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -31,13 +31,14 @@ export default function RootLayout({ return ( {GTM_ID && } - + -
-
-
{children}
+
+ {/** 1400 + 48*2 패딩 양옆 48로 임의적용 */} +
+
+
{children}
+
diff --git a/app/page.tsx b/app/page.tsx index 954a7e2b..78c5bae6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,7 +10,7 @@ export const metadata: Metadata = { export default async function Home() { return ( -
+
diff --git a/src/widgets/home/banner.tsx b/src/widgets/home/banner.tsx index a4bbe672..2b9b8f55 100644 --- a/src/widgets/home/banner.tsx +++ b/src/widgets/home/banner.tsx @@ -13,7 +13,7 @@ const bannerImages = [ export default function BannerCarousel() { return ( - + {bannerImages.map((src, i) => ( diff --git a/src/widgets/home/header.tsx b/src/widgets/home/header.tsx index a089337a..122b4b48 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -6,6 +6,7 @@ import { getServerCookie } from '@/shared/lib/server-cookie'; import { isNumeric } from '@/shared/lib/validation'; import Button from '@/shared/ui/button'; import Image from 'next/image'; +import clsx from 'clsx'; export default async function Header() { const memberIdStr = await getServerCookie('memberId'); @@ -25,7 +26,12 @@ export default async function Header() { : 'profile-default.svg'; return ( -
+
Logo From 4bcacd91221a8eeee87ad4a33fa0c789f010476c Mon Sep 17 00:00:00 2001 From: aken-you Date: Sat, 23 Aug 2025 01:30:49 +0900 Subject: [PATCH 019/128] =?UTF-8?q?feat:=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=94=94=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 5 + public/icons/book.svg | 3 + public/icons/empty-study-case.svg | 9 + public/icons/feedback.svg | 9 + public/icons/group.svg | 3 + public/icons/phone.svg | 3 + .../study/schedule/ui/today-study-card.tsx | 248 +++++++++++++----- 7 files changed, 213 insertions(+), 67 deletions(-) create mode 100644 public/icons/book.svg create mode 100644 public/icons/empty-study-case.svg create mode 100644 public/icons/feedback.svg create mode 100644 public/icons/group.svg create mode 100644 public/icons/phone.svg diff --git a/next.config.ts b/next.config.ts index 0ba97d79..b8d962c9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,6 +21,11 @@ const nextConfig: NextConfig = { hostname: 'test-api.zeroone.it.kr', pathname: '/**', }, + { + protocol: 'https', + hostname: 'api.zeroone.it.kr', + pathname: '/profile-image/**', + }, { protocol: 'https', hostname: 'www.zeroone.it.kr', diff --git a/public/icons/book.svg b/public/icons/book.svg new file mode 100644 index 00000000..c1e78a4d --- /dev/null +++ b/public/icons/book.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/empty-study-case.svg b/public/icons/empty-study-case.svg new file mode 100644 index 00000000..f49d60aa --- /dev/null +++ b/public/icons/empty-study-case.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/feedback.svg b/public/icons/feedback.svg new file mode 100644 index 00000000..e985f7ee --- /dev/null +++ b/public/icons/feedback.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/group.svg b/public/icons/group.svg new file mode 100644 index 00000000..14b74cce --- /dev/null +++ b/public/icons/group.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/phone.svg b/public/icons/phone.svg new file mode 100644 index 00000000..2800649d --- /dev/null +++ b/public/icons/phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index 9bb89839..ae66b814 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -1,94 +1,208 @@ 'use client'; -import { useEffect, useState } from 'react'; -import UserProfileModal from '@/entities/user/ui/user-profile-modal'; -import { useDailyStudyDetailQuery } from '@/features/study/interview/model/use-interview-query'; -import { getStatusBadge } from '@/features/study/interview/ui/status-badge-map'; -import StudyDoneModal from '@/features/study/interview/ui/study-done-modal'; -import StudyReadyModal from '@/features/study/interview/ui/study-ready-modal'; +import Image from 'next/image'; +import Link from 'next/link'; import { getCookie } from '@/shared/tanstack-query/cookie'; import UserAvatar from '@/shared/ui/avatar'; +import Badge from '@/shared/ui/badge'; +import { DailyStudyDetail } from '../../interview/api/interview-types'; +import { useDailyStudyDetailQuery } from '../../interview/model/use-interview-query'; +import { getStatusBadge } from '../../interview/ui/status-badge-map'; +import StudyDoneModal from '../../interview/ui/study-done-modal'; +import StudyReadyModal from '../../interview/ui/study-ready-modal'; export default function TodayStudyCard({ studyDate }: { studyDate: string }) { - const [memberId, setMemberId] = useState(null); - - useEffect(() => { - const id = getCookie('memberId'); - setMemberId(id ? Number(id) : null); - }, []); + const memberIdInCookie = getCookie('memberId'); + const memberId = memberIdInCookie ? Number(memberIdInCookie) : null; const { data: todayStudyData } = useDailyStudyDetailQuery(studyDate); if (!todayStudyData) return null; + // 내가 피면접자(답변하는 사람)인지 const isInterviewee = memberId === todayStudyData.intervieweeId; - const matchedUserId = isInterviewee - ? todayStudyData.interviewerId - : todayStudyData.intervieweeId; - - const matchedUserImage = isInterviewee - ? todayStudyData.interviewerImage - : todayStudyData.intervieweeImage; - - const matchedUsername = isInterviewee - ? todayStudyData.interviewerName - : todayStudyData.intervieweeName; + const partner = { + id: isInterviewee + ? todayStudyData.interviewerId + : todayStudyData.intervieweeId, + name: isInterviewee + ? todayStudyData.interviewerName + : todayStudyData.intervieweeName, + image: isInterviewee + ? todayStudyData.interviewerImage + : todayStudyData.intervieweeImage, + }; return ( -
+

오늘의 스터디

- {memberId !== null && - (isInterviewee ? ( - - ) : ( - - ))}
-
- - {`${todayStudyData.studySpaceId} 조`} - - - - - - {matchedUsername} - -
- } - /> - - {todayStudyData.subject} - - {getStatusBadge(todayStudyData.progressStatus ?? 'PENDING')} - +
+
+ group + + + 스터디 조 + + + + {todayStudyData.studySpaceId}조 + +
+ +
-
-
피드백
-

{todayStudyData.feedback ?? '-'}

+
+
+
+ 스터디 상세 + + 스터디 상세 + + {getStatusBadge(todayStudyData.progressStatus)} +
+ + {isInterviewee ? ( + + ) : ( + + )} +
+ + {todayStudyData.progressStatus === 'PENDING' && } + {todayStudyData.progressStatus !== 'PENDING' && ( +
+ + + {/* 오른쪽 - 피드백 */} + {todayStudyData.progressStatus === 'IN_PROGRESS' ? ( +
+

피드백

+ +
+ 스터디 시작 전 + + 수정하기를 눌러 피드백을 작성해주세요. + +
+
+ ) : ( +
+

피드백

+ +

+ {todayStudyData.feedback} +

+
+ )} + + +
+ )}
); +} - function InfoBox({ - label, - children, - }: { - label: string; - children: React.ReactNode; - }) { - return ( -
- {label} - {children} +function PartnerInfo({ + name, + image, + isInterviewee, +}: { + name: string; + image?: string; + isInterviewee: boolean; +}) { + return ( +
+
+ + +
+ {name} + {isInterviewee ? ( + 지원자 + ) : ( + 면접관 + )} +
- ); - } + +
+ + +
+
+ ); +} + +function BeforeStudy() { + return ( +
+ 스터디 시작 전 +

+ 스터디 시작 전입니다. + ‘준비하기’ 버튼을 눌러 스터디를 시작해 주세요. +

+
+ ); +} + +function StudySubject({ subject }: Pick) { + return ( +
+

스터디 주제

+

{subject}

+
+ ); +} + +function StudyLink({ link }: Pick) { + return ( +
+ 스터디 링크 + + +
+ 스터디 링크 + + {link} + +
+ +
+ ); } From 994dfc5f6481755d6b4bcc7a466f3320898fc82a Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 11 Sep 2025 00:38:00 +0900 Subject: [PATCH 020/128] =?UTF-8?q?refactor:=20=EC=98=A4=EB=8A=98=EC=9D=98?= =?UTF-8?q?=20=EC=8A=A4=ED=84=B0=EB=94=94=20=EC=83=81=EC=84=B8=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/schedule/ui/today-study-card.tsx | 132 ++++++++++-------- 1 file changed, 76 insertions(+), 56 deletions(-) diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index ae66b814..0f22dfc3 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; +import { isNumeric } from '@/shared/lib/validation'; import { getCookie } from '@/shared/tanstack-query/cookie'; import UserAvatar from '@/shared/ui/avatar'; import Badge from '@/shared/ui/badge'; @@ -13,11 +14,13 @@ import StudyReadyModal from '../../interview/ui/study-ready-modal'; export default function TodayStudyCard({ studyDate }: { studyDate: string }) { const memberIdInCookie = getCookie('memberId'); - const memberId = memberIdInCookie ? Number(memberIdInCookie) : null; + const memberId = isNumeric(memberIdInCookie) + ? Number(memberIdInCookie) + : null; const { data: todayStudyData } = useDailyStudyDetailQuery(studyDate); - if (!todayStudyData) return null; + if (!todayStudyData || memberId === null) return null; // 내가 피면접자(답변하는 사람)인지 const isInterviewee = memberId === todayStudyData.intervieweeId; @@ -56,69 +59,86 @@ export default function TodayStudyCard({ studyDate }: { studyDate: string }) {
-
-
-
- 스터디 상세 - - 스터디 상세 - - {getStatusBadge(todayStudyData.progressStatus)} -
+ +
+ ); +} - {isInterviewee ? ( - - ) : ( - - )} +function TodayStudyDetail({ + memberId, + studyDate, + ...todayStudyData +}: DailyStudyDetail & { memberId: number; studyDate: string }) { + // 내가 피면접자(답변하는 사람)인지 + const isInterviewee = memberId === todayStudyData.intervieweeId; + + return ( +
+
+
+ 스터디 상세 + + 스터디 상세 + + {getStatusBadge(todayStudyData.progressStatus)}
- {todayStudyData.progressStatus === 'PENDING' && } - {todayStudyData.progressStatus !== 'PENDING' && ( -
- - - {/* 오른쪽 - 피드백 */} - {todayStudyData.progressStatus === 'IN_PROGRESS' ? ( -
-

피드백

- -
- 스터디 시작 전 - - 수정하기를 눌러 피드백을 작성해주세요. - -
-
- ) : ( -
-

피드백

+ {isInterviewee ? ( + + ) : ( + + )} +
-

- {todayStudyData.feedback} -

+ {todayStudyData.progressStatus === 'PENDING' && } + {todayStudyData.progressStatus !== 'PENDING' && ( +
+ + + {/* 오른쪽 - 피드백 */} + {todayStudyData.progressStatus === 'IN_PROGRESS' ? ( +
+

피드백

+ +
+ 스터디 시작 전 + + 수정하기를 눌러 피드백을 작성해주세요. +
- )} +
+ ) : ( +
+

피드백

- -
- )} -
- +

+ {todayStudyData.feedback} +

+
+ )} + + +
+ )} +
); } From c8e5fca0649944e04c8de63c5a69119a2872d7e6 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 11 Sep 2025 01:02:54 +0900 Subject: [PATCH 021/128] =?UTF-8?q?refactor:=20TodayStudyDetail=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=82=B4=EB=B6=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8F=99=EC=8B=9C=EC=97=90=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EB=90=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 동시에 실행되지 않은 코드가 하나의 컴포넌트에 모여있어서 로직을 파악하기 힘듦 --- .../study/schedule/ui/today-study-card.tsx | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index 0f22dfc3..9d1d034b 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -63,23 +63,20 @@ export default function TodayStudyCard({ studyDate }: { studyDate: string }) { />
- + {isInterviewee ? ( + + ) : ( + + )} ); } -function TodayStudyDetail({ - memberId, +// 사용자가 면접자(질문하는 사람)이면 보여줄 컴포넌트 +function InterviewerStudyDetail({ studyDate, ...todayStudyData -}: DailyStudyDetail & { memberId: number; studyDate: string }) { - // 내가 피면접자(답변하는 사람)인지 - const isInterviewee = memberId === todayStudyData.intervieweeId; - +}: DailyStudyDetail & { studyDate: string }) { return (
@@ -96,14 +93,13 @@ function TodayStudyDetail({ {getStatusBadge(todayStudyData.progressStatus)}
- {isInterviewee ? ( - - ) : ( - - )} +
- {todayStudyData.progressStatus === 'PENDING' && } + {todayStudyData.progressStatus === 'PENDING' && ( + + )} + {todayStudyData.progressStatus !== 'PENDING' && (
@@ -142,6 +138,71 @@ function TodayStudyDetail({ ); } +// 사용자가 피면접자(답변하는 사람)이면 보여줄 컴포넌트 +function IntervieweeStudyDetail({ + studyDate, + ...todayStudyData +}: DailyStudyDetail & { studyDate: string }) { + return ( +
+
+
+ 스터디 상세 + + 스터디 상세 + + {getStatusBadge(todayStudyData.progressStatus)} +
+ + +
+ + {todayStudyData.progressStatus === 'PENDING' && ( + + )} + {todayStudyData.progressStatus !== 'PENDING' && ( +
+ + + {/* 오른쪽 - 피드백 */} + {todayStudyData.progressStatus === 'IN_PROGRESS' ? ( +
+

피드백

+ +
+ 스터디 시작 전 + + 아직 면접관이 피드백을 작성하지 않았어요. + +
+
+ ) : ( +
+

피드백

+ +

+ {todayStudyData.feedback} +

+
+ )} + + +
+ )} +
+ ); +} + function PartnerInfo({ name, image, @@ -179,7 +240,7 @@ function PartnerInfo({ ); } -function BeforeStudy() { +function PendingStudy({ isInterviewee }: { isInterviewee: boolean }) { return (

스터디 시작 전입니다. - ‘준비하기’ 버튼을 눌러 스터디를 시작해 주세요. + + {isInterviewee + ? '‘준비하기’ 버튼을 눌러 스터디를 시작해 주세요.' + : '아직 지원자가 면접 준비하기를 작성하지 않았어요.'} +

); From ac3b74a7bf8b8543c2a45dce483186d7816dc8ff Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 11 Sep 2025 01:23:36 +0900 Subject: [PATCH 022/128] =?UTF-8?q?refactor:=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=20JSX=EB=A5=BC=20=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/schedule/ui/today-study-card.tsx | 121 ++++++++---------- 1 file changed, 56 insertions(+), 65 deletions(-) diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index 9d1d034b..a5e20fe8 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -72,6 +72,46 @@ export default function TodayStudyCard({ studyDate }: { studyDate: string }) { ); } +const renderFeedback = ( + progressStatus: DailyStudyDetail['progressStatus'], + feedback: DailyStudyDetail['feedback'], + noFeedbackMessage: string, +) => { + switch (progressStatus) { + case 'IN_PROGRESS': { + return ( +
+

피드백

+ +
+ 스터디 시작 전 + + {noFeedbackMessage} + +
+
+ ); + } + case 'ABSENT': + case 'COMPLETE': { + return ( +
+

피드백

+ +

{feedback}

+
+ ); + } + default: + return null; + } +}; + // 사용자가 면접자(질문하는 사람)이면 보여줄 컴포넌트 function InterviewerStudyDetail({ studyDate, @@ -96,39 +136,16 @@ function InterviewerStudyDetail({
- {todayStudyData.progressStatus === 'PENDING' && ( - - )} - - {todayStudyData.progressStatus !== 'PENDING' && ( + {todayStudyData.progressStatus === 'PENDING' ? ( + + ) : (
- {/* 오른쪽 - 피드백 */} - {todayStudyData.progressStatus === 'IN_PROGRESS' ? ( -
-

피드백

- -
- 스터디 시작 전 - - 수정하기를 눌러 피드백을 작성해주세요. - -
-
- ) : ( -
-

피드백

- -

- {todayStudyData.feedback} -

-
+ {renderFeedback( + todayStudyData.progressStatus, + todayStudyData.feedback, + '수정하기를 눌러 피드백을 작성해주세요.', )} @@ -162,38 +179,16 @@ function IntervieweeStudyDetail({
- {todayStudyData.progressStatus === 'PENDING' && ( - - )} - {todayStudyData.progressStatus !== 'PENDING' && ( + {todayStudyData.progressStatus === 'PENDING' ? ( + + ) : (
- {/* 오른쪽 - 피드백 */} - {todayStudyData.progressStatus === 'IN_PROGRESS' ? ( -
-

피드백

- -
- 스터디 시작 전 - - 아직 면접관이 피드백을 작성하지 않았어요. - -
-
- ) : ( -
-

피드백

- -

- {todayStudyData.feedback} -

-
+ {renderFeedback( + todayStudyData.progressStatus, + todayStudyData.feedback, + '아직 면접관이 피드백을 작성하지 않았어요.', )} @@ -240,7 +235,7 @@ function PartnerInfo({ ); } -function PendingStudy({ isInterviewee }: { isInterviewee: boolean }) { +function BeforeStudy({ description }: { description: string }) { return (

스터디 시작 전입니다. - - {isInterviewee - ? '‘준비하기’ 버튼을 눌러 스터디를 시작해 주세요.' - : '아직 지원자가 면접 준비하기를 작성하지 않았어요.'} - + {description}

); From e46a997cb28c37b137217cb96f6377ba56d9f08e Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 11 Sep 2025 01:28:08 +0900 Subject: [PATCH 023/128] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=ED=81=B4=EB=A6=AD=ED=95=98=EB=A9=B4=20?= =?UTF-8?q?=EC=83=81=EB=8C=80=EB=B0=A9=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/schedule/ui/today-study-card.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index a5e20fe8..d562ae2d 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { isNumeric } from '@/shared/lib/validation'; import { getCookie } from '@/shared/tanstack-query/cookie'; import UserAvatar from '@/shared/ui/avatar'; @@ -57,6 +58,7 @@ export default function TodayStudyCard({ studyDate }: { studyDate: string }) {
전화걸기 - + + 프로필 보기 + + } + />
); From bd9bacd5cbb2290198b2d682d9dcf2b53088e18d Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 11 Sep 2025 01:32:09 +0900 Subject: [PATCH 024/128] =?UTF-8?q?style:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=20=EB=95=8C=20=EB=B3=B4=EC=97=AC=EC=A4=84=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/schedule/ui/today-study-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index d562ae2d..88219e10 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -147,7 +147,7 @@ function InterviewerStudyDetail({ {renderFeedback( todayStudyData.progressStatus, todayStudyData.feedback, - '수정하기를 눌러 피드백을 작성해주세요.', + '완료하기를 눌러 피드백을 작성해주세요.', )} From 4874f732c588aaab03b2ff0f4ac549a6dd8a6cc1 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 11 Sep 2025 01:35:56 +0900 Subject: [PATCH 025/128] =?UTF-8?q?refactor:=20renderFeedback=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A1=B4=EC=9E=AC=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=EB=A1=9C=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=ED=8C=90=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/schedule/ui/today-study-card.tsx | 61 ++++++++----------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index 88219e10..f4da512d 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -75,43 +75,36 @@ export default function TodayStudyCard({ studyDate }: { studyDate: string }) { } const renderFeedback = ( - progressStatus: DailyStudyDetail['progressStatus'], feedback: DailyStudyDetail['feedback'], noFeedbackMessage: string, ) => { - switch (progressStatus) { - case 'IN_PROGRESS': { - return ( -
-

피드백

- -
- 스터디 시작 전 - - {noFeedbackMessage} - -
-
- ); - } - case 'ABSENT': - case 'COMPLETE': { - return ( -
-

피드백

- -

{feedback}

-
- ); - } - default: - return null; + if (feedback) { + return ( +
+

피드백

+ +

{feedback}

+
+ ); } + + return ( +
+

피드백

+ +
+ 스터디 시작 전 + + {noFeedbackMessage} + +
+
+ ); }; // 사용자가 면접자(질문하는 사람)이면 보여줄 컴포넌트 @@ -145,7 +138,6 @@ function InterviewerStudyDetail({ {renderFeedback( - todayStudyData.progressStatus, todayStudyData.feedback, '완료하기를 눌러 피드백을 작성해주세요.', )} @@ -188,7 +180,6 @@ function IntervieweeStudyDetail({ {renderFeedback( - todayStudyData.progressStatus, todayStudyData.feedback, '아직 면접관이 피드백을 작성하지 않았어요.', )} From a3eaa614ea03d39f68b6bdd692e3fe65b7c832ed Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 11 Sep 2025 23:55:51 +0900 Subject: [PATCH 026/128] =?UTF-8?q?feat:=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=AA=A8=EB=8B=AC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/ui/user-phone-number-copy-modal.tsx | 56 +++++++++++++++++++ .../study/schedule/ui/today-study-card.tsx | 23 ++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 src/entities/user/ui/user-phone-number-copy-modal.tsx diff --git a/src/entities/user/ui/user-phone-number-copy-modal.tsx b/src/entities/user/ui/user-phone-number-copy-modal.tsx new file mode 100644 index 00000000..6184bbec --- /dev/null +++ b/src/entities/user/ui/user-phone-number-copy-modal.tsx @@ -0,0 +1,56 @@ +import { XIcon } from 'lucide-react'; +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; + +export default function UserPhoneNumberCopyModal({ + trigger, + phoneNumber, +}: { + trigger: React.ReactNode; + phoneNumber: string; +}) { + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(phoneNumber); + alert('전화번호가 복사되었습니다.'); + } catch (e) { + alert('전화번호 복사에 실패하였습니다'); + } + }; + + return ( + + {trigger} + + + + + + + 전화하기 + + + + + + + +
+ + {phoneNumber} + + + +
+ + + 개인정보 보호를 위해 통화 시 주의해 주세요. + +
+
+
+
+ ); +} diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index f4da512d..5d6146fc 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; +import UserPhoneNumberCopyModal from '@/entities/user/ui/user-phone-number-copy-modal'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { isNumeric } from '@/shared/lib/validation'; import { getCookie } from '@/shared/tanstack-query/cookie'; @@ -57,10 +58,12 @@ export default function TodayStudyCard({ studyDate }: { studyDate: string }) {
+ {/* todo: phoneNumber 수정 */}
@@ -195,11 +198,13 @@ function PartnerInfo({ id, name, image, + phoneNumber, isInterviewee, }: { id: number; name: string; image?: string; + phoneNumber: string; isInterviewee: boolean; }) { return ( @@ -218,10 +223,20 @@ function PartnerInfo({
- + + 전화걸기 + 전화걸기 + + } + /> Date: Mon, 22 Sep 2025 23:04:20 +0900 Subject: [PATCH 027/128] =?UTF-8?q?feat:=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EC=9B=90=EC=9D=98=20=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/review/api/review-types.ts | 99 ++++++++++++++++++- .../study/interview/api/interview-types.ts | 1 + .../study/schedule/ui/today-study-card.tsx | 3 +- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/entities/review/api/review-types.ts b/src/entities/review/api/review-types.ts index 20d478cc..f14d3b52 100644 --- a/src/entities/review/api/review-types.ts +++ b/src/entities/review/api/review-types.ts @@ -1,4 +1,101 @@ -// 리뷰 관련 타입 +export type StudyProgressStatus = + | 'PENDING' + | 'IN_PROGRESS' + | 'COMPLETE' + | 'ABSENT'; + +export interface DailyStudy { + interviewer: string; + interviewerImage: string; + interviewee: string; + intervieweeImage: string; + dailyStudyId: number; + subject: string; + description: string; + link: string; + progressStatus: StudyProgressStatus; + studyDate: string; + feedback: string | undefined; +} + +export interface DailyStudyDetail { + dailyStudyId: number; + interviewerId: number; + interviewerName: string; + interviewerImage: string; + intervieweeId: number; + intervieweeName: string; + intervieweeImage: string; + studySpaceId: number; + partnerTel: string; + progressStatus: StudyProgressStatus; + subject: string; + description: string; + link: string; + feedback: string; +} + +export interface GetDailyStudiesParams { + cursor?: number; + pageSize?: number; + studyDate?: string; +} + +export interface GetDailyStudiesResponse { + items: DailyStudy[]; + nextCursor: number; + hasNext: boolean; +} + +export interface GetMonthlyCalendarParams { + year: number; + month: number; +} + +export interface StudyCalendarDay { + day: number; + hasStudy: boolean; + status: StudyProgressStatus | undefined; +} + +export interface MonthlyCalendarResponse { + calendar: StudyCalendarDay[]; + monthlyCompletedCount?: number; + totalCompletedCount?: number; +} + +export interface PostDailyRetrospectRequest { + description: string; + parentId: number; +} + +export interface PrepareStudyRequest { + subject: string; + link: string; +} + +export interface JoinStudyRequest { + memberId: number; + selfIntroduction?: string; + studyPlan?: string; + preferredStudySubjectId?: string; + availableStudyTimeIds?: number[]; + techStackIds?: number[]; + tel?: string; + githubLink?: string; + blogOrSnsLink?: string; +} + +export interface WeeklyParticipationResponse { + memberId: number; + isParticipate: boolean; +} + +export interface CompleteStudyRequest { + feedback: string; + progressStatus: StudyProgressStatus; +} + export interface EvalKeyword { id: number; keyword: string; diff --git a/src/features/study/interview/api/interview-types.ts b/src/features/study/interview/api/interview-types.ts index 1e4a678e..7556e251 100644 --- a/src/features/study/interview/api/interview-types.ts +++ b/src/features/study/interview/api/interview-types.ts @@ -13,6 +13,7 @@ export interface DailyStudyDetail { intervieweeId: number; intervieweeName: string; intervieweeImage: string; + partnerTel: string; studySpaceId: number; progressStatus: StudyProgressStatus; subject: string; diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx index 5d6146fc..c2fad11a 100644 --- a/src/features/study/schedule/ui/today-study-card.tsx +++ b/src/features/study/schedule/ui/today-study-card.tsx @@ -37,6 +37,7 @@ export default function TodayStudyCard({ studyDate }: { studyDate: string }) { image: isInterviewee ? todayStudyData.interviewerImage : todayStudyData.intervieweeImage, + tel: todayStudyData.partnerTel, }; return ( @@ -63,7 +64,7 @@ export default function TodayStudyCard({ studyDate }: { studyDate: string }) { id={partner.id} name={partner.name} image={partner.image} - phoneNumber="010-1234-5678" + phoneNumber={partner.tel} isInterviewee={partner.id === todayStudyData.intervieweeId} />
From 5593616822b5b12750448de045c7f881855b729a Mon Sep 17 00:00:00 2001 From: aken-you Date: Wed, 24 Sep 2025 19:16:29 +0900 Subject: [PATCH 028/128] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EA=B3=B3=EC=97=90=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?type=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/review/api/review-types.ts | 98 ------------------------- 1 file changed, 98 deletions(-) diff --git a/src/entities/review/api/review-types.ts b/src/entities/review/api/review-types.ts index f14d3b52..22123814 100644 --- a/src/entities/review/api/review-types.ts +++ b/src/entities/review/api/review-types.ts @@ -1,101 +1,3 @@ -export type StudyProgressStatus = - | 'PENDING' - | 'IN_PROGRESS' - | 'COMPLETE' - | 'ABSENT'; - -export interface DailyStudy { - interviewer: string; - interviewerImage: string; - interviewee: string; - intervieweeImage: string; - dailyStudyId: number; - subject: string; - description: string; - link: string; - progressStatus: StudyProgressStatus; - studyDate: string; - feedback: string | undefined; -} - -export interface DailyStudyDetail { - dailyStudyId: number; - interviewerId: number; - interviewerName: string; - interviewerImage: string; - intervieweeId: number; - intervieweeName: string; - intervieweeImage: string; - studySpaceId: number; - partnerTel: string; - progressStatus: StudyProgressStatus; - subject: string; - description: string; - link: string; - feedback: string; -} - -export interface GetDailyStudiesParams { - cursor?: number; - pageSize?: number; - studyDate?: string; -} - -export interface GetDailyStudiesResponse { - items: DailyStudy[]; - nextCursor: number; - hasNext: boolean; -} - -export interface GetMonthlyCalendarParams { - year: number; - month: number; -} - -export interface StudyCalendarDay { - day: number; - hasStudy: boolean; - status: StudyProgressStatus | undefined; -} - -export interface MonthlyCalendarResponse { - calendar: StudyCalendarDay[]; - monthlyCompletedCount?: number; - totalCompletedCount?: number; -} - -export interface PostDailyRetrospectRequest { - description: string; - parentId: number; -} - -export interface PrepareStudyRequest { - subject: string; - link: string; -} - -export interface JoinStudyRequest { - memberId: number; - selfIntroduction?: string; - studyPlan?: string; - preferredStudySubjectId?: string; - availableStudyTimeIds?: number[]; - techStackIds?: number[]; - tel?: string; - githubLink?: string; - blogOrSnsLink?: string; -} - -export interface WeeklyParticipationResponse { - memberId: number; - isParticipate: boolean; -} - -export interface CompleteStudyRequest { - feedback: string; - progressStatus: StudyProgressStatus; -} - export interface EvalKeyword { id: number; keyword: string; From 2627d4ec8c6e802846c81bc339ba5a51c0cbfe10 Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 28 Sep 2025 01:29:47 +0900 Subject: [PATCH 029/128] =?UTF-8?q?chore:=20@radix-ui/react-radio-group=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + yarn.lock | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/package.json b/package.json index d0ae9f0f..110b87bc 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-dialog": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-toggle": "^1.1.9", diff --git a/yarn.lock b/yarn.lock index fb80f80e..713ffc5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3024,6 +3024,34 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-radio-group@npm:^1.3.8": + version: 1.3.8 + resolution: "@radix-ui/react-radio-group@npm:1.3.8" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-presence": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-roving-focus": "npm:1.1.11" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/23af8e8b833da1fc4aa4e67c3607dedee4fc5b39278d2e2b820bec7f7b3c0891b006a8a35c57ba436ddf18735bbd8dad9a598d14632a328753a875fde447975c + languageName: node + linkType: hard + "@radix-ui/react-roving-focus@npm:1.1.10": version: 1.1.10 resolution: "@radix-ui/react-roving-focus@npm:1.1.10" @@ -3051,6 +3079,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-roving-focus@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-roving-focus@npm:1.1.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/2cd43339c36e89a3bf1db8aab34b939113dfbde56bf3a33df2d74757c78c9489b847b1962f1e2441c67e41817d120cb6177943e0f655f47bc1ff8e44fd55b1a2 + languageName: node + linkType: hard + "@radix-ui/react-slot@npm:1.2.0": version: 1.2.0 resolution: "@radix-ui/react-slot@npm:1.2.0" @@ -10753,6 +10808,7 @@ __metadata: "@radix-ui/react-dialog": "npm:^1.1.10" "@radix-ui/react-dropdown-menu": "npm:^2.1.15" "@radix-ui/react-progress": "npm:^1.1.7" + "@radix-ui/react-radio-group": "npm:^1.3.8" "@radix-ui/react-slot": "npm:^1.2.2" "@radix-ui/react-switch": "npm:^1.2.4" "@radix-ui/react-toggle": "npm:^1.1.9" From 3965a01ef75440c624e124c2e56cc05f67004a43 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 01:33:07 +0900 Subject: [PATCH 030/128] =?UTF-8?q?feat:=20input=20disable=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=8B=9C=20=EC=BB=A4=EC=8A=A4=ED=85=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/input/base.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/shared/ui/input/base.tsx b/src/shared/ui/input/base.tsx index c0bbffae..1d6d667b 100644 --- a/src/shared/ui/input/base.tsx +++ b/src/shared/ui/input/base.tsx @@ -40,6 +40,9 @@ const inputVariants = cva( boxed: 'rounded-100 border px-150 border-border-default', bare: 'border-0 rounded-none px-0 focus-visible:outline-none shadow-none', }, + disabled: { + true: 'bg-background-disabled text-text-disabled border-border-disabled cursor-not-allowed', + }, }, defaultVariants: { color: 'default', @@ -64,7 +67,16 @@ export const BaseInput = React.forwardRef( return ( { onChange?.(e); From 71a4f5ac3b31eb51b4dba9b2568a11145fe1ab9b Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 01:33:19 +0900 Subject: [PATCH 031/128] =?UTF-8?q?feat:=20=EB=8B=A8=EC=9D=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/toggle/group.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/shared/ui/toggle/group.tsx b/src/shared/ui/toggle/group.tsx index 45076634..1b090346 100644 --- a/src/shared/ui/toggle/group.tsx +++ b/src/shared/ui/toggle/group.tsx @@ -9,15 +9,26 @@ export interface ToggleGroupProps { options: ToggleOption[]; value?: string[]; onChange?: (v: string[]) => void; + multiple?: boolean; } -function ToggleGroup({ options, value, onChange }: ToggleGroupProps) { +function ToggleGroup({ + options, + value, + onChange, + multiple = true, +}: ToggleGroupProps) { const selected = value ?? []; const toggle = (key: string) => { - const next = selected.includes(key) - ? selected.filter((x) => x !== key) - : [...selected, key]; + let next: string[]; + if (multiple) { + next = selected.includes(key) + ? selected.filter((x) => x !== key) + : [...selected, key]; + } else { + next = selected.includes(key) ? [] : [key]; + } onChange?.(next); }; From 33dc7c3a1fc2d894cb2a7c2e446e0b0196c67182 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 01:38:15 +0900 Subject: [PATCH 032/128] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=88=98=EC=A0=95=ED=95=84=EC=9A=94)=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20api=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=20=ED=9B=84=20=EC=88=98=EC=A0=95=EC=9D=B4=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/model/open-group-form.schema.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/features/study/group/model/open-group-form.schema.ts diff --git a/src/features/study/group/model/open-group-form.schema.ts b/src/features/study/group/model/open-group-form.schema.ts new file mode 100644 index 00000000..a6c97940 --- /dev/null +++ b/src/features/study/group/model/open-group-form.schema.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { OpenGroupRequest } from '../api/group-types'; +import { + EXPERIENCE_LEVEL_OPTIONS, + METHOD_OPTIONS, + REGULAR_MEETING_OPTIONS, + TYPE_OPTIONS, +} from '../const/group-const'; + +// YYYY-MM-DD 형식 검증 +const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +export const OpenGroupFormSchema = z.object({ + type: z.enum(TYPE_OPTIONS), + targetRole: z.array(z.string()).min(1, '역할을 1개 이상 선택해 주세요.'), + maxMembers: z + .string() + .trim() + .regex(/^[1-9]\d*$/, '최소 1 이상의 정수를 입력해 주세요.'), + experienceLevel: z.enum(EXPERIENCE_LEVEL_OPTIONS), + method: z.enum(METHOD_OPTIONS), + regularMeeting: z.enum(REGULAR_MEETING_OPTIONS), + startDate: z + .string() + .trim() + .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 시작일을 입력해 주세요.'), + durationWeeks: z + .string() + .trim() + .regex(/^[1-9]\d*$/, '기간은 1 이상 정수로 입력해 주세요.'), + price: z + .string() + .trim() + .regex(/^\d+$/, '가격은 0 이상 정수로 입력해 주세요.'), +}); + +export type OpenGroupFormValues = z.infer; + +export function buildOpenGroupDefaultValues(): OpenGroupFormValues { + return { + type: '프로젝트', + targetRole: [], + maxMembers: '', + experienceLevel: '주니어', + method: '온라인', + regularMeeting: '주1회', + startDate: '', + durationWeeks: '', + price: '', + }; +} + +export function toOpenGroupRequest(v: OpenGroupFormValues): OpenGroupRequest { + return { + type: v.type, + targetRole: v.targetRole.join(','), + maxMembers: Number(v.maxMembers), + experienceLevel: v.experienceLevel, + method: v.method, + regularMeeting: v.regularMeeting, + startDate: v.startDate.trim(), + durationWeeks: Number(v.durationWeeks), + price: Number(v.price), + }; +} From 881e717dd09c810f40560b48a25cafdc7894e378 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 01:38:27 +0900 Subject: [PATCH 033/128] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=83=9D=EC=84=B1=20const=20=EA=B0=92=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/group/const/group-const.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/features/study/group/const/group-const.ts diff --git a/src/features/study/group/const/group-const.ts b/src/features/study/group/const/group-const.ts new file mode 100644 index 00000000..9d32fac3 --- /dev/null +++ b/src/features/study/group/const/group-const.ts @@ -0,0 +1,30 @@ +export const TYPE_OPTIONS = [ + '프로젝트', + '멘토링', + '세미나', + '챌린지', + '책스터디', + '강의스터디', +] as const; + +export const EXPERIENCE_LEVEL_OPTIONS = [ + '입문자', + '취준생', + '주니어', + '미들', + '시니어', +] as const; + +export const METHOD_OPTIONS = ['온라인', '오프라인', '온오프라인'] as const; + +export const REGULAR_MEETING_OPTIONS = [ + '없음', + '주1회', + '주2회', + '주3회이상', +] as const; + +export type TypeOption = (typeof TYPE_OPTIONS)[number]; +export type ExperienceLevelOption = (typeof EXPERIENCE_LEVEL_OPTIONS)[number]; +export type MethodOption = (typeof METHOD_OPTIONS)[number]; +export type RegularMeetingOption = (typeof REGULAR_MEETING_OPTIONS)[number]; From 116fcf790a9f05e72077955f894a6a044dfeb6d8 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 01:38:49 +0900 Subject: [PATCH 034/128] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20request=20=ED=83=80=EC=9E=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=88=98=EC=A0=95=20=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/group/api/group-types.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/features/study/group/api/group-types.ts diff --git a/src/features/study/group/api/group-types.ts b/src/features/study/group/api/group-types.ts new file mode 100644 index 00000000..df93bd1f --- /dev/null +++ b/src/features/study/group/api/group-types.ts @@ -0,0 +1,18 @@ +import { + ExperienceLevelOption, + MethodOption, + RegularMeetingOption, + TypeOption, +} from '../const/group-const'; + +export interface OpenGroupRequest { + type: TypeOption; + targetRole: string; + maxMembers: number; + experienceLevel: ExperienceLevelOption; + method: MethodOption; + regularMeeting: RegularMeetingOption; + startDate: string; + durationWeeks: number; + price: number; +} From f10f9bedf5802f330d117732ec0e4e23d2de8dc3 Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 28 Sep 2025 01:53:07 +0900 Subject: [PATCH 035/128] =?UTF-8?q?feat:=20Radio=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/radio/index.tsx | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/shared/ui/radio/index.tsx diff --git a/src/shared/ui/radio/index.tsx b/src/shared/ui/radio/index.tsx new file mode 100644 index 00000000..fc8987d3 --- /dev/null +++ b/src/shared/ui/radio/index.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; +import { cva } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '@/shared/shadcn/lib/utils'; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +const radioGroupItemVariants = cva( + 'border-border-default hover:bg-fill-neutral-subtle-hover disabled:border-border-disabled disabled:bg-icon-disabled data-[state=checked]:border-border-success rounded-full border-2 bg-white data-[state=checked]:border-4', + { + variants: { + size: { + medium: 'h-[16px] w-[16px]', + large: 'h-[20px] w-[20px]', + }, + }, + defaultVariants: { + size: 'large', + }, + }, +); + +function RadioGroupItem({ + size = 'large', + className, + ...props +}: { size: 'medium' | 'large' } & React.ComponentProps< + typeof RadioGroupPrimitive.RadioGroupItem +>) { + return ( + + + + ); +} + +export { RadioGroup, RadioGroupItem }; From f3d09fa8cc45babdc8fa5b94a0054e3686a13ade Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 28 Sep 2025 01:53:22 +0900 Subject: [PATCH 036/128] =?UTF-8?q?feat:=20Radio=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=EB=B6=81=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stories/ui/radio.stories.tsx | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/stories/ui/radio.stories.tsx diff --git a/src/stories/ui/radio.stories.tsx b/src/stories/ui/radio.stories.tsx new file mode 100644 index 00000000..f9be2fe9 --- /dev/null +++ b/src/stories/ui/radio.stories.tsx @@ -0,0 +1,46 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import Button from '@/shared/ui/button'; +import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; + +const meta: Meta = { + component: RadioGroup, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [state, setState] = useState('default'); + + return ( +
+ +
+ setState('first')} + /> + +
+
+ setState('second')} + /> + + +
+
+ + +
+ ); + }, +}; From 5ed3e602e2e4f58bb334db64c2d093c9c8fd4aad Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 28 Sep 2025 01:54:39 +0900 Subject: [PATCH 037/128] =?UTF-8?q?fix:=20RadioGroupItem=EC=9D=98=20size?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=EC=9D=84=20=EC=98=B5=EC=85=94=EB=84=90?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/radio/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/ui/radio/index.tsx b/src/shared/ui/radio/index.tsx index fc8987d3..4fb00dc1 100644 --- a/src/shared/ui/radio/index.tsx +++ b/src/shared/ui/radio/index.tsx @@ -37,7 +37,7 @@ function RadioGroupItem({ size = 'large', className, ...props -}: { size: 'medium' | 'large' } & React.ComponentProps< +}: { size?: 'medium' | 'large' } & React.ComponentProps< typeof RadioGroupPrimitive.RadioGroupItem >) { return ( From a87dff6e40b3107cc40a3132646b514ac2e453fd Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 28 Sep 2025 01:56:22 +0900 Subject: [PATCH 038/128] =?UTF-8?q?refactor:=20radio=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=B6=81=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stories/ui/radio.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stories/ui/radio.stories.tsx b/src/stories/ui/radio.stories.tsx index f9be2fe9..1e93dabf 100644 --- a/src/stories/ui/radio.stories.tsx +++ b/src/stories/ui/radio.stories.tsx @@ -13,11 +13,11 @@ type Story = StoryObj; export const Default: Story = { render: () => { - const [state, setState] = useState('default'); + const [state, setState] = useState<'first' | 'second'>('first'); return (
- +
Date: Sun, 28 Sep 2025 02:01:21 +0900 Subject: [PATCH 039/128] =?UTF-8?q?feat:=20RadioGroup=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=B6=81=20=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stories/ui/radio.stories.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/stories/ui/radio.stories.tsx b/src/stories/ui/radio.stories.tsx index 1e93dabf..3ba7d98b 100644 --- a/src/stories/ui/radio.stories.tsx +++ b/src/stories/ui/radio.stories.tsx @@ -5,6 +5,13 @@ import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; const meta: Meta = { component: RadioGroup, + parameters: { + docs: { + description: { + story: 'RadioGroupItem 컴포넌트의 id와 label의 htmlFor을 맞춰주세요.', + }, + }, + }, tags: ['autodocs'], }; From e878c28404722a3dbb354f66be93f2e234641fb9 Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 28 Sep 2025 02:08:47 +0900 Subject: [PATCH 040/128] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EB=B6=81=20RadioGroup=EC=9D=98=20onValueChange=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stories/ui/radio.stories.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/stories/ui/radio.stories.tsx b/src/stories/ui/radio.stories.tsx index 3ba7d98b..5c91f1c4 100644 --- a/src/stories/ui/radio.stories.tsx +++ b/src/stories/ui/radio.stories.tsx @@ -24,21 +24,17 @@ export const Default: Story = { return (
- + setState(value)} + >
- setState('first')} - /> +
- setState('second')} - /> +
From 2093d75f53de05da9e76e08e1d24d0edc1b472fe Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:08:56 +0900 Subject: [PATCH 041/128] =?UTF-8?q?fix:=20=EA=B2=BD=EB=A0=A5=20const=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/group/const/group-const.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/study/group/const/group-const.ts b/src/features/study/group/const/group-const.ts index 9d32fac3..6688b217 100644 --- a/src/features/study/group/const/group-const.ts +++ b/src/features/study/group/const/group-const.ts @@ -10,9 +10,10 @@ export const TYPE_OPTIONS = [ export const EXPERIENCE_LEVEL_OPTIONS = [ '입문자', '취준생', - '주니어', - '미들', - '시니어', + '주니어 (1~3년차)', + '중니어 (4~6년차)', + '시니어 (7년차 이상)', + '경력 무관', ] as const; export const METHOD_OPTIONS = ['온라인', '오프라인', '온오프라인'] as const; From 52760269b5a3b7398bd4a016f9fc46ce779615fc Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:09:06 +0900 Subject: [PATCH 042/128] =?UTF-8?q?feat:=20=EC=8A=A4=ED=85=9D=201=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/ui/step/step1-group.tsx | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/features/study/group/ui/step/step1-group.tsx diff --git a/src/features/study/group/ui/step/step1-group.tsx b/src/features/study/group/ui/step/step1-group.tsx new file mode 100644 index 00000000..da22ddd2 --- /dev/null +++ b/src/features/study/group/ui/step/step1-group.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useState } from 'react'; +import { SingleDropdown } from '@/shared/ui/dropdown'; +import FormField from '@/shared/ui/form/form-field'; +import { BaseInput } from '@/shared/ui/input'; +import { ToggleGroup } from '@/shared/ui/toggle'; +import { + EXPERIENCE_LEVEL_OPTIONS, + METHOD_OPTIONS, + REGULAR_MEETING_OPTIONS, + TYPE_OPTIONS, +} from '../../const/group-const'; +import { OpenGroupFormValues } from '../../model/open-group-form.schema'; + +const ROLE_OPTIONS = [ + { label: '백엔드', value: 'backend' }, + { label: '프론트엔드', value: 'frontend' }, + { label: '기획', value: 'planner' }, + { label: 'UX/UI 디자이너', value: 'uxui-designer' }, +]; + +const typeOptions = TYPE_OPTIONS.map((v) => ({ label: v, value: v })); +const expOptions = EXPERIENCE_LEVEL_OPTIONS.map((v) => ({ + label: v, + value: v, +})); +const methodOptions = METHOD_OPTIONS.map((v) => ({ label: v, value: v })); +const meetingOptions = REGULAR_MEETING_OPTIONS.map((v) => ({ + label: v, + value: v, +})); + +export default function Step1GroupStudy() { + const [method, setMethod] = useState(undefined); + + return ( + <> +
기본 정보 설정
+ + name="type" + label="스터디 유형" + helper="어떤 방식으로 진행되는 스터디인지 선택해주세요." + direction="vertical" + required + > + {/* todo: 여기 radio 변경 */} + + + + + name="targetRole" + label="모집 대상" + helper="함께하고 싶은 대상(직무·관심사 등)을 선택해주세요. (복수 선택 가능)" + direction="vertical" + required + > + + + + + name="maxMembers" + label="모집 인원" + // todo: 여기 description 변경되면 수정 필요 + helper="모집 인원을 입력해 주세요." + direction="vertical" + required + > + + + + + name="experienceLevel" + label="경력 여부" + helper="함께할 구성원의 경력 레벨을 선택해 주세요." + direction="vertical" + required + > + + + + + name="method" + label="진행 방식" + helper="스터디가 진행되는 방식을 선택해주세요. (예: 온라인, 오프라인 등)" + direction="vertical" + required + > +
+ + +
+ + + + name="regularMeeting" + label="정기 모임" + helper="정기적으로 모일 빈도를 선택해주세요." + direction="vertical" + required + > + {/* todo: 여기 radio 변경 */} + + + + + name="startDate" + label="진행 기간" + helper="스터디가 운영될 기간을 입력해주세요." + direction="vertical" + required + > +
+ +
~
+ +
+ + + ); +} From 4e5290769e10a62a6344c2a3ecab9b74f2d59630 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:09:20 +0900 Subject: [PATCH 043/128] =?UTF-8?q?feat:=20=EC=8A=A4=ED=85=9D=202=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/ui/step/step2-group.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/features/study/group/ui/step/step2-group.tsx diff --git a/src/features/study/group/ui/step/step2-group.tsx b/src/features/study/group/ui/step/step2-group.tsx new file mode 100644 index 00000000..78ff5254 --- /dev/null +++ b/src/features/study/group/ui/step/step2-group.tsx @@ -0,0 +1,51 @@ +'use client'; + +import FormField from '@/shared/ui/form/form-field'; +import { BaseInput, TextAreaInput } from '@/shared/ui/input'; +import { OpenGroupFormValues } from '../../model/open-group-form.schema'; + +export default function Step2GroupStudy() { + // todo: 그룹 폼 필드 변경해야 함 + return ( + <> +
+ 스터디 소개 작성 +
+ + name="type" + label="썸네일" + direction="vertical" + required + > + {/* todo: 여기 파일 업로드 변경 */} + + + + name="type" + label="스터디 제목" + direction="vertical" + required + > + + + + + name="type" + label="스터디 한 줄 소개" + direction="vertical" + required + > + + + + + name="type" + label="스터디 소개" + direction="vertical" + required + > + + + + ); +} From 4315d08d9df76c8de3ff0acdf1d2448ad43aaf2e Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:09:56 +0900 Subject: [PATCH 044/128] =?UTF-8?q?fix:=20=EA=B2=BD=EB=A0=A5=20=EC=9D=B8?= =?UTF-8?q?=ED=92=8B/=EC=95=84=EC=9B=83=ED=92=8B=20=EB=8B=A4=EB=A5=B4?= =?UTF-8?q?=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/model/open-group-form.schema.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/features/study/group/model/open-group-form.schema.ts b/src/features/study/group/model/open-group-form.schema.ts index a6c97940..9afc32d1 100644 --- a/src/features/study/group/model/open-group-form.schema.ts +++ b/src/features/study/group/model/open-group-form.schema.ts @@ -9,6 +9,7 @@ import { // YYYY-MM-DD 형식 검증 const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const ExperienceLevelEnum = z.enum(EXPERIENCE_LEVEL_OPTIONS); export const OpenGroupFormSchema = z.object({ type: z.enum(TYPE_OPTIONS), @@ -17,7 +18,10 @@ export const OpenGroupFormSchema = z.object({ .string() .trim() .regex(/^[1-9]\d*$/, '최소 1 이상의 정수를 입력해 주세요.'), - experienceLevel: z.enum(EXPERIENCE_LEVEL_OPTIONS), + experienceLevel: z + .union([ExperienceLevelEnum, z.literal('')]) + .transform((v) => (v === '' ? undefined : v)) + .pipe(ExperienceLevelEnum), method: z.enum(METHOD_OPTIONS), regularMeeting: z.enum(REGULAR_MEETING_OPTIONS), startDate: z @@ -34,14 +38,15 @@ export const OpenGroupFormSchema = z.object({ .regex(/^\d+$/, '가격은 0 이상 정수로 입력해 주세요.'), }); -export type OpenGroupFormValues = z.infer; +export type OpenGroupFormValues = z.input; +export type OpenGroupParsedValues = z.output; export function buildOpenGroupDefaultValues(): OpenGroupFormValues { return { type: '프로젝트', targetRole: [], maxMembers: '', - experienceLevel: '주니어', + experienceLevel: '', method: '온라인', regularMeeting: '주1회', startDate: '', @@ -50,7 +55,7 @@ export function buildOpenGroupDefaultValues(): OpenGroupFormValues { }; } -export function toOpenGroupRequest(v: OpenGroupFormValues): OpenGroupRequest { +export function toOpenGroupRequest(v: OpenGroupParsedValues): OpenGroupRequest { return { type: v.type, targetRole: v.targetRole.join(','), From 6376a90f1f295e59f91b7bc814d183aa33d2dddd Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:13:42 +0900 Subject: [PATCH 045/128] =?UTF-8?q?feat:=20=EC=8A=A4=ED=85=9D=203=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/ui/step/step3-group.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/features/study/group/ui/step/step3-group.tsx diff --git a/src/features/study/group/ui/step/step3-group.tsx b/src/features/study/group/ui/step/step3-group.tsx new file mode 100644 index 00000000..db8a1101 --- /dev/null +++ b/src/features/study/group/ui/step/step3-group.tsx @@ -0,0 +1,42 @@ +'use client'; + +import FormField from '@/shared/ui/form/form-field'; +import { TextAreaInput } from '@/shared/ui/input'; +import { OpenGroupFormValues } from '../../model/open-group-form.schema'; + +export default function Step3GroupStudy() { + // todo: 그룹 폼 필드 변경해야 함 + return ( + <> +
+ 지원 & 규칙 설정 +
+ + name="targetRole" + label="스터디원에게 보여줄 질문을 입력하세요" + helper="스터디 지원자가 신청 시 작성해야 할 질문을 설정하세요. (예: 지원 동기, 경험, 기대하는 점 등)" + direction="vertical" + required + > + + + +
+
+ 스터디 리더님, 개설 규칙을 준수해주세요. +
+
    +
  • 모집 공고의 정보는 사실에 기반해 작성해야 합니다.
  • +
  • 진행 기간과 모임 빈도를 명확히 안내해야 합니다.
  • +
  • 스터디 개설 후 최소 1회 이상 정기 모임을 진행해야 합니다.
  • +
  • 스터디원과의 약속을 존중하고 성실히 운영해야 합니다.
  • +
  • + 타인의 개인정보 및 자료를 무단으로 공유하거나 외부 유출해서는 안 + 됩니다. +
  • +
  • 플랫폼의 운영 정책 및 커뮤니티 가이드를 준수해야 합니다.
  • +
+
+ + ); +} From c5b14f3dfad51f8e0695bca1e93d2f00b92c76dd Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:22:51 +0900 Subject: [PATCH 046/128] =?UTF-8?q?feat:=20=ED=8F=B0=ED=8A=B8=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/ui/step/step1-group.tsx | 7 +++++ .../study/group/ui/step/step2-group.tsx | 4 +++ .../study/group/ui/step/step3-group.tsx | 1 + src/shared/ui/form/form-field.tsx | 31 ++++++++++++++++--- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/features/study/group/ui/step/step1-group.tsx b/src/features/study/group/ui/step/step1-group.tsx index da22ddd2..d9353046 100644 --- a/src/features/study/group/ui/step/step1-group.tsx +++ b/src/features/study/group/ui/step/step1-group.tsx @@ -42,6 +42,7 @@ export default function Step1GroupStudy() { label="스터디 유형" helper="어떤 방식으로 진행되는 스터디인지 선택해주세요." direction="vertical" + scale="medium" required > {/* todo: 여기 radio 변경 */} @@ -53,6 +54,7 @@ export default function Step1GroupStudy() { label="모집 대상" helper="함께하고 싶은 대상(직무·관심사 등)을 선택해주세요. (복수 선택 가능)" direction="vertical" + scale="medium" required > @@ -64,6 +66,7 @@ export default function Step1GroupStudy() { // todo: 여기 description 변경되면 수정 필요 helper="모집 인원을 입력해 주세요." direction="vertical" + scale="medium" required > @@ -74,6 +77,7 @@ export default function Step1GroupStudy() { label="경력 여부" helper="함께할 구성원의 경력 레벨을 선택해 주세요." direction="vertical" + scale="medium" required > @@ -84,6 +88,7 @@ export default function Step1GroupStudy() { label="진행 방식" helper="스터디가 진행되는 방식을 선택해주세요. (예: 온라인, 오프라인 등)" direction="vertical" + scale="medium" required >
@@ -102,6 +107,7 @@ export default function Step1GroupStudy() { label="정기 모임" helper="정기적으로 모일 빈도를 선택해주세요." direction="vertical" + scale="medium" required > {/* todo: 여기 radio 변경 */} @@ -113,6 +119,7 @@ export default function Step1GroupStudy() { label="진행 기간" helper="스터디가 운영될 기간을 입력해주세요." direction="vertical" + scale="medium" required >
diff --git a/src/features/study/group/ui/step/step2-group.tsx b/src/features/study/group/ui/step/step2-group.tsx index 78ff5254..25f058c4 100644 --- a/src/features/study/group/ui/step/step2-group.tsx +++ b/src/features/study/group/ui/step/step2-group.tsx @@ -15,6 +15,7 @@ export default function Step2GroupStudy() { name="type" label="썸네일" direction="vertical" + scale="medium" required > {/* todo: 여기 파일 업로드 변경 */} @@ -24,6 +25,7 @@ export default function Step2GroupStudy() { name="type" label="스터디 제목" direction="vertical" + scale="medium" required > @@ -33,6 +35,7 @@ export default function Step2GroupStudy() { name="type" label="스터디 한 줄 소개" direction="vertical" + scale="medium" required > @@ -42,6 +45,7 @@ export default function Step2GroupStudy() { name="type" label="스터디 소개" direction="vertical" + scale="medium" required > diff --git a/src/features/study/group/ui/step/step3-group.tsx b/src/features/study/group/ui/step/step3-group.tsx index db8a1101..fe95702b 100644 --- a/src/features/study/group/ui/step/step3-group.tsx +++ b/src/features/study/group/ui/step/step3-group.tsx @@ -16,6 +16,7 @@ export default function Step3GroupStudy() { label="스터디원에게 보여줄 질문을 입력하세요" helper="스터디 지원자가 신청 시 작성해야 할 질문을 설정하세요. (예: 지원 동기, 경험, 기대하는 점 등)" direction="vertical" + scale="medium" required > diff --git a/src/shared/ui/form/form-field.tsx b/src/shared/ui/form/form-field.tsx index a922c6f7..5cebba69 100644 --- a/src/shared/ui/form/form-field.tsx +++ b/src/shared/ui/form/form-field.tsx @@ -13,6 +13,22 @@ import { cn } from '@/shared/shadcn/lib/utils'; import { FieldControl, type ControlledChildProps } from './field-control'; type Direction = 'horizontal' | 'vertical'; +type Scale = 'small' | 'medium'; + +const TYPO = { + small: { + label: 'font-designer-14b', + helper: 'font-designer-14r', + foot: 'font-designer-13r', + counter: 'font-designer-13r', + }, + medium: { + label: 'font-designer-16b', + helper: 'font-designer-14r', + foot: 'font-designer-14r', + counter: 'font-designer-13r', + }, +} as const; export interface FormFieldProps< T extends FieldValues, @@ -27,6 +43,7 @@ export interface FormFieldProps< description?: React.ReactNode; required?: boolean; direction?: Direction; + scale?: Scale; id?: string; showCounterRight?: boolean; @@ -47,6 +64,7 @@ export default function FormField< description, required = false, direction = 'horizontal', + scale = 'small', id, children, showCounterRight = false, @@ -79,7 +97,7 @@ export default function FormField<
@@ -90,7 +108,7 @@ export default function FormField<
{helper && ( -
+
{helper}
)} @@ -104,10 +122,13 @@ export default function FormField< {children} -
+
@@ -115,7 +136,7 @@ export default function FormField<
{showCounterRight && typeof counterMax === 'number' && ( -
+
{currentLen}/{counterMax}
)} From c28518514a425ece7c7ff7eb12a8d7eac73ba397 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:23:53 +0900 Subject: [PATCH 047/128] =?UTF-8?q?fix:=20group=20=EB=8B=A8=EC=9D=BC/?= =?UTF-8?q?=EB=B3=B5=EC=88=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=ED=83=80=EC=9E=85=20=ED=99=95=EC=9E=A5=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/toggle/group.tsx | 74 +++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/src/shared/ui/toggle/group.tsx b/src/shared/ui/toggle/group.tsx index 1b090346..7df7c946 100644 --- a/src/shared/ui/toggle/group.tsx +++ b/src/shared/ui/toggle/group.tsx @@ -1,3 +1,4 @@ +import type { ComponentType, ReactNode } from 'react'; import ToggleButton from './button'; export interface ToggleOption { @@ -5,31 +6,63 @@ export interface ToggleOption { label: string; } -export interface ToggleGroupProps { +interface MultiProps { options: ToggleOption[]; + multiple?: true; value?: string[]; onChange?: (v: string[]) => void; - multiple?: boolean; + renderItem?: ComponentType; } -function ToggleGroup({ - options, - value, - onChange, - multiple = true, -}: ToggleGroupProps) { - const selected = value ?? []; +interface SingleProps { + options: ToggleOption[]; + multiple: false; + value?: string; + onChange?: (v: string | undefined) => void; + allowDeselect?: boolean; + emptyValue?: string | undefined; + renderItem?: ComponentType; +} + +export type ToggleGroupProps = MultiProps | SingleProps; + +export interface ItemRendererProps { + pressed: boolean; + onPress: () => void; + children: ReactNode; +} + +function DefaultToggleItem({ pressed, onPress, children }: ItemRendererProps) { + return ( + + {children} + + ); +} + +function ToggleGroup(props: ToggleGroupProps) { + const { options } = props; + const isMulti = props.multiple !== false; + const Item = props.renderItem ?? DefaultToggleItem; + + const selectedSet = new Set( + isMulti ? (props.value ?? []) : props.value ? [props.value] : [], + ); const toggle = (key: string) => { - let next: string[]; - if (multiple) { - next = selected.includes(key) - ? selected.filter((x) => x !== key) - : [...selected, key]; + if (isMulti) { + const curr = (props.value ?? []) as string[]; + const next = curr.includes(key) + ? curr.filter((x) => x !== key) + : [...curr, key]; + (props.onChange as ((v: string[]) => void) | undefined)?.(next); } else { - next = selected.includes(key) ? [] : [key]; + const curr = props.value as string | undefined; + const allowDeselect = props.allowDeselect ?? true; + const cleared = 'emptyValue' in props ? props.emptyValue : undefined; + const next = curr === key ? (allowDeselect ? cleared : key) : key; + (props.onChange as ((v: string | undefined) => void) | undefined)?.(next); } - onChange?.(next); }; return ( @@ -39,14 +72,9 @@ function ToggleGroup({ aria-label="toggle-group" > {options.map(({ value: v, label }) => ( - toggle(v)} - > + toggle(v)}> {label} - + ))}
); From 181ad38552bdd4b5e4d38e3982da39f1abd137fe Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:24:16 +0900 Subject: [PATCH 048/128] =?UTF-8?q?feat:=20=EC=8A=A4=ED=85=9D=20=EB=B3=84?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=20=EC=8A=A4=ED=84=B0=EB=94=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/ui/open-group-modal.tsx | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 src/features/study/group/ui/open-group-modal.tsx diff --git a/src/features/study/group/ui/open-group-modal.tsx b/src/features/study/group/ui/open-group-modal.tsx new file mode 100644 index 00000000..f673fec7 --- /dev/null +++ b/src/features/study/group/ui/open-group-modal.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { XIcon } from 'lucide-react'; +import { useState } from 'react'; +import { FormProvider, useForm, type SubmitHandler } from 'react-hook-form'; + +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; + +import { + OpenGroupFormSchema, + type OpenGroupFormValues, + buildOpenGroupDefaultValues, + toOpenGroupRequest, +} from '../model/open-group-form.schema'; +import Step1GroupStudy from './step/step1-group'; +import Step2GroupStudy from './step/step2-group'; +import Step3GroupStudy from './step/step3-group'; + +function Stepper({ step }: { step: 1 | 2 | 3 }) { + const dot = (n: 1 | 2 | 3) => { + const active = step === n; + + return ( +
+ {n} +
+ ); + }; + + return ( +
+ {[1, 2, 3].map((n) => dot(n as 1 | 2 | 3))} +
+ ); +} + +interface OpenGroupStudyModalProps { + memberId: number; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export default function OpenGroupStudyModal({ + memberId, + trigger, + open, + onOpenChange, +}: OpenGroupStudyModalProps) { + return ( + + {trigger ? {trigger} : null} + + + + + + 그룹 개설하기 + + + + + + onOpenChange?.(false)} /> + + + + ); +} + +function OpenGroupStudyForm({ onClose }: { onClose: () => void }) { + const methods = useForm({ + resolver: zodResolver(OpenGroupFormSchema), + mode: 'onChange', + defaultValues: buildOpenGroupDefaultValues(), + }); + const { handleSubmit, trigger, formState } = methods; + + const [step, setStep] = useState<1 | 2 | 3>(1); + + // 스텝별 검증 필드 지정 + const STEP_FIELDS: Record<1 | 2 | 3, (keyof OpenGroupFormValues)[]> = { + 1: [ + 'type', + 'targetRole', + 'maxMembers', + 'experienceLevel', + 'method', + 'regularMeeting', + 'startDate', + ], + 2: ['type'], + 3: ['price'], + }; + + const goNext = async () => { + const fields = STEP_FIELDS[step]; + const ok = await trigger(fields as any, { shouldFocus: true }); + if (!ok) return; + if (step < 3) setStep((s) => (s + 1) as 1 | 2 | 3); + }; + + const goPrev = () => { + if (step > 1) setStep((s) => (s - 1) as 1 | 2 | 3); + }; + + const onSubmit: SubmitHandler = (values) => { + const req = toOpenGroupRequest(values); + console.log('[DEV] OpenGroupRequest preview:', req); + alert('유효성 통과! (콘솔에서 요청 페이로드 미리보기 확인)'); + onClose(); + }; + + return ( + <> + + + + {...methods}> +
+ {step === 1 && } + {step === 2 && } + {step === 3 && } + + +
+ + +
+ {step > 1 && ( + + )} +
+ +
+ + + + {step < 3 ? ( + + ) : ( + + )} +
+
+ + ); +} From 396dfbcfda1fe0956a4b0d9cf095f0c16860e229 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Sun, 28 Sep 2025 03:26:04 +0900 Subject: [PATCH 049/128] =?UTF-8?q?style:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/home/sidebar.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index 2c95571a..c96352de 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; import MyProfileCard from '@/entities/user/ui/my-profile-card'; +import OpenGroupStudyModal from '@/features/study/group/ui/open-group-modal'; import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; import { getServerCookie } from '@/shared/lib/server-cookie'; import Calendar from '@/widgets/home/calendar'; @@ -59,6 +60,22 @@ export default async function Sidebar() { } /> )} + {/* todo: 편하게 확인하시라고 추가했습니다. 이후 목록 추가되고 연결되면 제가 지워서 커밋 올리도록 하겠습니다. */} + {/* +

+ + 그룹 스터디를 시작해 보세요! + + + 스터디 신청하기 + +

+ + } + /> */} Date: Sun, 28 Sep 2025 23:02:55 +0900 Subject: [PATCH 050/128] =?UTF-8?q?refactor:=20RadioGroup=EC=9D=98=20grid?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/radio/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/ui/radio/index.tsx b/src/shared/ui/radio/index.tsx index 4fb00dc1..54db934e 100644 --- a/src/shared/ui/radio/index.tsx +++ b/src/shared/ui/radio/index.tsx @@ -12,7 +12,7 @@ function RadioGroup({ return ( ); From 59ffb5370c9b907d697b22cd4541cb8c5aa18452 Mon Sep 17 00:00:00 2001 From: yeun38 Date: Sun, 28 Sep 2025 23:21:47 +0900 Subject: [PATCH 051/128] =?UTF-8?q?feat:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20UI=EA=B5=AC=ED=98=84=EC=A4=91=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(nav)/study/[id]/page.tsx | 3 + app/(nav)/study/page.tsx | 141 +++++++++++++++++++++++++++ app/layout.tsx | 4 +- src/entities/study/ui/study-card.tsx | 93 ++++++++++++++++++ src/shared/icons/plus.svg | 3 + src/widgets/home/header.tsx | 19 ++-- src/widgets/home/sidebar.tsx | 2 +- 7 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 app/(nav)/study/[id]/page.tsx create mode 100644 app/(nav)/study/page.tsx create mode 100644 src/entities/study/ui/study-card.tsx create mode 100644 src/shared/icons/plus.svg diff --git a/app/(nav)/study/[id]/page.tsx b/app/(nav)/study/[id]/page.tsx new file mode 100644 index 00000000..7addef93 --- /dev/null +++ b/app/(nav)/study/[id]/page.tsx @@ -0,0 +1,3 @@ +export default function StudyPage() { + return
Study Page
; +} diff --git a/app/(nav)/study/page.tsx b/app/(nav)/study/page.tsx new file mode 100644 index 00000000..73ce3539 --- /dev/null +++ b/app/(nav)/study/page.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import StudyCard, { GroupStudy } from '@/entities/study/ui/study-card'; +import Button from '@/shared/ui/button'; +import IconPlus from '@/shared/icons/plus.svg'; + +import Sidebar from '@/widgets/home/sidebar'; +const groupStudies: GroupStudy[] = [ + { + basicInfo: { + groupStudyId: 1, + type: 'frontend', + targetRole: 'junior', + maxMembers: 5, + experienceLevel: 'beginner', + method: 'online', + regularMeeting: '매주 화요일 8PM', + startDate: '2025-10-01', + durationWeeks: 6, + price: 50000, + status: 'recruiting', + createdAt: '2025-09-20T10:00:00Z', + updatedAt: '2025-09-25T12:30:00Z', + }, + simpleDetailInfo: { + title: 'React 입문 스터디', + summary: '기초부터 React를 배워보는 주니어 대상 스터디', + }, + }, + { + basicInfo: { + groupStudyId: 2, + type: 'backend', + targetRole: 'senior', + maxMembers: 8, + experienceLevel: 'intermediate', + method: 'offline', + regularMeeting: '매주 토요일 2PM', + startDate: '2025-11-05', + durationWeeks: 8, + price: 100000, + status: 'planned', + createdAt: '2025-09-22T09:00:00Z', + updatedAt: '2025-09-26T11:00:00Z', + }, + simpleDetailInfo: { + title: 'Spring Boot 중급 스터디', + summary: '실무 중심으로 스프링부트를 다뤄보는 스터디', + }, + }, + { + basicInfo: { + groupStudyId: 3, + type: 'fullstack', + targetRole: 'all', + maxMembers: 10, + experienceLevel: 'beginner', + method: 'hybrid', + regularMeeting: '격주 일요일 3PM', + startDate: '2025-12-01', + durationWeeks: 10, + price: 0, + status: 'recruiting', + createdAt: '2025-09-23T14:20:00Z', + updatedAt: '2025-09-27T15:40:00Z', + }, + simpleDetailInfo: { + title: '풀스택 웹 프로젝트 스터디', + summary: 'React + Node.js로 팀 프로젝트 경험 쌓기', + }, + }, + { + basicInfo: { + groupStudyId: 4, + type: 'ai', + targetRole: 'researcher', + maxMembers: 6, + experienceLevel: 'advanced', + method: 'online', + regularMeeting: '매주 수요일 9PM', + startDate: '2025-10-15', + durationWeeks: 12, + price: 200000, + status: 'in-progress', + createdAt: '2025-09-24T08:00:00Z', + updatedAt: '2025-09-28T09:30:00Z', + }, + simpleDetailInfo: { + title: '딥러닝 논문 스터디', + summary: '최신 AI 논문 리뷰와 구현을 목표로 하는 스터디', + }, + }, + { + basicInfo: { + groupStudyId: 5, + type: 'mobile', + targetRole: 'junior', + maxMembers: 4, + experienceLevel: 'beginner', + method: 'offline', + regularMeeting: '매주 금요일 7PM', + startDate: '2025-11-20', + durationWeeks: 5, + price: 30000, + status: 'recruiting', + createdAt: '2025-09-25T10:45:00Z', + updatedAt: '2025-09-27T18:00:00Z', + }, + simpleDetailInfo: { + title: 'Flutter 앱 개발 기초', + summary: 'Flutter로 간단한 앱을 함께 만들어보는 스터디', + }, + }, +]; + +export default function Study() { + return ( +
+
+
+ + 스터디 둘러보기 + + +
+
+ {groupStudies.map((study) => ( + + ))} +
+
+ +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 7fda55f8..316d31fd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -35,9 +35,9 @@ export default function RootLayout({
{/** 1400 + 48*2 패딩 양옆 48로 임의적용 */} -
+
-
{children}
+
{children}
diff --git a/src/entities/study/ui/study-card.tsx b/src/entities/study/ui/study-card.tsx new file mode 100644 index 00000000..d59a8a21 --- /dev/null +++ b/src/entities/study/ui/study-card.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import Badge from '@/shared/ui/badge'; + +export interface GroupStudy { + basicInfo: { + groupStudyId: number; // int64 + type: string; + targetRole: string; + maxMembers: number; // int32 + experienceLevel: string; + method: string; + regularMeeting: string; + startDate: string; // date + durationWeeks: number; // int32 + price: number; + status: string; + createdAt: string; // date-time + updatedAt: string; // date-time + }; + simpleDetailInfo: { + title: string; + summary: string; + }; +} + +export default function StudyCard(props: GroupStudy) { + const { basicInfo, simpleDetailInfo } = props; + + const basicInfoItems = [ + { + label: '유형', + value: + basicInfo.method === 'online' + ? '온라인' + : basicInfo.method === 'offline' + ? '오프라인' + : '혼합', + }, + { label: '주제', value: `${basicInfo.maxMembers}명` }, + { + label: '경력', + value: + basicInfo.experienceLevel === 'beginner' + ? '초급' + : basicInfo.experienceLevel === 'intermediate' + ? '중급' + : '고급', + }, + { label: '정기모임', value: `${basicInfo.durationWeeks}주` }, + { + label: '모집인원', + value: `/${basicInfo.maxMembers}`, + }, + { + label: '참가비', + value: + basicInfo.price === 0 + ? '무료' + : `${basicInfo.price.toLocaleString()}원`, + }, + ]; + + return ( +
+
+
+
+ + {simpleDetailInfo.title} + + 제로원 스터디 +
+

+ {simpleDetailInfo.summary} +

+
+
+ {basicInfoItems.map((item, idx) => ( +
+ + {item.label} + + + {item.value} + +
+ ))} +
+
+
img
+
+ ); +} diff --git a/src/shared/icons/plus.svg b/src/shared/icons/plus.svg new file mode 100644 index 00000000..897e8420 --- /dev/null +++ b/src/shared/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/widgets/home/header.tsx b/src/widgets/home/header.tsx index 122b4b48..fcd0b5d4 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -1,3 +1,5 @@ +import clsx from 'clsx'; +import Image from 'next/image'; import Link from 'next/link'; import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; import HeaderUserDropdown from '@/features/auth/ui/header-user-dropdown'; @@ -5,8 +7,6 @@ import LoginModal from '@/features/auth/ui/login-modal'; import { getServerCookie } from '@/shared/lib/server-cookie'; import { isNumeric } from '@/shared/lib/validation'; import Button from '@/shared/ui/button'; -import Image from 'next/image'; -import clsx from 'clsx'; export default async function Header() { const memberIdStr = await getServerCookie('memberId'); @@ -28,16 +28,16 @@ export default async function Header() { return (
- Logo + Logo Logo-title {/* 1차 MVP에선 사용하지 않아 제외 */} - {/* */} + {/* 알림 기능을 구현하지 못해 주석 처리 */} {/*
diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index 2c95571a..904d8d0f 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -14,7 +14,7 @@ export default async function Sidebar() { const userProfile = await getUserProfileInServer(memberId); return ( -
diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 9c263fa8..0e044210 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -36,7 +36,7 @@ const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( export default function MemberListTable() { const [roleId, setRoleId] = useState(null); const [memberStatus, setMemberStatus] = useState(null); - const [searchKeyword, setSearchKeyword] = useState(null); + const [searchKeyword, setSearchKeyword] = useState(''); const [page, setPage] = useState(1); const { data } = useGetMemberListQuery({ @@ -83,6 +83,23 @@ export default function MemberListTable() { return ( <> +
+
+

사용자 관리

+ + + {data?.totalElements} + + + 명의 사용자 + +
+ + +
); } + +function MemberListSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( + onChange(e.target.value)} + placeholder="이름, 이메일 입력" + /> + ); +} From 012ddbec7358840b42326209035fe147a6c26699 Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 17:30:44 +0900 Subject: [PATCH 073/128] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/admin/page.tsx | 2 +- public/icons/search.svg | 3 +++ src/features/admin/ui/member-list-table.tsx | 17 +++++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 public/icons/search.svg diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index 939636e0..2428c1be 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -13,7 +13,7 @@ export default async function AdminPage() { // 서버 side에서 첫 페이지 데이터 미리 가져오기 await queryClient.prefetchQuery({ - queryKey: ['memberList', null, null, null, 1], // memberList, roleId, memberStatus, searchKeyword, page + queryKey: ['memberList', null, null, '', 1], // memberList, roleId, memberStatus, searchKeyword, page queryFn: () => getMemberListInServer({}), }); diff --git a/public/icons/search.svg b/public/icons/search.svg new file mode 100644 index 00000000..1fe51ca0 --- /dev/null +++ b/public/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 0e044210..6067dcaa 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -7,6 +7,7 @@ import Button from '@/shared/ui/button'; import Checkbox from '@/shared/ui/checkbox'; import { SingleDropdown } from '@/shared/ui/dropdown'; import Pagination from '@/shared/ui/pagination'; +import SearchIcon from 'public/icons/search.svg'; import { MemberStatus, RoleId } from '../api/types'; import { useGetMemberListQuery } from '../model/use-member-list-query'; @@ -244,11 +245,15 @@ function MemberListSearchInput({ onChange: (v: string) => void; }) { return ( - onChange(e.target.value)} - placeholder="이름, 이메일 입력" - /> +
+ + onChange(e.target.value)} + className="outline-0" + placeholder="이름, 이메일 입력" + /> +
); } From 007a24b34d1711f06544973c38a84dbef04462ad Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 19:33:16 +0900 Subject: [PATCH 074/128] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=96=B4?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9A=B0=EB=8A=94=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/icons/filled-x.svg | 3 +++ src/features/admin/ui/member-list-table.tsx | 27 ++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 public/icons/filled-x.svg diff --git a/public/icons/filled-x.svg b/public/icons/filled-x.svg new file mode 100644 index 00000000..dcad8aa1 --- /dev/null +++ b/public/icons/filled-x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 6067dcaa..6915bbb8 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -7,6 +7,7 @@ import Button from '@/shared/ui/button'; import Checkbox from '@/shared/ui/checkbox'; import { SingleDropdown } from '@/shared/ui/dropdown'; import Pagination from '@/shared/ui/pagination'; +import FilledX from 'public/icons/filled-x.svg'; import SearchIcon from 'public/icons/search.svg'; import { MemberStatus, RoleId } from '../api/types'; import { useGetMemberListQuery } from '../model/use-member-list-query'; @@ -245,15 +246,23 @@ function MemberListSearchInput({ onChange: (v: string) => void; }) { return ( -
- - onChange(e.target.value)} - className="outline-0" - placeholder="이름, 이메일 입력" - /> +
+
+ + onChange(e.target.value)} + className="outline-0" + placeholder="이름 입력" + /> +
+ + {value.length > 0 && ( + + )}
); } From e4cf3166e6e8443d9e54fe3c12a8ea019887e892 Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 22:12:22 +0900 Subject: [PATCH 075/128] =?UTF-8?q?style:=20=EC=9D=B4=EB=A6=84=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=20input=20placeholder=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/ui/member-list-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 6915bbb8..d244a28f 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -254,7 +254,7 @@ function MemberListSearchInput({ value={value} onChange={(e) => onChange(e.target.value)} className="outline-0" - placeholder="이름 입력" + placeholder="이름으로 검색" />
From f10ae13ec528372b7256811e748d7b569c684b76 Mon Sep 17 00:00:00 2001 From: yeun38 Date: Wed, 8 Oct 2025 01:30:43 +0900 Subject: [PATCH 076/128] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=85=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=ED=95=9C=20api=20=EC=97=B0=EA=B2=B0=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(service)/(nav)/study/page.tsx | 18 +- src/entities/study/ui/study-card.tsx | 93 --------- .../study/group/api/get-group-study-list.ts | 21 ++ .../study/group/api/group-study-types.ts | 7 + .../study/group/ui/group-study-list.tsx | 179 ++++++++++++++++++ 5 files changed, 216 insertions(+), 102 deletions(-) delete mode 100644 src/entities/study/ui/study-card.tsx create mode 100644 src/features/study/group/api/get-group-study-list.ts create mode 100644 src/features/study/group/api/group-study-types.ts create mode 100644 src/features/study/group/ui/group-study-list.tsx diff --git a/app/(service)/(nav)/study/page.tsx b/app/(service)/(nav)/study/page.tsx index 73ce3539..b9686b5f 100644 --- a/app/(service)/(nav)/study/page.tsx +++ b/app/(service)/(nav)/study/page.tsx @@ -1,9 +1,13 @@ -import React from 'react'; -import StudyCard, { GroupStudy } from '@/entities/study/ui/study-card'; -import Button from '@/shared/ui/button'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import StudyCard, { + GroupStudy, +} from '@/features/study/group/ui/group-study-list'; +import { getGroupStudyList } from '@/features/study/group/api/get-group-study-list'; import IconPlus from '@/shared/icons/plus.svg'; - +import Button from '@/shared/ui/button'; import Sidebar from '@/widgets/home/sidebar'; +import GroupStudyList from '@/features/study/group/ui/group-study-list'; + const groupStudies: GroupStudy[] = [ { basicInfo: { @@ -129,11 +133,7 @@ export default function Study() { 스터디 개설하기
-
- {groupStudies.map((study) => ( - - ))} -
+
diff --git a/src/entities/study/ui/study-card.tsx b/src/entities/study/ui/study-card.tsx deleted file mode 100644 index bf0645c3..00000000 --- a/src/entities/study/ui/study-card.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import Badge from '@/shared/ui/badge'; - -export interface GroupStudy { - basicInfo: { - groupStudyId: number; // int64 - type: string; - targetRole: string; - maxMembers: number; // int32 - experienceLevel: string; - method: string; - regularMeeting: string; - startDate: string; // date - durationWeeks: number; // int32 - price: number; - status: string; - createdAt: string; // date-time - updatedAt: string; // date-time - }; - simpleDetailInfo: { - title: string; - summary: string; - }; -} - -export default function StudyCard(props: GroupStudy) { - const { basicInfo, simpleDetailInfo } = props; - - const basicInfoItems = [ - { - label: '유형', - value: - basicInfo.method === 'online' - ? '온라인' - : basicInfo.method === 'offline' - ? '오프라인' - : '혼합', - }, - { label: '주제', value: `${basicInfo.maxMembers}명` }, - { - label: '경력', - value: - basicInfo.experienceLevel === 'beginner' - ? '초급' - : basicInfo.experienceLevel === 'intermediate' - ? '중급' - : '고급', - }, - { label: '정기모임', value: `${basicInfo.durationWeeks}주` }, - { - label: '모집인원', - value: `/${basicInfo.maxMembers}`, - }, - { - label: '참가비', - value: - basicInfo.price === 0 - ? '무료' - : `${basicInfo.price.toLocaleString()}원`, - }, - ]; - - return ( -
-
-
-
- - {simpleDetailInfo.title} - - 제로원 스터디 -
-

- {simpleDetailInfo.summary} -

-
-
- {basicInfoItems.map((item, idx) => ( -
- - {item.label} - - - {item.value} - -
- ))} -
-
-
img
-
- ); -} diff --git a/src/features/study/group/api/get-group-study-list.ts b/src/features/study/group/api/get-group-study-list.ts new file mode 100644 index 00000000..fb687078 --- /dev/null +++ b/src/features/study/group/api/get-group-study-list.ts @@ -0,0 +1,21 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { GroupStudyListRequest } from './group-study-types'; + +// 그룹 스터디 리스트 조회 +export const getGroupStudyList = async (params: GroupStudyListRequest) => { + const { page, size, status } = params; + + const { data } = await axiosInstance.get('/group-studies', { + params: { + page, + size, + status, + }, + }); + + if (data.statusCode !== 200) { + throw new Error('Failed to fetch group study list'); + } + + return data.content; +}; diff --git a/src/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts new file mode 100644 index 00000000..0bc55091 --- /dev/null +++ b/src/features/study/group/api/group-study-types.ts @@ -0,0 +1,7 @@ +export type GroupStudyStatus = 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; + +export interface GroupStudyListRequest { + page: number; + size: number; + status: GroupStudyStatus; +} diff --git a/src/features/study/group/ui/group-study-list.tsx b/src/features/study/group/ui/group-study-list.tsx new file mode 100644 index 00000000..b96a685b --- /dev/null +++ b/src/features/study/group/ui/group-study-list.tsx @@ -0,0 +1,179 @@ +'use client'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import React, { Fragment } from 'react'; +import Badge from '@/shared/ui/badge'; +import { getGroupStudyList } from '../api/get-group-study-list'; + +enum Method { + ONLINE = '온라인', + OFFLINE = '오프라인', + HYBRID = '혼합', +} + +enum Type { + PROJECT = '프로젝트', + MENTORING = '멘토링', + SEMINAR = '세미나', + CHALLENGE = '챌린지', + BOOK_STUDY = '책 스터디', + LECTURE_STUDY = '강의 스터디', +} + +enum Frequency { + NONE = '없음', + WEEKLY = '주 1회', + BIWEEKLY = '주 2회', + TRIPLE_WEEKLY_OR_MORE = '주 3회 이상', +} + +export interface GroupStudy { + basicInfo: { + groupStudyId: number; // int64 + type: Type[keyof Type]; + targetRoles: string; + maxMembersCount: number; // int32 + experienceLevels: string[]; + method: Method[keyof Method]; + regularMeeting: Frequency[keyof Frequency]; + startDate: string; // date + durationWeeks: number; // int32 + price: number; + status: string; + createdAt: string; // date-time + updatedAt: string; // date-time + }; + simpleDetailInfo: { + title: string; + summary: string; + }; +} + +export default function GroupStudyList() { + const { data, fetchNextPage } = useInfiniteQuery({ + queryKey: ['groupStudies'], + queryFn: async ({ pageParam }) => { + const response = await getGroupStudyList({ + page: pageParam, + size: 20, + status: 'RECRUITING', + }); + + return response; + }, + getNextPageParam: (lastPage) => { + if (lastPage.hasNext) { + return lastPage.page + 1; + } + + return null; + }, + initialPageParam: 0, + maxPages: 3, + }); + + const basicInfoItems = (basicInfo) => [ + { + label: '유형', + value: Method[basicInfo.method as keyof typeof Method], + }, + { + label: '주제', + value: basicInfo.targetRoles + .map((role) => { + switch (role) { + case 'FRONTEND': + return '프론트엔드'; + case 'BACKEND': + return '백엔드'; + case 'PLANNER': + return '기획'; + case 'DESIGNER': + return '디자이너'; + } + }) + .join(', '), + }, + { + label: '경력', + value: + basicInfo.experienceLevels + .map((level) => { + switch (level) { + case 'BEGINNER': + return '입문자'; + case 'JUNIOR': + return '주니어'; + case 'MIDDLE': + return '미들레벨'; + case 'SENIOR': + return '시니어'; + case 'JOB_SEEKER': + return '취준생'; + default: + return level; + } + }) + .join(', ') || '무관', + }, + { + label: '정기모임', + value: `${Frequency[basicInfo.regularMeeting as keyof typeof Frequency]}`, + }, + { + label: '모집인원', + value: `/${basicInfo.maxMembersCount}`, + }, + { + label: '참가비', + value: + basicInfo.price === 0 + ? '무료' + : `${basicInfo.price.toLocaleString()}원`, + }, + ]; + + return ( +
+ + {data?.pages.map((page, i) => ( + + {page.content.map((study, index) => { + return ( +
+
+
+
+ + {study.simpleDetailInfo.title} + + 제로원 스터디 +
+

+ {study.simpleDetailInfo.summary} +

+
+
+ {basicInfoItems(study.basicInfo).map((item, idx) => ( +
+ + {item.label} + + + {item.value} + +
+ ))} +
+
+
img
+
+ ); + })} +
+ ))} +
+ ); +} From 6a597ec8c6c121237b464c5234e26b21529d9b19 Mon Sep 17 00:00:00 2001 From: yeun38 Date: Wed, 8 Oct 2025 02:50:10 +0900 Subject: [PATCH 077/128] =?UTF-8?q?feat:=20=EC=8B=A0=EC=B2=AD=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8/=EA=B1=B0=EC=A0=88=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(my)/my-study/entry-list/page.tsx | 8 ++- src/features/my-page/api/get-entry-list.ts | 24 +++++++++ src/features/my-page/api/types.ts | 13 +++++ .../my-page/api/update-entry-status.ts | 22 ++++++++ src/features/my-page/ui/entry-card.tsx | 21 ++++---- src/features/my-page/ui/entry-list.tsx | 50 +++++++++++++++++++ 6 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 src/features/my-page/api/get-entry-list.ts create mode 100644 src/features/my-page/api/update-entry-status.ts create mode 100644 src/features/my-page/ui/entry-list.tsx diff --git a/app/(service)/(my)/my-study/entry-list/page.tsx b/app/(service)/(my)/my-study/entry-list/page.tsx index 8c9a3c5f..9fde4dc7 100644 --- a/app/(service)/(my)/my-study/entry-list/page.tsx +++ b/app/(service)/(my)/my-study/entry-list/page.tsx @@ -1,7 +1,7 @@ +import EntryList from '@/features/my-page/ui/entry-list'; import Image from 'next/image'; -import EntryCard from '@/features/my-page/ui/entry-card'; -export default function EntryList() { +export default function EntryPage() { return (
@@ -14,9 +14,7 @@ export default function EntryList() {
새로운 신청자 확인하기
-
- -
+
); } diff --git a/src/features/my-page/api/get-entry-list.ts b/src/features/my-page/api/get-entry-list.ts new file mode 100644 index 00000000..7215fae8 --- /dev/null +++ b/src/features/my-page/api/get-entry-list.ts @@ -0,0 +1,24 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { EntryListRequest } from './types'; + +// 그룹 스터디 리스트 조회 +export const getEntryList = async (params: EntryListRequest) => { + const { page, size, status, groupStudyId } = params; + + const { data } = await axiosInstance.get( + `/group-studies/${groupStudyId}/applies`, + { + params: { + page, + size, + status, + }, + }, + ); + + if (data.statusCode !== 200) { + throw new Error('Failed to fetch entry list'); + } + + return data.content; +}; diff --git a/src/features/my-page/api/types.ts b/src/features/my-page/api/types.ts index 63315e17..f3b68e07 100644 --- a/src/features/my-page/api/types.ts +++ b/src/features/my-page/api/types.ts @@ -80,3 +80,16 @@ export interface StudyDashboardResponse { studyActivity: StudyActivity; growthMetric: GrowthMetric; } + +export interface EntryListRequest { + groupStudyId: number; + page: number; + size: number; + status?: 'PENDING'; +} + +export interface EntryStatusRequest { + groupStudyId: number; + applyId: number; + status: 'APPROVED' | 'REJECTED'; +} diff --git a/src/features/my-page/api/update-entry-status.ts b/src/features/my-page/api/update-entry-status.ts new file mode 100644 index 00000000..b0db8de6 --- /dev/null +++ b/src/features/my-page/api/update-entry-status.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { EntryStatusRequest } from './types'; + +// 그룹 스터디 리스트 조회 +export const getEntryList = async (params: EntryStatusRequest) => { + const { status, groupStudyId, applyId } = params; + + const { data } = await axiosInstance.patch( + `/group-studies/${groupStudyId}/apply/${applyId}/process`, + { + params: { + status, + }, + }, + ); + + if (data.statusCode !== 200) { + throw new Error('Failed to fetch entry list'); + } + + return data.content; +}; diff --git a/src/features/my-page/ui/entry-card.tsx b/src/features/my-page/ui/entry-card.tsx index cb285b22..5d1e9da7 100644 --- a/src/features/my-page/ui/entry-card.tsx +++ b/src/features/my-page/ui/entry-card.tsx @@ -5,9 +5,13 @@ import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-pr import { cn } from '@/shared/shadcn/lib/utils'; import Button from '@/shared/ui/button'; -export default function EntryCard() { - const temperPreset = getSincerityPresetByLevelName('3단계'); +export default function EntryCard(props: any) { + const { entry } = props; + const temperPreset = getSincerityPresetByLevelName( + entry.applicantInfo.sincerityTemp.levelName as string, + ); + console.log('entry', entry); const timeAgo = (date: string | Date): string => { const now = dayjs(); const target = dayjs(date); @@ -30,7 +34,7 @@ export default function EntryCard() { }; return ( -
+
- 40.0 ℃ + {`${entry.applicantInfo.sincerityTemp.temperature}`} ℃

- {timeAgo('2023-10-01 14:23:00')} + {timeAgo(entry.createdAt)}

-

- 프론트엔드 개발자로 2년째 일하고 있으며 주로 React와 TypeScript를 - 사용하고 있습니다. 최근에는 Next.js와 디자인 시스템에도 관심을 두고 - 있는데, 혼자 공부하기보다 다른 개발자들과 함께 코드 리뷰와 토론을 통해 - 깊이 있는 성장을 하고 싶어 이 스터디에 지원하게 되었습니다. -

+

{entry.answer}

+ )} +
+ ); +} From 7f9759b5bc0620c466899fadc436523c2fb44687 Mon Sep 17 00:00:00 2001 From: aken-you Date: Wed, 8 Oct 2025 17:16:51 +0900 Subject: [PATCH 078/128] =?UTF-8?q?fix:=20=EB=A3=A8=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20not-found=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/{ => (admin)}/not-found.tsx | 0 app/(service)/not-found.tsx | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) rename app/{ => (admin)}/not-found.tsx (100%) create mode 100644 app/(service)/not-found.tsx diff --git a/app/not-found.tsx b/app/(admin)/not-found.tsx similarity index 100% rename from app/not-found.tsx rename to app/(admin)/not-found.tsx diff --git a/app/(service)/not-found.tsx b/app/(service)/not-found.tsx new file mode 100644 index 00000000..48151d31 --- /dev/null +++ b/app/(service)/not-found.tsx @@ -0,0 +1,16 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import Button from '@/shared/ui/button'; + +export default function NotFound() { + return ( +
+ 404 에러 + + + +
+ ); +} From 6890717cce016a6504aff2d51046250c40308a3d Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Thu, 9 Oct 2025 20:05:48 +0900 Subject: [PATCH 079/128] =?UTF-8?q?fix:=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=97=90=20=EB=A7=9E=EA=B2=8C=20step1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/api/group-study-types.ts | 48 +++++++++ src/features/study/group/api/group-types.ts | 52 --------- src/features/study/group/const/group-const.ts | 26 ----- .../study/group/const/group-study-const.ts | 81 ++++++++++++++ .../group/model/open-group-form.schema.ts | 101 ++++++------------ .../study/group/ui/open-group-modal.tsx | 2 +- .../study/group/ui/step/step1-group.tsx | 42 ++++---- 7 files changed, 180 insertions(+), 172 deletions(-) create mode 100644 src/features/study/group/api/group-study-types.ts delete mode 100644 src/features/study/group/api/group-types.ts delete mode 100644 src/features/study/group/const/group-const.ts create mode 100644 src/features/study/group/const/group-study-const.ts diff --git a/src/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts new file mode 100644 index 00000000..196a236b --- /dev/null +++ b/src/features/study/group/api/group-study-types.ts @@ -0,0 +1,48 @@ +import { + EXPERIENCE_LEVEL_OPTIONS, + REGULAR_MEETINGS, + STUDY_METHODS, + STUDY_TYPES, + TARGET_ROLE_OPTIONS, + THUMBNAIL_EXTENSION, +} from '../const/group-study-const'; + +export type StudyType = (typeof STUDY_TYPES)[number]; +export type TargetRole = (typeof TARGET_ROLE_OPTIONS)[number]; +export type ExperienceLevel = (typeof EXPERIENCE_LEVEL_OPTIONS)[number]; +export type StudyMethod = (typeof STUDY_METHODS)[number]; +export type RegularMeeting = (typeof REGULAR_MEETINGS)[number]; +export type ThumbnailExtension = (typeof THUMBNAIL_EXTENSION)[number]; + +export interface BasicInfo { + type: StudyType; + targetRoles: TargetRole[]; + maxMembersCount: number; + experienceLevels: ExperienceLevel[]; + method: StudyMethod; + regularMeeting: RegularMeeting; + location: string; + startDate: string; + endDate: string; + price: number; + createdAt: string; + updatedAt: string; +} + +export interface DetailInfo { + title: string; + description: string; + summary: string; + thumbnailExtension: ThumbnailExtension; +} + +export interface InterviewPost { + interviewPost: string[]; +} + +export interface OpenGroupStudyRequest { + basicInfo: BasicInfo; + detailInfo: DetailInfo; + interviewPost: InterviewPost; + thumbnailExtension: ThumbnailExtension; +} diff --git a/src/features/study/group/api/group-types.ts b/src/features/study/group/api/group-types.ts deleted file mode 100644 index 070f01e2..00000000 --- a/src/features/study/group/api/group-types.ts +++ /dev/null @@ -1,52 +0,0 @@ -export interface BasicInfo { - groupStudyId: number; - type: string; - targetRoles: string[]; - maxMembersCount: number; - experienceLevels: string[]; - method: 'ONLINE' | 'OFFLINE' | 'HYBRID'; - regularMeeting: 'NONE' | 'WEEKLY' | 'BIWEEKLY' | 'TRIPLE_WEEKLY_OR_MORE'; - location: string; - startDate: string; - endDate: string; - price: number; - status: 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; - createdAt: string; - updatedAt: string; -} - -export interface DetailInfo { - thumbnail: { - imageId: number; - resizedImages: { - resizedImageId: number; - resizedImageUrl: string; - imageSizeType: { - imageTypeName: string; - width: number | undefined; - height: number | undefined; - }; - }[]; - }; - title: string; - description: string; - summary: string; -} - -export interface InterviewPost { - interviewPost: string; -} - -export interface OpenGroupRequest { - basicInfo: BasicInfo; - detailInfo: DetailInfo; - interviewPost: InterviewPost; - thumbnailExtension: - | 'DEFAULT' - | 'JPG' - | 'PNG' - | 'GIF' - | 'WEBP' - | 'SVG' - | 'JPEG'; -} diff --git a/src/features/study/group/const/group-const.ts b/src/features/study/group/const/group-const.ts deleted file mode 100644 index 0cd7a48f..00000000 --- a/src/features/study/group/const/group-const.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const STUDY_TYPES = [ - '프로젝트', - '멘토링', - '세미나', - '챌린지', - '책 스터디', - '강의 스터디', -] as const; - -export const EXPERIENCE_LEVEL_OPTIONS = [ - '입문자', - '취준생', - '주니어 (1~3년차)', - '중니어 (4~6년차)', - '시니어 (7년차 이상)', - '경력 무관', -] as const; - -export const METHOD_OPTIONS = ['온라인', '오프라인', '온오프라인'] as const; - -export const MEETING_OPTIONS = [ - '없음', - '주 1회', - '주 2회', - '주 3회 이상', -] as const; diff --git a/src/features/study/group/const/group-study-const.ts b/src/features/study/group/const/group-study-const.ts new file mode 100644 index 00000000..3de1f4c4 --- /dev/null +++ b/src/features/study/group/const/group-study-const.ts @@ -0,0 +1,81 @@ +export const STUDY_TYPES = [ + 'PROJECT', + 'MENTORING', + 'SEMINAR', + 'CHALLENGE', + 'BOOK_STUDY', + 'LECTURE_STUDY', +] as const; + +export const TARGET_ROLE_OPTIONS = [ + 'BACKEND', + 'FRONTEND', + 'PLANNER', + 'DESIGNER', +] as const; + +export const EXPERIENCE_LEVEL_OPTIONS = [ + 'BEGINNER', + 'JOB_SEEKER', + 'JUNIOR', + 'MIDDLE', + 'SENIOR', +] as const; + +export const STUDY_METHODS = ['ONLINE', 'OFFLINE', 'HYBRID'] as const; + +export const REGULAR_MEETINGS = [ + 'NONE', + 'WEEKLY', + 'BIWEEKLY', + 'TRIPLE_WEEKLY_OR_MORE', +] as const; + +export const THUMBNAIL_EXTENSION = [ + 'DEFAULT', + 'JPG', + 'PNG', + 'GIF', + 'WEBP', + 'SVG', + 'JPEG', +] as const; + +// UI 매칭을 위한 라벨 const + +export const STUDY_TYPE_LABELS = { + PROJECT: '프로젝트', + MENTORING: '멘토링', + SEMINAR: '세미나', + CHALLENGE: '챌린지', + BOOK_STUDY: '책 스터디', + LECTURE_STUDY: '강의 스터디', +} as const; + +export const ROLE_OPTIONS_UI = [ + { label: '백엔드', value: 'BACKEND' }, + { label: '프론트엔드', value: 'FRONTEND' }, + { label: '기획', value: 'PLANNER' }, + { label: '디자이너', value: 'DESIGNER' }, +]; + +export const EXPERIENCE_LEVEL_OPTIONS_UI = [ + { label: '입문자', value: 'BEGINNER' }, + { label: '취준생', value: 'JOB_SEEKER' }, + { label: '주니어', value: 'JUNIOR' }, + { label: '미들', value: 'MIDDLE' }, + { label: '시니어', value: 'SENIOR' }, +]; + +export const STUDY_METHOD_LABELS = { + ONLINE: '온라인', + OFFLINE: '오프라인', + HYBRID: '온오프라인', +} as const; + +export const REGULAR_MEETING_LABELS = { + NONE: '없음', + WEEKLY: '주 1회', + BIWEEKLY: '주 2회', + TRIPLE_WEEKLY_OR_MORE: '주 3회 이상', +} as const; diff --git a/src/features/study/group/model/open-group-form.schema.ts b/src/features/study/group/model/open-group-form.schema.ts index 595c4640..a5c7c438 100644 --- a/src/features/study/group/model/open-group-form.schema.ts +++ b/src/features/study/group/model/open-group-form.schema.ts @@ -1,59 +1,31 @@ import { z } from 'zod'; -import { OpenGroupRequest } from '../api/group-types'; +import { OpenGroupStudyRequest } from '../api/group-study-types'; import { - METHOD_OPTIONS, - MEETING_OPTIONS, STUDY_TYPES, -} from '../const/group-const'; + TARGET_ROLE_OPTIONS, + EXPERIENCE_LEVEL_OPTIONS, + REGULAR_MEETINGS, + THUMBNAIL_EXTENSION, + STUDY_METHODS, +} from '../const/group-study-const'; const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; -const StudyMap: Record< - string, - | 'PROJECT' - | 'MENTORING' - | 'SEMINAR' - | 'CHALLENGE' - | 'BOOK_STUDY' - | 'LECTURE_STUDY' -> = { - 프로젝트: 'PROJECT', - 멘토링: 'MENTORING', - 세미나: 'SEMINAR', - 챌린지: 'CHALLENGE', - '책 스터디': 'BOOK_STUDY', - '강의 스터디': 'LECTURE_STUDY', -}; - -const MethodMap: Record = { - 온라인: 'ONLINE', - 오프라인: 'OFFLINE', - 온오프라인: 'HYBRID', -}; - -const RegularMeetingMap: Record< - string, - 'WEEKLY' | 'BIWEEKLY' | 'TRIPLE_WEEKLY_OR_MORE' | 'NONE' -> = { - '주 1회': 'WEEKLY', - '주 2회': 'BIWEEKLY', - '주 3회 이상': 'TRIPLE_WEEKLY_OR_MORE', - 없음: 'NONE', -}; - export const OpenGroupFormSchema = z.object({ type: z.enum(STUDY_TYPES), - targetRole: z.array(z.string()).min(1, '역할을 1개 이상 선택해 주세요.'), + targetRoles: z + .array(z.enum(TARGET_ROLE_OPTIONS)) + .min(1, '역할을 1개 이상 선택해 주세요.'), maxMembersCount: z .string() .trim() .regex(/^[1-9]\d*$/, '최소 1 이상의 정수를 입력해 주세요.'), experienceLevels: z - .array(z.string()) + .array(z.enum(EXPERIENCE_LEVEL_OPTIONS)) .min(1, '경력을 1개 이상 선택해 주세요.'), - method: z.enum(METHOD_OPTIONS), + method: z.enum(STUDY_METHODS), location: z.string().trim(), - regularMeeting: z.enum(MEETING_OPTIONS), + regularMeeting: z.enum(REGULAR_MEETINGS), startDate: z .string() .trim() @@ -61,7 +33,7 @@ export const OpenGroupFormSchema = z.object({ endDate: z .string() .trim() - .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 시작일을 입력해 주세요.'), + .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 종료일을 입력해 주세요.'), price: z .string() .trim() @@ -70,15 +42,7 @@ export const OpenGroupFormSchema = z.object({ description: z.string().trim(), summary: z.string().trim(), interviewPost: z.string().optional(), - thumbnailExtension: z.enum([ - 'DEFAULT', - 'JPG', - 'PNG', - 'GIF', - 'WEBP', - 'SVG', - 'JPEG', - ]), + thumbnailExtension: z.enum(THUMBNAIL_EXTENSION), }); export type OpenGroupFormValues = z.input; @@ -86,13 +50,13 @@ export type OpenGroupParsedValues = z.output; export function buildOpenGroupDefaultValues(): OpenGroupFormValues { return { - type: '프로젝트', - targetRole: [], + type: 'PROJECT', + targetRoles: [], maxMembersCount: '', experienceLevels: [], - method: '온라인', + method: 'ONLINE', location: '', - regularMeeting: '주 1회', + regularMeeting: 'NONE', startDate: '', endDate: '', price: '', @@ -104,35 +68,32 @@ export function buildOpenGroupDefaultValues(): OpenGroupFormValues { }; } -export function toOpenGroupRequest(v: OpenGroupParsedValues): OpenGroupRequest { +export function toOpenGroupRequest( + v: OpenGroupParsedValues, +): OpenGroupStudyRequest { return { basicInfo: { - groupStudyId: 0, - type: StudyMap[v.type], - targetRoles: v.targetRole, + type: v.type, + targetRoles: v.targetRoles, maxMembersCount: Number(v.maxMembersCount), experienceLevels: v.experienceLevels ?? [], - method: MethodMap[v.method], - regularMeeting: RegularMeetingMap[v.regularMeeting], + method: v.method, + regularMeeting: v.regularMeeting, location: v.location.trim(), startDate: v.startDate.trim(), endDate: v.endDate.trim(), price: Number(v.price), - status: 'RECRUITING', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, detailInfo: { - thumbnail: { - imageId: 0, - resizedImages: [], - }, - title: '', - description: '', - summary: '', + thumbnailExtension: v.thumbnailExtension, + title: v.title, + description: v.description, + summary: v.summary, }, interviewPost: { - interviewPost: v.interviewPost ?? '', + interviewPost: v.interviewPost ? [v.interviewPost] : [], }, thumbnailExtension: v.thumbnailExtension, }; diff --git a/src/features/study/group/ui/open-group-modal.tsx b/src/features/study/group/ui/open-group-modal.tsx index 7344d8b8..f1d5b9bb 100644 --- a/src/features/study/group/ui/open-group-modal.tsx +++ b/src/features/study/group/ui/open-group-modal.tsx @@ -93,7 +93,7 @@ function OpenGroupStudyForm({ onClose }: { onClose: () => void }) { const STEP_FIELDS: Record<1 | 2 | 3, (keyof OpenGroupFormValues)[]> = { 1: [ 'type', - 'targetRole', + 'targetRoles', 'maxMembersCount', 'experienceLevels', 'method', diff --git a/src/features/study/group/ui/step/step1-group.tsx b/src/features/study/group/ui/step/step1-group.tsx index 8792c5de..e519e02f 100644 --- a/src/features/study/group/ui/step/step1-group.tsx +++ b/src/features/study/group/ui/step/step1-group.tsx @@ -11,28 +11,24 @@ import FormField from '@/shared/ui/form/form-field'; import { BaseInput } from '@/shared/ui/input'; import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; import { GroupItems } from '@/shared/ui/toggle'; +import { TargetRole } from '../../api/group-study-types'; import { - EXPERIENCE_LEVEL_OPTIONS, STUDY_TYPES, - MEETING_OPTIONS, - METHOD_OPTIONS, -} from '../../const/group-const'; + ROLE_OPTIONS_UI, + EXPERIENCE_LEVEL_OPTIONS_UI, + STUDY_METHODS, + STUDY_METHOD_LABELS, + STUDY_TYPE_LABELS, + REGULAR_MEETINGS, + REGULAR_MEETING_LABELS, +} from '../../const/group-study-const'; import { OpenGroupFormValues } from '../../model/open-group-form.schema'; -const ROLE_OPTIONS = [ - { label: '백엔드', value: 'backend' }, - { label: '프론트엔드', value: 'frontend' }, - { label: '기획', value: 'planner' }, - { label: 'UX/UI 디자이너', value: 'uxui-designer' }, -]; - -const expOptions = EXPERIENCE_LEVEL_OPTIONS.map((v) => ({ - label: v, +const methodOptions = STUDY_METHODS.map((v) => ({ + label: STUDY_METHOD_LABELS[v], value: v, })); -const methodOptions = METHOD_OPTIONS.map((v) => ({ label: v, value: v })); - const memberOptions = Array.from({ length: 20 }, (_, i) => { const value = (i + 1).toString(); @@ -77,21 +73,21 @@ export default function Step1OpenGroupStudy() { htmlFor={`option${index}`} className="font-designer-14m text-text-default" > - {type} + {STUDY_TYPE_LABELS[type]}
))} - - name="targetRole" + + name="targetRoles" label="모집 대상" helper="함께하고 싶은 대상(직무·관심사 등)을 선택해주세요. (복수 선택 가능)" direction="vertical" size="medium" required > - + name="maxMembersCount" @@ -111,7 +107,7 @@ export default function Step1OpenGroupStudy() { size="medium" required > - +
@@ -146,7 +142,7 @@ export default function Step1OpenGroupStudy() { value={field.value} onChange={field.onChange} placeholder="위치를 입력하세요." - disabled={!methodValue || methodValue === '온라인'} + disabled={!methodValue || methodValue === 'ONLINE'} /> )} /> @@ -173,14 +169,14 @@ export default function Step1OpenGroupStudy() { value={regularMeetingField.value} onValueChange={regularMeetingField.onChange} > - {MEETING_OPTIONS.map((type, index) => ( + {REGULAR_MEETINGS.map((type, index) => (
))} From 4753910c32766278815230cda66fa65f13f63dd2 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 9 Oct 2025 21:23:32 +0900 Subject: [PATCH 080/128] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=8B=A0=EC=B2=AD=20=EB=AA=A8=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/ui/apply-group-study-modal.tsx | 186 ++++++++++++++++++ src/widgets/home/sidebar.tsx | 6 + 2 files changed, 192 insertions(+) create mode 100644 src/features/study/group/ui/apply-group-study-modal.tsx diff --git a/src/features/study/group/ui/apply-group-study-modal.tsx b/src/features/study/group/ui/apply-group-study-modal.tsx new file mode 100644 index 00000000..44c9f106 --- /dev/null +++ b/src/features/study/group/ui/apply-group-study-modal.tsx @@ -0,0 +1,186 @@ +'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 { z } from 'zod'; +import Button from '@/shared/ui/button'; +import Checkbox from '@/shared/ui/checkbox'; +import { TextAreaInput } from '@/shared/ui/input'; +import { Modal } from '@/shared/ui/modal'; + +interface ApplyGroupStudyModalProps { + title: string; + questions: string[]; +} + +export default function ApplyGroupStudyModal({ + title, + questions, +}: ApplyGroupStudyModalProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + + 스터디 신청서 작성하기 + + setOpen(false)}> + + + + + setOpen(false)} + /> + + + + ); +} + +const ApplyGroupStudyFormSchema = z.object({ + answer: z.array(z.string().min(1, '답변을 작성해주세요.')), +}); + +type ApplyGroupStudyFormValues = z.infer; + +function buildApplyGroupStudyDefaultValues(): ApplyGroupStudyFormValues { + return { + answer: [], + }; +} + +function ApplyGroupStudyForm({ + title, + questions, + onClose, +}: { + title: string; + questions: string[]; + onClose: () => void; +}) { + const [checked, setChecked] = useState(false); + const methods = useForm<{ answer: string[] }>({ + resolver: zodResolver(ApplyGroupStudyFormSchema), + mode: 'onChange', + defaultValues: buildApplyGroupStudyDefaultValues(), + }); + + const handleApply = () => {}; + + return ( + <> + +

{title}

+ +
+ + 리더의 질문 + + + +
+ {questions.map((question, index) => ( +
+ + +
+ ))} +
+
+
+ +
+

스터디 참여 규칙을 준수해주세요

+ +
    +
  • + · + 모집 공고의 정보는 사실에 기반해 작성해야 합니다. +
  • +
  • + · + 진행 기간과 모임 빈도를 명확히 안내해야 합니다. +
  • +
  • + · + + 스터디 개설 후 최소 1회 이상 정기 모임을 진행해야 합니다. + +
  • +
  • + · + 스터디원과의 약속을 존중하고 성실히 운영해야 합니다. +
  • +
  • + · + + 타인의 개인정보 및 자료를 무단으로 공유하거나 외부 유출해서는 안 + 됩니다. + +
  • +
  • + · + + 플랫폼의 운영 정책 및 커뮤니티 가이드를 준수해야 합니다. + +
  • +
+ +
+
+ + 참여 규칙을 확인하시고 동의해 주시겠습니까? + + 필수 +
+ + { + setChecked((prev) => !prev); + }} + /> +
+
+
+ + + + + + + + + ); +} diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index 2c95571a..b4a10030 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; import MyProfileCard from '@/entities/user/ui/my-profile-card'; +import ApplyGroupStudyModal from '@/features/study/group/ui/apply-group-study-modal'; import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; import { getServerCookie } from '@/shared/lib/server-cookie'; import Calendar from '@/widgets/home/calendar'; @@ -74,6 +75,11 @@ export default async function Sidebar() { 피드백 + + ); From 4c5c243acdf1e4ba1d702127a07a647e145278b2 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 9 Oct 2025 15:32:15 +0900 Subject: [PATCH 081/128] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?UI=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/ui/member-list-table.tsx | 25 ++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index d244a28f..764622ba 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -102,7 +102,30 @@ export default function MemberListTable() { onChange={setSearchKeyword} />
-
+ +
+
+ {someSelected && ( +
+

+ + {selectedIds.size} + + 명 선택 +

+ +
+ + +
+
+ )} +
+ Date: Thu, 9 Oct 2025 15:42:12 +0900 Subject: [PATCH 082/128] =?UTF-8?q?refactor:=20=EA=B6=8C=ED=95=9C=EA=B3=BC?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=EB=A5=BC=20const=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/const/member.ts | 12 ++++++++++++ src/features/admin/ui/member-list-table.tsx | 17 +++-------------- 2 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 src/features/admin/const/member.ts diff --git a/src/features/admin/const/member.ts b/src/features/admin/const/member.ts new file mode 100644 index 00000000..4008a566 --- /dev/null +++ b/src/features/admin/const/member.ts @@ -0,0 +1,12 @@ +export const ROLE_MAP = { + ROLE_MEMBER: '일반', + ROLE_MENTOR: '멘토', + ROLE_ADMIN: '관리자', +}; + +export const MEMBER_STATUS_MAP = { + ACTIVE: '활성', + PERM_BAN: '일시정지', + PAUSED: '영구정지', + DORMANT: '휴면', +}; diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 764622ba..d026c86d 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -9,25 +9,16 @@ import { SingleDropdown } from '@/shared/ui/dropdown'; import Pagination from '@/shared/ui/pagination'; import FilledX from 'public/icons/filled-x.svg'; import SearchIcon from 'public/icons/search.svg'; +import ChangeStatusModal from './chage-status-modal'; import { MemberStatus, RoleId } from '../api/types'; +import { MEMBER_STATUS_MAP, ROLE_MAP } from '../const/member'; import { useGetMemberListQuery } from '../model/use-member-list-query'; -const ROLE_MAP = { - ROLE_MEMBER: '일반', - ROLE_MENTOR: '멘토', - ROLE_ADMIN: '관리자', -}; const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ value: key, label, })); -const MEMBER_STATUS_MAP = { - ACTIVE: '활성', - PERM_BAN: '일시정지', - PAUSED: '영구정지', - DORMANT: '휴면', -}; const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( ([key, label]) => ({ value: key, @@ -118,9 +109,7 @@ export default function MemberListTable() { - +
)} From dac20b04e67dda85ee667ff9da64a5b2ed24f1da Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 9 Oct 2025 15:54:27 +0900 Subject: [PATCH 083/128] =?UTF-8?q?style:=20MemberListFilter=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20=EC=B5=9C=EC=83=81?= =?UTF-8?q?=EC=9C=84=20=EC=9A=94=EC=86=8C=EB=A1=9C=20div=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/ui/member-list-table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index d026c86d..9aeb2880 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -219,7 +219,7 @@ function MemberListFilter({ onSelectMemberStatus: (memberStatus: MemberStatus | null) => void; }) { return ( - <> +
{(roleId || memberStatus) && (
- +
); } From d7b5293571c5959906d5097cc876c8803c047942 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 9 Oct 2025 18:01:16 +0900 Subject: [PATCH 084/128] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/api/member-list.ts | 19 +++- src/features/admin/api/types.ts | 5 + .../admin/model/use-member-list-query.ts | 59 +++++++++- src/features/admin/ui/chage-status-modal.tsx | 105 ++++++++++++++++++ src/features/admin/ui/member-list-table.tsx | 8 +- 5 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 src/features/admin/ui/chage-status-modal.tsx diff --git a/src/features/admin/api/member-list.ts b/src/features/admin/api/member-list.ts index 064ffedd..f6a16992 100644 --- a/src/features/admin/api/member-list.ts +++ b/src/features/admin/api/member-list.ts @@ -1,6 +1,11 @@ import { axiosInstance } from '@/shared/tanstack-query/axios'; -import { GetMemberListRequest, GetMemberListResponse } from './types'; +import { + ChangeMemberStatusRequest, + GetMemberListRequest, + GetMemberListResponse, +} from './types'; +// 사용자 목록 조회 export const getMemberList = async ({ roleId, memberStatus, @@ -24,3 +29,15 @@ export const getMemberList = async ({ return res.data.content; }; + +// 사용자 계정 상태 변경 +export const changeMemberStatus = async ({ + memberId, + to, +}: ChangeMemberStatusRequest) => { + const res = await axiosInstance.patch( + `/admin/members/${memberId}/status?to=${to}`, + ); + + return res.data; +}; diff --git a/src/features/admin/api/types.ts b/src/features/admin/api/types.ts index 9f4d17a9..46c13a7b 100644 --- a/src/features/admin/api/types.ts +++ b/src/features/admin/api/types.ts @@ -28,3 +28,8 @@ export interface GetMemberListResponse { }; }[]; } + +export interface ChangeMemberStatusRequest { + memberId: number; + to: MemberStatus; +} diff --git a/src/features/admin/model/use-member-list-query.ts b/src/features/admin/model/use-member-list-query.ts index 762ce211..4c835a01 100644 --- a/src/features/admin/model/use-member-list-query.ts +++ b/src/features/admin/model/use-member-list-query.ts @@ -1,7 +1,12 @@ -import { useQuery } from '@tanstack/react-query'; -import { getMemberList } from '../api/member-list'; -import { GetMemberListRequest } from '../api/types'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { changeMemberStatus, getMemberList } from '../api/member-list'; +import { + ChangeMemberStatusRequest, + GetMemberListRequest, + GetMemberListResponse, +} from '../api/types'; +// 사용자 목록 조회 export const useGetMemberListQuery = ({ roleId, memberStatus, @@ -19,3 +24,51 @@ export const useGetMemberListQuery = ({ }), }); }; + +// 사용자 계정 상태 변경 +export const useChangeMemberStatusMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: { + members: GetMemberListResponse['content']; + to: ChangeMemberStatusRequest['to']; + }) => { + // 병렬로 선택한 모든 사용자의 상태 변경 요청을 보냄 + // 성공: {status: "fulfilled", value: {memberId, success: true}} + // 실패: {status: "fulfilled", value: {memberId, success: false}} (개별 요청 실패는 catch에서 처리했기 때문에 status가 rejected가 되지 않음) + const results: PromiseSettledResult<{ + memberId: number; + success: boolean; + }>[] = await Promise.allSettled( + data.members.map((member) => + changeMemberStatus({ memberId: member.memberId, to: data.to }) + .then(() => ({ memberId: member.memberId, success: true })) + .catch(() => ({ memberId: member.memberId, success: false })), + ), + ); + + const failedMemberIds = results + .filter( + (result) => result.status === 'fulfilled' && !result.value.success, + ) + .map((result) => + result.status === 'fulfilled' ? result.value.memberId : null, + ) + .filter(Boolean); + + if (failedMemberIds.length > 0) { + const failedMemberNames = data.members + .filter((member) => failedMemberIds.includes(member.memberId)) + .map((member) => member.memberName); + + alert( + `다음 회원들의 상태 변경에 실패했습니다: ${failedMemberNames.join(', ')}`, + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['memberList'] }); + }, + }); +}; diff --git a/src/features/admin/ui/chage-status-modal.tsx b/src/features/admin/ui/chage-status-modal.tsx new file mode 100644 index 00000000..c78bb755 --- /dev/null +++ b/src/features/admin/ui/chage-status-modal.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { XIcon } from 'lucide-react'; +import { useState } from 'react'; +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; +import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; +import { GetMemberListResponse, MemberStatus } from '../api/types'; +import { MEMBER_STATUS_MAP } from '../const/member'; +import { useChangeMemberStatusMutation } from '../model/use-member-list-query'; + +const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( + ([key, label]) => ({ + value: key, + label, + }), +); + +interface ChangeStatusModalProps { + members: GetMemberListResponse['content']; +} + +export default function ChangeStatusModal({ members }: ChangeStatusModalProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + + 계정 상태 변경 + + setOpen(false)}> + + + + + setOpen(false)} /> + + + + ); +} + +function ChangeStatusForm({ + members, + onClose, +}: { + members: GetMemberListResponse['content']; + onClose: () => void; +}) { + const INIT_STATUS: MemberStatus = 'ACTIVE'; + const [status, setStatus] = useState(INIT_STATUS); + + const { mutate: changeStatus } = useChangeMemberStatusMutation(); + + const handleChangeStatus = () => { + changeStatus( + { members, to: status }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + }; + + return ( + <> + + setStatus(status)} + > + {MEMBER_STATUS_OPTIONS.map(({ value, label }) => ( +
+ + +
+ ))} +
+
+ + + + + + + + + ); +} diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 9aeb2880..d24f6208 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -41,7 +41,7 @@ export default function MemberListTable() { const memberList = data?.content || []; - const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); const headerCheckboxRef = useRef(null); const allSelected = @@ -109,7 +109,11 @@ export default function MemberListTable() { - + + selectedIds.has(member.memberId), + )} + />
)} From 1c9b2907a5517c0674d8a8a8f79e591c5d25ce4d Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 9 Oct 2025 18:26:03 +0900 Subject: [PATCH 085/128] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/api/member-list.ts | 13 +++ src/features/admin/api/types.ts | 5 + .../admin/model/use-member-list-query.ts | 55 +++++++++- src/features/admin/ui/change-role-modal.tsx | 103 ++++++++++++++++++ src/features/admin/ui/member-list-table.tsx | 9 +- 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/features/admin/ui/change-role-modal.tsx diff --git a/src/features/admin/api/member-list.ts b/src/features/admin/api/member-list.ts index f6a16992..6c5251ac 100644 --- a/src/features/admin/api/member-list.ts +++ b/src/features/admin/api/member-list.ts @@ -1,5 +1,6 @@ import { axiosInstance } from '@/shared/tanstack-query/axios'; import { + ChangeMemberRoleRequest, ChangeMemberStatusRequest, GetMemberListRequest, GetMemberListResponse, @@ -41,3 +42,15 @@ export const changeMemberStatus = async ({ return res.data; }; + +// 사용자 권한 변경 +export const changeMemberRole = async ({ + memberId, + roleId, +}: ChangeMemberRoleRequest) => { + const res = await axiosInstance.patch( + `/admin/members/${memberId}/role?role-id=${roleId}`, + ); + + return res.data; +}; diff --git a/src/features/admin/api/types.ts b/src/features/admin/api/types.ts index 46c13a7b..071def3c 100644 --- a/src/features/admin/api/types.ts +++ b/src/features/admin/api/types.ts @@ -33,3 +33,8 @@ export interface ChangeMemberStatusRequest { memberId: number; to: MemberStatus; } + +export interface ChangeMemberRoleRequest { + memberId: number; + roleId: RoleId; +} diff --git a/src/features/admin/model/use-member-list-query.ts b/src/features/admin/model/use-member-list-query.ts index 4c835a01..38422eb3 100644 --- a/src/features/admin/model/use-member-list-query.ts +++ b/src/features/admin/model/use-member-list-query.ts @@ -1,6 +1,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { changeMemberStatus, getMemberList } from '../api/member-list'; import { + changeMemberRole, + changeMemberStatus, + getMemberList, +} from '../api/member-list'; +import { + ChangeMemberRoleRequest, ChangeMemberStatusRequest, GetMemberListRequest, GetMemberListResponse, @@ -72,3 +77,51 @@ export const useChangeMemberStatusMutation = () => { }, }); }; + +// 사용자 권한 변경 +export const useChangeMemberRoleMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: { + members: GetMemberListResponse['content']; + roleId: ChangeMemberRoleRequest['roleId']; + }) => { + // 병렬로 선택한 모든 사용자의 상태 변경 요청을 보냄 + // 성공: {status: "fulfilled", value: {memberId, success: true}} + // 실패: {status: "fulfilled", value: {memberId, success: false}} (개별 요청 실패는 catch에서 처리했기 때문에 status가 rejected가 되지 않음) + const results: PromiseSettledResult<{ + memberId: number; + success: boolean; + }>[] = await Promise.allSettled( + data.members.map((member) => + changeMemberRole({ memberId: member.memberId, roleId: data.roleId }) + .then(() => ({ memberId: member.memberId, success: true })) + .catch(() => ({ memberId: member.memberId, success: false })), + ), + ); + + const failedMemberIds = results + .filter( + (result) => result.status === 'fulfilled' && !result.value.success, + ) + .map((result) => + result.status === 'fulfilled' ? result.value.memberId : null, + ) + .filter(Boolean); + + if (failedMemberIds.length > 0) { + const failedMemberNames = data.members + .filter((member) => failedMemberIds.includes(member.memberId)) + .map((member) => member.memberName); + + alert( + `다음 회원들의 권한 변경에 실패했습니다: ${failedMemberNames.join(', ')}`, + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['memberList'] }); + }, + }); +}; diff --git a/src/features/admin/ui/change-role-modal.tsx b/src/features/admin/ui/change-role-modal.tsx new file mode 100644 index 00000000..beebf1fe --- /dev/null +++ b/src/features/admin/ui/change-role-modal.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { XIcon } from 'lucide-react'; +import { useState } from 'react'; +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; +import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; +import { GetMemberListResponse, RoleId } from '../api/types'; +import { ROLE_MAP } from '../const/member'; +import { useChangeMemberRoleMutation } from '../model/use-member-list-query'; + +const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ + value: key, + label, +})); + +interface ChangeRoleModalProps { + members: GetMemberListResponse['content']; +} + +export default function ChangeRoleModal({ members }: ChangeRoleModalProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + + 권한 변경 + + setOpen(false)}> + + + + + setOpen(false)} /> + + + + ); +} + +function ChangeRoleForm({ + members, + onClose, +}: { + members: GetMemberListResponse['content']; + onClose: () => void; +}) { + const INIT_ROLE: RoleId = 'ROLE_MEMBER'; + const [role, setRole] = useState(INIT_ROLE); + + const { mutate: changeStatus } = useChangeMemberRoleMutation(); + + const handleChangeStatus = () => { + changeStatus( + { members, roleId: role }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + }; + + return ( + <> + + setRole(roleId)} + > + {ROLE_OPTIONS.map(({ value, label }) => ( +
+ + +
+ ))} +
+
+ + + + + + + + + ); +} diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index d24f6208..2542e8f7 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -10,6 +10,7 @@ import Pagination from '@/shared/ui/pagination'; import FilledX from 'public/icons/filled-x.svg'; import SearchIcon from 'public/icons/search.svg'; import ChangeStatusModal from './chage-status-modal'; +import ChangeRoleModal from './change-role-modal'; import { MemberStatus, RoleId } from '../api/types'; import { MEMBER_STATUS_MAP, ROLE_MAP } from '../const/member'; import { useGetMemberListQuery } from '../model/use-member-list-query'; @@ -106,9 +107,11 @@ export default function MemberListTable() {

- + + selectedIds.has(member.memberId), + )} + /> selectedIds.has(member.memberId), From aff7e96c2a2ef82b518071f4ee104cef5d358fa6 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 9 Oct 2025 18:44:03 +0900 Subject: [PATCH 086/128] =?UTF-8?q?refactor:=20=EC=84=A0=ED=83=9D=ED=95=9C?= =?UTF-8?q?=20=ED=96=89=20=EB=B0=B0=EA=B2=BD=EC=83=89=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/ui/member-list-table.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 2542e8f7..37fe3c5e 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -163,11 +163,15 @@ export default function MemberListTable() { {memberList.map((user, idx) => ( Date: Thu, 9 Oct 2025 18:45:51 +0900 Subject: [PATCH 087/128] =?UTF-8?q?refactor:=20=EA=B6=8C=ED=95=9C=EA=B3=BC?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B0=B0=EC=97=B4=20=EC=83=81=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/const/member.ts | 12 ++++++++++++ src/features/admin/ui/chage-status-modal.tsx | 9 +-------- src/features/admin/ui/change-role-modal.tsx | 7 +------ src/features/admin/ui/member-list-table.tsx | 19 ++++++------------- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/features/admin/const/member.ts b/src/features/admin/const/member.ts index 4008a566..9c269162 100644 --- a/src/features/admin/const/member.ts +++ b/src/features/admin/const/member.ts @@ -4,9 +4,21 @@ export const ROLE_MAP = { ROLE_ADMIN: '관리자', }; +export const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ + value: key, + label, +})); + export const MEMBER_STATUS_MAP = { ACTIVE: '활성', PERM_BAN: '일시정지', PAUSED: '영구정지', DORMANT: '휴면', }; + +export const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( + ([key, label]) => ({ + value: key, + label, + }), +); diff --git a/src/features/admin/ui/chage-status-modal.tsx b/src/features/admin/ui/chage-status-modal.tsx index c78bb755..18d2cb13 100644 --- a/src/features/admin/ui/chage-status-modal.tsx +++ b/src/features/admin/ui/chage-status-modal.tsx @@ -6,16 +6,9 @@ import Button from '@/shared/ui/button'; import { Modal } from '@/shared/ui/modal'; import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; import { GetMemberListResponse, MemberStatus } from '../api/types'; -import { MEMBER_STATUS_MAP } from '../const/member'; +import { MEMBER_STATUS_OPTIONS } from '../const/member'; import { useChangeMemberStatusMutation } from '../model/use-member-list-query'; -const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( - ([key, label]) => ({ - value: key, - label, - }), -); - interface ChangeStatusModalProps { members: GetMemberListResponse['content']; } diff --git a/src/features/admin/ui/change-role-modal.tsx b/src/features/admin/ui/change-role-modal.tsx index beebf1fe..b7751d8a 100644 --- a/src/features/admin/ui/change-role-modal.tsx +++ b/src/features/admin/ui/change-role-modal.tsx @@ -6,14 +6,9 @@ import Button from '@/shared/ui/button'; import { Modal } from '@/shared/ui/modal'; import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; import { GetMemberListResponse, RoleId } from '../api/types'; -import { ROLE_MAP } from '../const/member'; +import { ROLE_OPTIONS } from '../const/member'; import { useChangeMemberRoleMutation } from '../model/use-member-list-query'; -const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ - value: key, - label, -})); - interface ChangeRoleModalProps { members: GetMemberListResponse['content']; } diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 37fe3c5e..d410c360 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -12,21 +12,14 @@ import SearchIcon from 'public/icons/search.svg'; import ChangeStatusModal from './chage-status-modal'; import ChangeRoleModal from './change-role-modal'; import { MemberStatus, RoleId } from '../api/types'; -import { MEMBER_STATUS_MAP, ROLE_MAP } from '../const/member'; +import { + MEMBER_STATUS_MAP, + MEMBER_STATUS_OPTIONS, + ROLE_MAP, + ROLE_OPTIONS, +} from '../const/member'; import { useGetMemberListQuery } from '../model/use-member-list-query'; -const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ - value: key, - label, -})); - -const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( - ([key, label]) => ({ - value: key, - label, - }), -); - export default function MemberListTable() { const [roleId, setRoleId] = useState(null); const [memberStatus, setMemberStatus] = useState(null); From f45182776d555eb0e240171ae4482c999a47aef5 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 9 Oct 2025 18:49:40 +0900 Subject: [PATCH 088/128] =?UTF-8?q?refactor:=20=EB=A9=98=ED=86=A0=EC=9D=BC?= =?UTF-8?q?=20=EA=B2=BD=EC=9A=B0=20=EC=98=86=EC=97=90=20check=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=EC=9C=BC=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/icons/seal-check.svg | 10 ++++++++++ src/features/admin/ui/member-list-table.tsx | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 public/icons/seal-check.svg diff --git a/public/icons/seal-check.svg b/public/icons/seal-check.svg new file mode 100644 index 00000000..57512572 --- /dev/null +++ b/public/icons/seal-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index d410c360..f6b135c3 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -8,6 +8,7 @@ import Checkbox from '@/shared/ui/checkbox'; import { SingleDropdown } from '@/shared/ui/dropdown'; import Pagination from '@/shared/ui/pagination'; import FilledX from 'public/icons/filled-x.svg'; +import SealCheckIcon from 'public/icons/seal-check.svg'; import SearchIcon from 'public/icons/search.svg'; import ChangeStatusModal from './chage-status-modal'; import ChangeRoleModal from './change-role-modal'; @@ -182,7 +183,8 @@ export default function MemberListTable() { {formatYYYYMMDD(user.loginMostRecentlyAt)} - + + {user.role.roleId === 'ROLE_MENTOR' && } {ROLE_MAP[user.role.roleId]} From af4764d57e4e569129410ebbda3d75868b20a7b0 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Thu, 9 Oct 2025 21:53:12 +0900 Subject: [PATCH 089/128] =?UTF-8?q?feat:=20step2=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/model/open-group-form.schema.ts | 15 +++-- .../group/ui/group-study-thumbnail-input.tsx | 14 ++--- .../study/group/ui/open-group-modal.tsx | 4 +- .../study/group/ui/step/step2-group.tsx | 59 ++++++++++++++----- src/widgets/home/sidebar.tsx | 1 - 5 files changed, 60 insertions(+), 33 deletions(-) diff --git a/src/features/study/group/model/open-group-form.schema.ts b/src/features/study/group/model/open-group-form.schema.ts index a5c7c438..c44042c0 100644 --- a/src/features/study/group/model/open-group-form.schema.ts +++ b/src/features/study/group/model/open-group-form.schema.ts @@ -38,14 +38,19 @@ export const OpenGroupFormSchema = z.object({ .string() .trim() .regex(/^\d+$/, '가격은 0 이상 정수로 입력해 주세요.'), - title: z.string().trim(), - description: z.string().trim(), - summary: z.string().trim(), + title: z.string().trim().min(1, '스터디 제목을 입력해주세요.'), + summary: z.string().trim().min(1, '한 줄 소개를 입력해주세요.'), + description: z.string().trim().min(1, '스터디 소개를 입력해주세요.'), interviewPost: z.string().optional(), - thumbnailExtension: z.enum(THUMBNAIL_EXTENSION), + thumbnailExtension: z + .enum(THUMBNAIL_EXTENSION) + .refine((val) => val !== 'DEFAULT', '썸네일 이미지를 선택해주세요.'), }); -export type OpenGroupFormValues = z.input; +// 사진 상태 저장을 위한 로컬용 state +export type OpenGroupFormValues = z.input & { + thumbnailFile?: File | undefined; +}; export type OpenGroupParsedValues = z.output; export function buildOpenGroupDefaultValues(): OpenGroupFormValues { diff --git a/src/features/study/group/ui/group-study-thumbnail-input.tsx b/src/features/study/group/ui/group-study-thumbnail-input.tsx index 319193e2..f0a3c53e 100644 --- a/src/features/study/group/ui/group-study-thumbnail-input.tsx +++ b/src/features/study/group/ui/group-study-thumbnail-input.tsx @@ -15,7 +15,7 @@ export default function GroupStudyThumbnailInput({ onChangeImage, }: { image?: string; - onChangeImage: (image?: string) => void; + onChangeImage: (file: File | null) => void; }) { const fileInputRef = useRef(null); @@ -35,7 +35,6 @@ export default function GroupStudyThumbnailInput({ const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - console.log('drag leave'); setIsDragging(false); }; // 영역 안에서 드래그 중일 때 @@ -43,8 +42,6 @@ export default function GroupStudyThumbnailInput({ e.preventDefault(); e.stopPropagation(); - console.log('drag over'); - if (e.dataTransfer.files) { setIsDragging(true); } @@ -53,13 +50,12 @@ export default function GroupStudyThumbnailInput({ const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); - console.log('drop'); setIsDragging(false); const file = e.dataTransfer.files[0]; // 1장만 허용 if (file && file.type.startsWith('image/')) { - onChangeImage(URL.createObjectURL(file)); + onChangeImage(file); } }; @@ -68,7 +64,7 @@ export default function GroupStudyThumbnailInput({ const file = e.target.files?.[0]; if (file && file.type.startsWith('image/')) { - onChangeImage(URL.createObjectURL(file)); + onChangeImage(file); } }; @@ -118,8 +114,8 @@ export default function GroupStudyThumbnailInput({ preview
); From 8127979091bc340084eca6424fece07e533e3a6f Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 00:38:25 +0900 Subject: [PATCH 092/128] =?UTF-8?q?refactor:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/admin/ui/admin-side-bar.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/widgets/admin/ui/admin-side-bar.tsx b/src/widgets/admin/ui/admin-side-bar.tsx index 8112430a..d8b5282e 100644 --- a/src/widgets/admin/ui/admin-side-bar.tsx +++ b/src/widgets/admin/ui/admin-side-bar.tsx @@ -1,35 +1,25 @@ import Image from 'next/image'; import Link from 'next/link'; import TabMenu from '@/shared/ui/tab-menu'; +import LogoutIcon from 'public/icons/logout.svg'; export default function AdminSideBar() { return ( From c2a7dc9da26713b41be2eb18ed697d45db5e9877 Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 01:07:15 +0900 Subject: [PATCH 093/128] =?UTF-8?q?refactor:=20AdminPage=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/admin/page.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index 7f691359..46e92df5 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -3,8 +3,6 @@ import { HydrationBoundary, QueryClient, } from '@tanstack/react-query'; -import Image from 'next/image'; -import Link from 'next/link'; import { getMemberListInServer } from '@/features/admin/api/member-list.server'; import MemberListTable from '@/features/admin/ui/member-list-table'; From 140106fadd40119aacdd4e816675921f5e923b81 Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 01:09:33 +0900 Subject: [PATCH 094/128] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=83=81=EC=84=B8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20layout=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/admin/detail/[id]/layout.tsx | 30 ++++++++++++ app/(admin)/admin/detail/[id]/page.tsx | 5 ++ app/(admin)/admin/detail/page.tsx | 5 ++ .../admin/ui/admin-detail-side-bar.tsx | 46 +++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 app/(admin)/admin/detail/[id]/layout.tsx create mode 100644 app/(admin)/admin/detail/[id]/page.tsx create mode 100644 app/(admin)/admin/detail/page.tsx create mode 100644 src/widgets/admin/ui/admin-detail-side-bar.tsx diff --git a/app/(admin)/admin/detail/[id]/layout.tsx b/app/(admin)/admin/detail/[id]/layout.tsx new file mode 100644 index 00000000..66947cef --- /dev/null +++ b/app/(admin)/admin/detail/[id]/layout.tsx @@ -0,0 +1,30 @@ +import { ArrowLeftIcon } from 'lucide-react'; +import Link from 'next/link'; +import AdminDetailSideBar from '@/widgets/admin/ui/admin-detail-side-bar'; + +export default async function AdminDetailLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { id: memberId } = await params; + + return ( +
+
+ + + +

사용자 상세 정보

+
+ +
+ + +
{children}
+
+
+ ); +} diff --git a/app/(admin)/admin/detail/[id]/page.tsx b/app/(admin)/admin/detail/[id]/page.tsx new file mode 100644 index 00000000..3774cfa8 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function AdminDetailByIdPage() { + notFound(); // /admin/detail/[id] 경로 404 처리 +} diff --git a/app/(admin)/admin/detail/page.tsx b/app/(admin)/admin/detail/page.tsx new file mode 100644 index 00000000..9292f648 --- /dev/null +++ b/app/(admin)/admin/detail/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function AdminDetailPage() { + notFound(); // /admin/detail 경로 404 처리 +} diff --git a/src/widgets/admin/ui/admin-detail-side-bar.tsx b/src/widgets/admin/ui/admin-detail-side-bar.tsx new file mode 100644 index 00000000..e00a2949 --- /dev/null +++ b/src/widgets/admin/ui/admin-detail-side-bar.tsx @@ -0,0 +1,46 @@ +'use client'; + +import Link from 'next/link'; + +import { usePathname } from 'next/navigation'; +import TabMenu from '@/shared/ui/tab-menu'; + +interface AdminDetailSideBarProps { + memberId: string; +} + +export default function AdminDetailSideBar({ + memberId, +}: AdminDetailSideBarProps) { + const pathname = usePathname(); + + return ( + + ); +} From 8e0ca1df70a044708617c65d0870550510f41c3a Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 01:10:18 +0900 Subject: [PATCH 095/128] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/[id]/account-history/page.tsx | 189 ++++++++++++++++++ .../admin/api/account-history.server.ts | 16 ++ src/features/admin/api/types.ts | 21 ++ src/shared/lib/time.ts | 8 + 4 files changed, 234 insertions(+) create mode 100644 app/(admin)/admin/detail/[id]/account-history/page.tsx create mode 100644 src/features/admin/api/account-history.server.ts diff --git a/app/(admin)/admin/detail/[id]/account-history/page.tsx b/app/(admin)/admin/detail/[id]/account-history/page.tsx new file mode 100644 index 00000000..6ffff85d --- /dev/null +++ b/app/(admin)/admin/detail/[id]/account-history/page.tsx @@ -0,0 +1,189 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; +import { ArrowRightIcon } from 'lucide-react'; +import { getAccountHistoriesInServer } from '@/features/admin/api/account-history.server'; +import { GetAccountHistoriesResponse } from '@/features/admin/api/types'; +import { formatHHMM, formatYYYYMMDD } from '@/shared/lib/time'; +import Badge from '@/shared/ui/badge'; + +export default async function AccountHistoryPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const queryClient = new QueryClient(); + const { id: memberId } = await params; + + // 서버 side에서 첫 페이지 데이터 미리 가져오기 + await queryClient.prefetchQuery({ + queryKey: ['accountHistory', memberId], + queryFn: () => getAccountHistoriesInServer({ memberId: Number(memberId) }), + }); + + const data: GetAccountHistoriesResponse = await queryClient.getQueryData([ + 'accountHistory', + memberId, + ]); + + return ( + +
+
+
+ + 계정 생성일 + +
+ {formatYYYYMMDD(data.joinedAt)} +
+
+ +
+ + 최근 로그인 + +
+ {data.loginMostRecentlyAt + ? formatYYYYMMDD(data.loginMostRecentlyAt) + : '기록 없음'} +
+
+ +
+ + 권한 + +
일반
+
+ +
+ + 계정 상태 + + + 활성 +
+
+ + + + +
+
+ ); +} + +function RecentLoginHistory({ + loginHists, +}: Pick) { + return ( +
+

최근 로그인 기록

+ +
    + {loginHists.map((hist) => ( +
  • + {formatYYYYMMDD(hist)} + {formatHHMM(hist)} +
  • + ))} +
+
+ ); +} + +function RecentRoleChangeHistory({ + roleChangeHists, +}: Pick) { + return ( +
+

권한 변경 이력

+ + {roleChangeHists.length > 0 ? ( +
    + {roleChangeHists.map((hist) => ( +
  • +
    + {formatYYYYMMDD(hist.changedAt)} + {formatHHMM(hist.changedAt)} +
    + +
    + {hist.from} + + + + {hist.to} +
    +
  • + ))} +
+ ) : ( +
+ 변경 이력이 없습니다. +
+ )} +
+ ); +} + +function RecentStatusChangeHistory({ + memberStatusChangeHists, +}: Pick) { + return ( +
+

+ 계정 상태 변경 이력 +

+ + {memberStatusChangeHists.length > 0 ? ( +
    + {memberStatusChangeHists.map((hist) => ( +
  • +
    + {formatYYYYMMDD(hist.changedAt)} + {formatHHMM(hist.changedAt)} +
    + +
    + + {hist.from} + + + + + + {hist.to} + +
    +
  • + ))} +
+ ) : ( +
+ 변경 이력이 없습니다. +
+ )} +
+ ); +} diff --git a/src/features/admin/api/account-history.server.ts b/src/features/admin/api/account-history.server.ts new file mode 100644 index 00000000..48e48658 --- /dev/null +++ b/src/features/admin/api/account-history.server.ts @@ -0,0 +1,16 @@ +import { axiosServerInstance } from '@/shared/tanstack-query/axios.server'; +import { + GetAccountHistoriesRequest, + GetAccountHistoriesResponse, +} from './types'; + +// 회원 계정 이력 조회 +export const getAccountHistoriesInServer = async ({ + memberId, +}: GetAccountHistoriesRequest): Promise => { + const res = await axiosServerInstance.get( + `/admin/members/${memberId}/account-histories`, + ); + + return res.data.content; +}; diff --git a/src/features/admin/api/types.ts b/src/features/admin/api/types.ts index 9f4d17a9..0f1f91d7 100644 --- a/src/features/admin/api/types.ts +++ b/src/features/admin/api/types.ts @@ -28,3 +28,24 @@ export interface GetMemberListResponse { }; }[]; } + +export interface GetAccountHistoriesRequest { + memberId: number; +} + +export interface GetAccountHistoriesResponse { + memberId: number; + joinedAt: string; + loginMostRecentlyAt: string; + loginHists: string[]; + roleChangeHists: { + changedAt: string; + from: string; + to: string; + }[]; + memberStatusChangeHists: { + changedAt: string; + from: string; + to: string; + }[]; +} diff --git a/src/shared/lib/time.ts b/src/shared/lib/time.ts index 567c1d9e..704f878d 100644 --- a/src/shared/lib/time.ts +++ b/src/shared/lib/time.ts @@ -26,6 +26,14 @@ export const formatYYYYMMDD = (dateString: string) => { return onlyDate; }; +export const formatHHMM = (dateString: string) => { + const date = new Date(dateString); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${hours}:${minutes}`; +}; + export const formatKoreaYMD = (targetDate?: Date) => format(getKoreaDate(targetDate), 'yyyy-MM-dd'); From 660005492bb40a2aaddc003fe33ac659717cf7dd Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 01:18:40 +0900 Subject: [PATCH 096/128] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=9D=84=20=EA=B0=80=EC=A7=80=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EC=9D=80=20=EC=82=AC=EB=9E=8C=EC=9D=B4=20=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EC=A0=91=EC=86=8D=ED=95=98=EB=A9=B4,=20=ED=99=88=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware.ts | 20 +++++++++++++++++++- src/shared/lib/jwt.ts | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 src/shared/lib/jwt.ts diff --git a/middleware.ts b/middleware.ts index 2e47f1f0..8ba30306 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import { decodeJwt } from '@/shared/lib/jwt'; import { getServerCookie } from '@/shared/lib/server-cookie'; import { isNumeric } from '@/shared/lib/validation'; import { isApiError } from '@/shared/tanstack-query/api-error'; @@ -105,10 +106,27 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(mainUrl); } + if (request.nextUrl.pathname.startsWith('/admin')) { + const decodedJwt = decodeJwt(accessToken); + + if (!decodedJwt || !decodedJwt.roleIds.includes('ROLE_ADMIN')) { + const homeUrl = new URL('/', request.url); + + return NextResponse.redirect(homeUrl); + } + } + return response; } // middleware가 적용될 경로 설정 export const config = { - matcher: ['/', '/my-page', '/my-study', '/my-study-review', '/sign-up'], + matcher: [ + '/', + '/my-page', + '/my-study', + '/my-study-review', + '/sign-up', + '/admin/:path*', + ], }; diff --git a/src/shared/lib/jwt.ts b/src/shared/lib/jwt.ts new file mode 100644 index 00000000..491d9be3 --- /dev/null +++ b/src/shared/lib/jwt.ts @@ -0,0 +1,14 @@ +export const decodeJwt = (token: string) => { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join(''), + ); + + return JSON.parse(jsonPayload); +}; From 4f56de90c7f7b730513cd581a7c9c7b72a4a41ed Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 01:27:49 +0900 Subject: [PATCH 097/128] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/detail/[id]/profile/page.tsx | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 app/(admin)/admin/detail/[id]/profile/page.tsx diff --git a/app/(admin)/admin/detail/[id]/profile/page.tsx b/app/(admin)/admin/detail/[id]/profile/page.tsx new file mode 100644 index 00000000..c400e549 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/profile/page.tsx @@ -0,0 +1,140 @@ +import { QueryClient } from '@tanstack/react-query'; +import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; +import { GetUserProfileResponse } from '@/entities/user/api/types'; +import ProfileInfoCard from '@/entities/user/ui/profile-info-card'; +import CakeIcon from '@/features/my-page/ui/icon/cake.svg'; +import GithubIcon from '@/features/my-page/ui/icon/github-logo.svg'; +import GlobeIcon from '@/features/my-page/ui/icon/globe-simple.svg'; +import PhoneIcon from '@/features/my-page/ui/icon/phone.svg'; +import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; +import UserAvatar from '@/shared/ui/avatar'; +import Badge from '@/shared/ui/badge'; + +// todo: UserProfileModal과 거의 유사하여 나중에 리팩토링하기 +export default async function ProfilePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const queryClient = new QueryClient(); + const { id: memberId } = await params; + + // 서버 side에서 첫 페이지 데이터 미리 가져오기 + await queryClient.prefetchQuery({ + queryKey: ['userProfile', memberId], + queryFn: () => getUserProfileInServer(Number(memberId)), + }); + + const profile: GetUserProfileResponse = await queryClient.getQueryData([ + 'userProfile', + memberId, + ]); + + const temperPreset = getSincerityPresetByLevelName( + profile.sincerityTemp.levelName, + ); + + return ( +
+
+ + +
+
+ {profile.memberProfile.mbti && ( + {profile.memberProfile.mbti} + )} + {profile.memberProfile.interests.slice(0, 4).map((interest) => ( + + {interest.name} + + ))} +
+ +
+
+ {profile.memberProfile.memberName} +
+ +
+ +
+ {profile.memberProfile.simpleIntroduction} +
+ +
+ } + value={profile.memberProfile.birthDate} + /> + } + value={profile.memberProfile.githubLink?.url} + /> + } value={profile.memberProfile.tel} /> + } + value={profile.memberProfile.blogOrSnsLink?.url} + /> +
+
+
+ +
+ + t.techStackName) + .join(', ')} + /> + t.label) + .join(', ')} + /> + + +
+
+ ); +} + +function Field({ icon, value }: { icon: React.ReactNode; value?: string }) { + return ( +
+ {icon} + + {value ?? ''} + +
+ ); +} From 479d43e09c490717c5a9567076d81079b16036c1 Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 01:33:08 +0900 Subject: [PATCH 098/128] =?UTF-8?q?feat:=20=EC=84=B1=EC=8B=A4=EC=98=A8?= =?UTF-8?q?=EB=8F=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=99=80=20study=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx | 3 +++ app/(admin)/admin/detail/[id]/study/page.tsx | 3 +++ src/widgets/admin/ui/admin-detail-side-bar.tsx | 6 ++++-- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx create mode 100644 app/(admin)/admin/detail/[id]/study/page.tsx diff --git a/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx b/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx new file mode 100644 index 00000000..ea0e590f --- /dev/null +++ b/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx @@ -0,0 +1,3 @@ +export default function SincerityTempPage() { + return
SincerityTempPage
; +} diff --git a/app/(admin)/admin/detail/[id]/study/page.tsx b/app/(admin)/admin/detail/[id]/study/page.tsx new file mode 100644 index 00000000..9c4cb849 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/study/page.tsx @@ -0,0 +1,3 @@ +export default function StudyPage() { + return
StudyPage
; +} diff --git a/src/widgets/admin/ui/admin-detail-side-bar.tsx b/src/widgets/admin/ui/admin-detail-side-bar.tsx index e00a2949..2052a7dc 100644 --- a/src/widgets/admin/ui/admin-detail-side-bar.tsx +++ b/src/widgets/admin/ui/admin-detail-side-bar.tsx @@ -36,8 +36,10 @@ export default function AdminDetailSideBar({ - - + + 성실온도 From df23861e73ed836eb618fb92a2c2006f394782a1 Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 01:51:28 +0900 Subject: [PATCH 099/128] =?UTF-8?q?style:=20RecentLoginHistory=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A3=BC=EC=84=9D=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/admin/detail/[id]/account-history/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(admin)/admin/detail/[id]/account-history/page.tsx b/app/(admin)/admin/detail/[id]/account-history/page.tsx index 6ffff85d..d8a923f6 100644 --- a/app/(admin)/admin/detail/[id]/account-history/page.tsx +++ b/app/(admin)/admin/detail/[id]/account-history/page.tsx @@ -68,7 +68,7 @@ export default async function AccountHistoryPage({
- + {/* */} Date: Fri, 10 Oct 2025 19:48:47 +0900 Subject: [PATCH 100/128] =?UTF-8?q?feat:=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20api=20=EC=88=98=EC=A0=95=20=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(service)/(nav)/study/page.tsx | 115 +----------------- .../study/group/api/get-group-study-list.ts | 33 +++-- .../study/group/api/group-study-types.ts | 77 +++++++++++- .../study/group/ui/group-study-list.tsx | 52 ++++---- 4 files changed, 120 insertions(+), 157 deletions(-) diff --git a/app/(service)/(nav)/study/page.tsx b/app/(service)/(nav)/study/page.tsx index b9686b5f..ed9ab587 100644 --- a/app/(service)/(nav)/study/page.tsx +++ b/app/(service)/(nav)/study/page.tsx @@ -1,120 +1,7 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; -import StudyCard, { - GroupStudy, -} from '@/features/study/group/ui/group-study-list'; -import { getGroupStudyList } from '@/features/study/group/api/get-group-study-list'; +import GroupStudyList from '@/features/study/group/ui/group-study-list'; import IconPlus from '@/shared/icons/plus.svg'; import Button from '@/shared/ui/button'; import Sidebar from '@/widgets/home/sidebar'; -import GroupStudyList from '@/features/study/group/ui/group-study-list'; - -const groupStudies: GroupStudy[] = [ - { - basicInfo: { - groupStudyId: 1, - type: 'frontend', - targetRole: 'junior', - maxMembers: 5, - experienceLevel: 'beginner', - method: 'online', - regularMeeting: '매주 화요일 8PM', - startDate: '2025-10-01', - durationWeeks: 6, - price: 50000, - status: 'recruiting', - createdAt: '2025-09-20T10:00:00Z', - updatedAt: '2025-09-25T12:30:00Z', - }, - simpleDetailInfo: { - title: 'React 입문 스터디', - summary: '기초부터 React를 배워보는 주니어 대상 스터디', - }, - }, - { - basicInfo: { - groupStudyId: 2, - type: 'backend', - targetRole: 'senior', - maxMembers: 8, - experienceLevel: 'intermediate', - method: 'offline', - regularMeeting: '매주 토요일 2PM', - startDate: '2025-11-05', - durationWeeks: 8, - price: 100000, - status: 'planned', - createdAt: '2025-09-22T09:00:00Z', - updatedAt: '2025-09-26T11:00:00Z', - }, - simpleDetailInfo: { - title: 'Spring Boot 중급 스터디', - summary: '실무 중심으로 스프링부트를 다뤄보는 스터디', - }, - }, - { - basicInfo: { - groupStudyId: 3, - type: 'fullstack', - targetRole: 'all', - maxMembers: 10, - experienceLevel: 'beginner', - method: 'hybrid', - regularMeeting: '격주 일요일 3PM', - startDate: '2025-12-01', - durationWeeks: 10, - price: 0, - status: 'recruiting', - createdAt: '2025-09-23T14:20:00Z', - updatedAt: '2025-09-27T15:40:00Z', - }, - simpleDetailInfo: { - title: '풀스택 웹 프로젝트 스터디', - summary: 'React + Node.js로 팀 프로젝트 경험 쌓기', - }, - }, - { - basicInfo: { - groupStudyId: 4, - type: 'ai', - targetRole: 'researcher', - maxMembers: 6, - experienceLevel: 'advanced', - method: 'online', - regularMeeting: '매주 수요일 9PM', - startDate: '2025-10-15', - durationWeeks: 12, - price: 200000, - status: 'in-progress', - createdAt: '2025-09-24T08:00:00Z', - updatedAt: '2025-09-28T09:30:00Z', - }, - simpleDetailInfo: { - title: '딥러닝 논문 스터디', - summary: '최신 AI 논문 리뷰와 구현을 목표로 하는 스터디', - }, - }, - { - basicInfo: { - groupStudyId: 5, - type: 'mobile', - targetRole: 'junior', - maxMembers: 4, - experienceLevel: 'beginner', - method: 'offline', - regularMeeting: '매주 금요일 7PM', - startDate: '2025-11-20', - durationWeeks: 5, - price: 30000, - status: 'recruiting', - createdAt: '2025-09-25T10:45:00Z', - updatedAt: '2025-09-27T18:00:00Z', - }, - simpleDetailInfo: { - title: 'Flutter 앱 개발 기초', - summary: 'Flutter로 간단한 앱을 함께 만들어보는 스터디', - }, - }, -]; export default function Study() { return ( diff --git a/src/features/study/group/api/get-group-study-list.ts b/src/features/study/group/api/get-group-study-list.ts index fb687078..279a9650 100644 --- a/src/features/study/group/api/get-group-study-list.ts +++ b/src/features/study/group/api/get-group-study-list.ts @@ -1,21 +1,28 @@ import { axiosInstance } from '@/shared/tanstack-query/axios'; -import { GroupStudyListRequest } from './group-study-types'; +import { + GroupStudyListRequest, + GroupStudyListResponse, +} from './group-study-types'; // 그룹 스터디 리스트 조회 -export const getGroupStudyList = async (params: GroupStudyListRequest) => { +export const getGroupStudyList = async ( + params: GroupStudyListRequest, +): Promise => { const { page, size, status } = params; - const { data } = await axiosInstance.get('/group-studies', { - params: { - page, - size, - status, - }, - }); + try { + const { data } = await axiosInstance.get('/group-studies', { + params: { + page, + size, + status, + }, + }); - if (data.statusCode !== 200) { - throw new Error('Failed to fetch group study list'); - } + if (data.statusCode !== 200) { + throw new Error('Failed to fetch group study list'); + } - return data.content; + return data.content; + } catch (err) {} }; diff --git a/src/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts index 0bc55091..8c30ac42 100644 --- a/src/features/study/group/api/group-study-types.ts +++ b/src/features/study/group/api/group-study-types.ts @@ -1,7 +1,80 @@ -export type GroupStudyStatus = 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; - export interface GroupStudyListRequest { page: number; size: number; status: GroupStudyStatus; } + +export interface ImageSizeType { + imageTypeName: 'ORIGINAL' | 'SMALL' | 'MEDIUM' | 'LARGE'; + width: number | null; + height: number | null; +} + +export interface ResizedImage { + resizedImageId: number; + resizedImageUrl: string; + imageSizeType: ImageSizeType; +} + +export interface Thumbnail { + imageId: number; + resizedImages: ResizedImage[]; +} + +export interface SimpleDetailInfo { + thumbnail: Thumbnail; + title: string; + summary: string; +} + +export type GroupStudyStatus = 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; +export type GroupStudyType = 'PROJECT' | 'STUDY'; +export type HostType = 'ZEROONE' | 'GENERAL' | 'METOR'; +export type TargetRole = 'PLANNER' | 'BACKEND' | 'FRONTEND' | 'DESIGNER'; +export type ExperienceLevel = + | 'JUNIOR' + | 'MIDDLE' + | 'SENIOR' + | 'BEGINNER' + | 'JOB_SEEKER'; +export type Method = 'ONLINE' | 'OFFLINE'; +export type RegularMeeting = + | 'WEEKLY' + | 'BIWEEKLY' + | 'TRIPLE_WEEKLY_OR_MORE' + | 'NONE'; + +export interface BasicInfo { + groupStudyId: number; + type: GroupStudyType; + hostType: HostType; + targetRoles: TargetRole[]; + maxMembersCount: number; + experienceLevels: ExperienceLevel[]; + method: Method; + regularMeeting: RegularMeeting; + location: string; + startDate: string; + endDate: string; + price: number; + status: GroupStudyStatus; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface GroupStudyData { + basicInfo: BasicInfo; + simpleDetailInfo: SimpleDetailInfo; + currentParticipantCount: number; +} + +export interface GroupStudyListResponse { + content: GroupStudyData[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; +} diff --git a/src/features/study/group/ui/group-study-list.tsx b/src/features/study/group/ui/group-study-list.tsx index b96a685b..28aa91f2 100644 --- a/src/features/study/group/ui/group-study-list.tsx +++ b/src/features/study/group/ui/group-study-list.tsx @@ -3,6 +3,8 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import React, { Fragment } from 'react'; import Badge from '@/shared/ui/badge'; import { getGroupStudyList } from '../api/get-group-study-list'; +import { BasicInfo } from '../api/group-study-types'; +import Image from 'next/image'; enum Method { ONLINE = '온라인', @@ -26,28 +28,6 @@ enum Frequency { TRIPLE_WEEKLY_OR_MORE = '주 3회 이상', } -export interface GroupStudy { - basicInfo: { - groupStudyId: number; // int64 - type: Type[keyof Type]; - targetRoles: string; - maxMembersCount: number; // int32 - experienceLevels: string[]; - method: Method[keyof Method]; - regularMeeting: Frequency[keyof Frequency]; - startDate: string; // date - durationWeeks: number; // int32 - price: number; - status: string; - createdAt: string; // date-time - updatedAt: string; // date-time - }; - simpleDetailInfo: { - title: string; - summary: string; - }; -} - export default function GroupStudyList() { const { data, fetchNextPage } = useInfiniteQuery({ queryKey: ['groupStudies'], @@ -71,7 +51,10 @@ export default function GroupStudyList() { maxPages: 3, }); - const basicInfoItems = (basicInfo) => [ + const basicInfoItems = ( + basicInfo: BasicInfo, + currentParticipantCount: number, + ) => [ { label: '유형', value: Method[basicInfo.method as keyof typeof Method], @@ -121,7 +104,7 @@ export default function GroupStudyList() { }, { label: '모집인원', - value: `/${basicInfo.maxMembersCount}`, + value: `${currentParticipantCount}/${basicInfo.maxMembersCount}`, }, { label: '참가비', @@ -134,7 +117,7 @@ export default function GroupStudyList() { return (
- + {/* */} {data?.pages.map((page, i) => ( {page.content.map((study, index) => { @@ -146,17 +129,23 @@ export default function GroupStudyList() {
+ {study.basicInfo.hostType === 'ZEROONE' && ( + 제로원 스터디 + )} + {study.simpleDetailInfo.title} - 제로원 스터디

{study.simpleDetailInfo.summary}

- {basicInfoItems(study.basicInfo).map((item, idx) => ( + {basicInfoItems( + study.basicInfo, + study.currentParticipantCount, + ).map((item, idx) => (
{item.label} @@ -168,7 +157,14 @@ export default function GroupStudyList() { ))}
-
img
+ thumbnail
); })} From 65a2408cd1383d6f263fca51f0828f48312065d7 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Fri, 10 Oct 2025 21:44:13 +0900 Subject: [PATCH 101/128] =?UTF-8?q?feat:=20step3=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/const/group-study-const.ts | 1 - .../group/model/open-group-form.schema.ts | 20 ++++--- .../study/group/ui/step/step3-group.tsx | 54 ++++++++++++++++--- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/features/study/group/const/group-study-const.ts b/src/features/study/group/const/group-study-const.ts index 3de1f4c4..3d1b7b89 100644 --- a/src/features/study/group/const/group-study-const.ts +++ b/src/features/study/group/const/group-study-const.ts @@ -42,7 +42,6 @@ export const THUMBNAIL_EXTENSION = [ ] as const; // UI 매칭을 위한 라벨 const - export const STUDY_TYPE_LABELS = { PROJECT: '프로젝트', MENTORING: '멘토링', diff --git a/src/features/study/group/model/open-group-form.schema.ts b/src/features/study/group/model/open-group-form.schema.ts index c44042c0..96253ad4 100644 --- a/src/features/study/group/model/open-group-form.schema.ts +++ b/src/features/study/group/model/open-group-form.schema.ts @@ -19,7 +19,7 @@ export const OpenGroupFormSchema = z.object({ maxMembersCount: z .string() .trim() - .regex(/^[1-9]\d*$/, '최소 1 이상의 정수를 입력해 주세요.'), + .regex(/^[1-9]\d*$/, '최소 1명 이상을 선택해주세요.'), experienceLevels: z .array(z.enum(EXPERIENCE_LEVEL_OPTIONS)) .min(1, '경력을 1개 이상 선택해 주세요.'), @@ -34,14 +34,18 @@ export const OpenGroupFormSchema = z.object({ .string() .trim() .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 종료일을 입력해 주세요.'), - price: z - .string() - .trim() - .regex(/^\d+$/, '가격은 0 이상 정수로 입력해 주세요.'), + price: z.string().trim().optional(), title: z.string().trim().min(1, '스터디 제목을 입력해주세요.'), summary: z.string().trim().min(1, '한 줄 소개를 입력해주세요.'), description: z.string().trim().min(1, '스터디 소개를 입력해주세요.'), - interviewPost: z.string().optional(), + interviewPost: z + .array(z.string()) + .refine((arr) => arr.length > 0 && arr.every((v) => v.trim() !== ''), { + message: '모든 질문을 입력해야 합니다.', + }) + .refine((arr) => arr.length <= 10, { + message: '질문은 최대 10개까지만 입력할 수 있습니다.', + }), thumbnailExtension: z .enum(THUMBNAIL_EXTENSION) .refine((val) => val !== 'DEFAULT', '썸네일 이미지를 선택해주세요.'), @@ -68,7 +72,7 @@ export function buildOpenGroupDefaultValues(): OpenGroupFormValues { title: '', description: '', summary: '', - interviewPost: '', + interviewPost: [''], thumbnailExtension: 'DEFAULT', }; } @@ -98,7 +102,7 @@ export function toOpenGroupRequest( summary: v.summary, }, interviewPost: { - interviewPost: v.interviewPost ? [v.interviewPost] : [], + interviewPost: v.interviewPost ?? [], }, thumbnailExtension: v.thumbnailExtension, }; diff --git a/src/features/study/group/ui/step/step3-group.tsx b/src/features/study/group/ui/step/step3-group.tsx index d639bda1..12c889ca 100644 --- a/src/features/study/group/ui/step/step3-group.tsx +++ b/src/features/study/group/ui/step/step3-group.tsx @@ -1,24 +1,66 @@ 'use client'; +import { useEffect, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import Button from '@/shared/ui/button'; import FormField from '@/shared/ui/form/form-field'; -import { TextAreaInput } from '@/shared/ui/input'; +import { BaseInput } from '@/shared/ui/input'; import { OpenGroupFormValues } from '../../model/open-group-form.schema'; export default function Step3OpenGroupStudy() { - // todo: 그룹 폼 필드 변경해야 함 + const { setValue } = useFormContext(); + + const [questions, setQuestions] = useState(['']); + + useEffect(() => { + setValue( + 'interviewPost', + questions.map((q) => q.trim()), + { shouldValidate: true }, + ); + }, [questions, setValue]); + + const handleAdd = () => setQuestions((prev) => [...prev, '']); + const handleRemove = (index: number) => + setQuestions((prev) => prev.filter((_, i) => i !== index)); + const handleChange = (index: number, value: string) => + setQuestions((prev) => prev.map((q, i) => (i === index ? value : q))); + return ( <>
지원 & 규칙 설정
- + name="interviewPost" - label="스터디 지원 시 작성할 질문" - helper="스터디 지원자가 신청할 때 답변해야 하는 질문들을 작성하세요. (예: 지원 동기, 관련 경험, 기대하는 점 등)" + label="스터디원에게 보여줄 질문을 입력하세요" direction="vertical" + helper="스터디 지원자가 신청 시 작성해야 할 질문을 설정하세요. (예: 지원 동기, 경험, 기대하는 점 등)" size="medium" + required > - +
+ {questions.map((q, index) => ( +
+ handleChange(index, e.target.value)} + /> + {index > 0 && ( +
handleRemove(index)} + > + X +
+ )} +
+ ))} + +
From f06f47a55213501215ecda1f6820be7234a03cc0 Mon Sep 17 00:00:00 2001 From: Mimiminz Date: Fri, 10 Oct 2025 21:44:24 +0900 Subject: [PATCH 102/128] =?UTF-8?q?feat:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A4=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/group/api/creat-group-study.ts | 9 +++++ .../group/const/use-group-study-mutation.ts | 10 +++++ .../study/group/ui/open-group-modal.tsx | 37 +++++++++++-------- .../study/group/ui/step/step1-group.tsx | 2 +- 4 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 src/features/study/group/api/creat-group-study.ts create mode 100644 src/features/study/group/const/use-group-study-mutation.ts diff --git a/src/features/study/group/api/creat-group-study.ts b/src/features/study/group/api/creat-group-study.ts new file mode 100644 index 00000000..0f4d4a65 --- /dev/null +++ b/src/features/study/group/api/creat-group-study.ts @@ -0,0 +1,9 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { OpenGroupStudyRequest } from './group-study-types'; + +// CS 스터디 매칭 신청 +export const createGroupStudy = async (payload: OpenGroupStudyRequest) => { + const res = await axiosInstance.post('/group-studies', payload); + + return res.data; +}; diff --git a/src/features/study/group/const/use-group-study-mutation.ts b/src/features/study/group/const/use-group-study-mutation.ts new file mode 100644 index 00000000..bee72b30 --- /dev/null +++ b/src/features/study/group/const/use-group-study-mutation.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; +import { createGroupStudy } from '../api/creat-group-study'; +import { OpenGroupStudyRequest } from '../api/group-study-types'; + +// 그룹 스터디 개설 mutation +export const useCreateGroupStudyMutation = () => { + return useMutation({ + mutationFn: (payload: OpenGroupStudyRequest) => createGroupStudy(payload), + }); +}; diff --git a/src/features/study/group/ui/open-group-modal.tsx b/src/features/study/group/ui/open-group-modal.tsx index c7cb221c..ad9fe9db 100644 --- a/src/features/study/group/ui/open-group-modal.tsx +++ b/src/features/study/group/ui/open-group-modal.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { XIcon } from 'lucide-react'; import { useState } from 'react'; -import { FormProvider, useForm, type SubmitHandler } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; import Button from '@/shared/ui/button'; import { Modal } from '@/shared/ui/modal'; @@ -17,6 +17,7 @@ import { import Step1OpenGroupStudy from './step/step1-group'; import Step2OpenGroupStudy from './step/step2-group'; import Step3OpenGroupStudy from './step/step3-group'; +import { useCreateGroupStudyMutation } from '../const/use-group-study-mutation'; function Stepper({ step }: { step: 1 | 2 | 3 }) { const dot = (n: 1 | 2 | 3) => { @@ -79,6 +80,8 @@ export default function OpenGroupStudyModal({ } function OpenGroupStudyForm({ onClose }: { onClose: () => void }) { + const { mutate: createGroupStudy } = useCreateGroupStudyMutation(); + const methods = useForm({ resolver: zodResolver(OpenGroupFormSchema), mode: 'onChange', @@ -99,7 +102,6 @@ function OpenGroupStudyForm({ onClose }: { onClose: () => void }) { 'regularMeeting', 'startDate', 'endDate', - // 'price', ], 2: ['thumbnailExtension', 'title', 'description', 'summary'], 3: ['interviewPost'], @@ -113,6 +115,7 @@ function OpenGroupStudyForm({ onClose }: { onClose: () => void }) { return; } + if (step < 3) setStep((s) => (s + 1) as 1 | 2 | 3); }; @@ -120,10 +123,18 @@ function OpenGroupStudyForm({ onClose }: { onClose: () => void }) { if (step > 1) setStep((s) => (s - 1) as 1 | 2 | 3); }; - const onSubmit: SubmitHandler = (values) => { - const req = toOpenGroupRequest(values); - console.log('[DEV] OpenGroupRequest preview:', req); - alert('유효성 통과! (콘솔에서 요청 페이로드 미리보기 확인)'); + const onValidSubmit = (values: OpenGroupFormValues) => { + const body = toOpenGroupRequest(values); + + createGroupStudy(body, { + onSuccess: () => { + alert('그룹 스터디 개설이 완료되었습니다!'); + onClose(); + }, + onError: () => { + alert('그룹 스터디 개설 중 오류가 발생했습니다. 다시 시도해 주세요.'); + }, + }); onClose(); }; @@ -131,12 +142,11 @@ function OpenGroupStudyForm({ onClose }: { onClose: () => void }) { <> - - {...methods}> +
{step === 1 && } {step === 2 && } @@ -165,14 +175,9 @@ function OpenGroupStudyForm({ onClose }: { onClose: () => void }) { 취소 + {step < 3 ? ( - ) : ( diff --git a/src/features/study/group/ui/step/step1-group.tsx b/src/features/study/group/ui/step/step1-group.tsx index e519e02f..a559baaa 100644 --- a/src/features/study/group/ui/step/step1-group.tsx +++ b/src/features/study/group/ui/step/step1-group.tsx @@ -46,8 +46,8 @@ export default function Step1OpenGroupStudy() { control, }); const methodValue = useWatch({ - control, name: 'method', + control, }); return ( From 2df707fb14511d29c36ee7e9b6bb3f33c6cccb53 Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 10 Oct 2025 22:25:14 +0900 Subject: [PATCH 103/128] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=8B=A0=EC=B2=AD=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/ui/landing.tsx | 11 ++ .../study/group/api/apply-group-study.ts | 17 +++ .../study/group/api/group-study-types.ts | 17 +++ .../group/model/use-apply-group-study.tsx | 9 ++ .../group/ui/apply-group-study-modal.tsx | 118 ++++++++++++------ src/widgets/home/sidebar.tsx | 4 - 6 files changed, 135 insertions(+), 41 deletions(-) create mode 100644 src/features/study/group/api/apply-group-study.ts create mode 100644 src/features/study/group/api/group-study-types.ts create mode 100644 src/features/study/group/model/use-apply-group-study.tsx diff --git a/src/features/auth/ui/landing.tsx b/src/features/auth/ui/landing.tsx index df785b52..50c82083 100644 --- a/src/features/auth/ui/landing.tsx +++ b/src/features/auth/ui/landing.tsx @@ -4,6 +4,7 @@ import Image from 'next/image'; import { useState, useEffect } from 'react'; import LoginModal from '@/features/auth/ui/login-modal'; import SignupModal from '@/features/auth/ui/sign-up-modal'; +import ApplyGroupStudyModal from '@/features/study/group/ui/apply-group-study-modal'; import Button from '@/shared/ui/button'; export default function Landing({ isSignupPage }: { isSignupPage: boolean }) { @@ -36,6 +37,16 @@ export default function Landing({ isSignupPage }: { isSignupPage: boolean }) { /> setSignupOpen(false)} /> + {/* 테스트용 */} + {/* */} +
=> { + const res = await axiosInstance.post(`/group-studies/${groupStudyId}/apply`, { + answer, + }); + + return res.data; +}; diff --git a/src/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts new file mode 100644 index 00000000..91d4b2b6 --- /dev/null +++ b/src/features/study/group/api/group-study-types.ts @@ -0,0 +1,17 @@ +// 그룹 스터디 신청 상태 +type ApplicationStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'KICKED'; + +// 그룹 스터디 신청 Request 타입 +export interface ApplyGroupStudyRequest { + groupStudyId: number; + answer: string[]; +} + +// 그룹 스터디 신청 Response 타입 +export interface ApplyGroupStudyResponse { + applyId: number; + applicantId: number; + groupStudyId: number; + status: ApplicationStatus; + createdAt: string; +} diff --git a/src/features/study/group/model/use-apply-group-study.tsx b/src/features/study/group/model/use-apply-group-study.tsx new file mode 100644 index 00000000..f015af51 --- /dev/null +++ b/src/features/study/group/model/use-apply-group-study.tsx @@ -0,0 +1,9 @@ +import { useMutation } from '@tanstack/react-query'; +import { applyGroupStudy } from '../api/apply-group-study'; + +// 그룹 스터디 신청 훅 +export const useApplyGroupStudyMutation = () => { + return useMutation({ + mutationFn: applyGroupStudy, + }); +}; diff --git a/src/features/study/group/ui/apply-group-study-modal.tsx b/src/features/study/group/ui/apply-group-study-modal.tsx index 44c9f106..8fb6abee 100644 --- a/src/features/study/group/ui/apply-group-study-modal.tsx +++ b/src/features/study/group/ui/apply-group-study-modal.tsx @@ -4,19 +4,21 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { XIcon } from 'lucide-react'; import { useState } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useController, useForm } from 'react-hook-form'; import { z } from 'zod'; import Button from '@/shared/ui/button'; import Checkbox from '@/shared/ui/checkbox'; -import { TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; +import { useApplyGroupStudyMutation } from '../model/use-apply-group-study'; interface ApplyGroupStudyModalProps { + groupStudyId: number; title: string; questions: string[]; } export default function ApplyGroupStudyModal({ + groupStudyId, title, questions, }: ApplyGroupStudyModalProps) { @@ -41,6 +43,7 @@ export default function ApplyGroupStudyModal({ setOpen(false)} @@ -52,34 +55,66 @@ export default function ApplyGroupStudyModal({ } const ApplyGroupStudyFormSchema = z.object({ - answer: z.array(z.string().min(1, '답변을 작성해주세요.')), + answer: z.array( + z.string().min(1, '답변을 작성해주세요.'), // 각 항목에 최소 1글자 이상 + ), + agree: z + .boolean() + .refine((val) => val === true, { message: '참여 규칙에 동의해야 합니다.' }), }); -type ApplyGroupStudyFormValues = z.infer; - -function buildApplyGroupStudyDefaultValues(): ApplyGroupStudyFormValues { - return { - answer: [], - }; -} +type ApplyGroupStudyFormData = z.infer; function ApplyGroupStudyForm({ + groupStudyId, title, questions, onClose, }: { + groupStudyId: number; title: string; questions: string[]; onClose: () => void; }) { - const [checked, setChecked] = useState(false); - const methods = useForm<{ answer: string[] }>({ + const { + register, + handleSubmit, + control, + formState: { errors }, + } = useForm({ resolver: zodResolver(ApplyGroupStudyFormSchema), - mode: 'onChange', - defaultValues: buildApplyGroupStudyDefaultValues(), + defaultValues: { + answer: Array(questions.length).fill(''), + }, + }); + + // ✅ checkbox를 controller로 제어 + const { + field: { value: checked, onChange: onToggle }, + } = useController({ + name: 'agree', + control, }); - const handleApply = () => {}; + const { mutate: applyGroupStudy } = useApplyGroupStudyMutation(); + + const onSubmit = (data: ApplyGroupStudyFormData) => { + const { answer } = data; + + console.log(groupStudyId, answer); + + return; + + applyGroupStudy( + { answer, groupStudyId }, + { + onSuccess: () => { + alert('스터디 신청이 완료되었습니다.'); + onClose(); + }, + }, + ); + }; return ( <> @@ -91,23 +126,36 @@ function ApplyGroupStudyForm({ 리더의 질문 - - - {questions.map((question, index) => ( -
- - + {questions.map((question, index) => ( +
+ + +
+