diff --git a/app/redirection/page.tsx b/app/(service)/redirection/page.tsx
similarity index 100%
rename from app/redirection/page.tsx
rename to app/(service)/redirection/page.tsx
diff --git a/app/sign-up/page.tsx b/app/(service)/sign-up/page.tsx
similarity index 100%
rename from app/sign-up/page.tsx
rename to app/(service)/sign-up/page.tsx
diff --git a/app/global.css b/app/global.css
index 1cb1b7fa..4f4957b0 100644
--- a/app/global.css
+++ b/app/global.css
@@ -714,6 +714,374 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E
}
}
+/* 자동완성이 안되는 문제가 있어 잠시 주석처리 합니다 */
+/* @layer utilities {
+ /* typography */
+/* .font-display-headings1 {
+ font-size: 72px;
+ font-weight: var(--font-weight-semibold);
+ line-height: 108px;
+ letter-spacing: 0;
+ }
+
+ .font-display-headings2 {
+ font-size: 64px;
+ font-weight: var(--font-weight-bold);
+ line-height: 96px;
+ letter-spacing: 0;
+ }
+
+ .font-display-headings3 {
+ font-size: 58px;
+ font-weight: var(--font-weight-bold);
+ line-height: 88px;
+ letter-spacing: 0;
+ }
+
+ .font-display-headings4 {
+ font-size: 52px;
+ font-weight: var(--font-weight-bold);
+ line-height: 78px;
+ letter-spacing: 0;
+ }
+
+ .font-display-headings5 {
+ font-size: 48px;
+ font-weight: var(--font-weight-bold);
+ line-height: 72px;
+ letter-spacing: 0;
+ }
+
+ .font-display-headings6 {
+ font-size: 40px;
+ font-weight: var(--font-weight-bold);
+ line-height: 60px;
+ letter-spacing: 0;
+ }
+
+ .font-bold-h1 {
+ font-size: 36px;
+ font-weight: var(--font-weight-bold);
+ line-height: 54px;
+ letter-spacing: 0;
+ }
+
+ .font-bold-h2 {
+ font-size: 32px;
+ font-weight: var(--font-weight-bold);
+ line-height: 48px;
+ letter-spacing: 0;
+ }
+
+ .font-bold-h3 {
+ font-size: 28px;
+ font-weight: var(--font-weight-bold);
+ line-height: 42px;
+ letter-spacing: 0;
+ }
+
+ .font-bold-h4 {
+ font-size: 24px;
+ font-weight: var(--font-weight-bold);
+ line-height: 36px;
+ letter-spacing: 0;
+ }
+
+ .font-bold-h5 {
+ font-size: 20px;
+ font-weight: var(--font-weight-bold);
+ line-height: 30px;
+ letter-spacing: 0;
+ }
+
+ .font-bold-h6 {
+ font-size: 18px;
+ font-weight: var(--font-weight-bold);
+ line-height: 28px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-36r {
+ font-size: 36px;
+ font-weight: var(--font-weight-regular);
+ line-height: 54px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-36m {
+ font-size: 36px;
+ font-weight: var(--font-weight-medium);
+ line-height: 54px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-36b {
+ font-size: 36px;
+ font-weight: var(--font-weight-bold);
+ line-height: 54px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-32r {
+ font-size: 32px;
+ font-weight: var(--font-weight-regular);
+ line-height: 48px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-32m {
+ font-size: 32px;
+ font-weight: var(--font-weight-medium);
+ line-height: 48px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-32b {
+ font-size: 32px;
+ font-weight: var(--font-weight-bold);
+ line-height: 48px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-28r {
+ font-size: 28px;
+ font-weight: var(--font-weight-regular);
+ line-height: 42px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-28m {
+ font-size: 28px;
+ font-weight: var(--font-weight-medium);
+ line-height: 42px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-28b {
+ font-size: 28px;
+ font-weight: var(--font-weight-bold);
+ line-height: 42px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-24r {
+ font-size: 24px;
+ font-weight: var(--font-weight-regular);
+ line-height: 37px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-24m {
+ font-size: 24px;
+ font-weight: var(--font-weight-medium);
+ line-height: 37px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-24b {
+ font-size: 24px;
+ font-weight: var(--font-weight-bold);
+ line-height: 37px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-20r {
+ font-size: 20px;
+ font-weight: var(--font-weight-regular);
+ line-height: 30px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-20m {
+ font-size: 20px;
+ font-weight: var(--font-weight-medium);
+ line-height: 30px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-20b {
+ font-size: 20px;
+ font-weight: var(--font-weight-bold);
+ line-height: 30px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-18r {
+ font-size: 18px;
+ font-weight: var(--font-weight-regular);
+ line-height: 29px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-18m {
+ font-size: 18px;
+ font-weight: var(--font-weight-medium);
+ line-height: 29px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-18b {
+ font-size: 18px;
+ font-weight: var(--font-weight-bold);
+ line-height: 29px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-16r {
+ font-size: 16px;
+ font-weight: var(--font-weight-regular);
+ line-height: 25px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-16m {
+ font-size: 16px;
+ font-weight: var(--font-weight-medium);
+ line-height: 25px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-16b {
+ font-size: 16px;
+ font-weight: var(--font-weight-bold);
+ line-height: 25px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-15r {
+ font-size: 15px;
+ font-weight: var(--font-weight-regular);
+ line-height: 23px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-15m {
+ font-size: 15px;
+ font-weight: var(--font-weight-medium);
+ line-height: 23px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-15b {
+ font-size: 15px;
+ font-weight: var(--font-weight-bold);
+ line-height: 23px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-14r {
+ font-size: 14px;
+ font-weight: var(--font-weight-regular);
+ line-height: 22px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-14m {
+ font-size: 14px;
+ font-weight: var(--font-weight-medium);
+ line-height: 22px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-14b {
+ font-size: 14px;
+ font-weight: var(--font-weight-bold);
+ line-height: 22px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-13r {
+ font-size: 13px;
+ font-weight: var(--font-weight-regular);
+ line-height: 20px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-13m {
+ font-size: 13px;
+ font-weight: var(--font-weight-medium);
+ line-height: 20px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-13b {
+ font-size: 13px;
+ font-weight: var(--font-weight-bold);
+ line-height: 20px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-12r {
+ font-size: 12px;
+ font-weight: var(--font-weight-regular);
+ line-height: 19px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-12m {
+ font-size: 12px;
+ font-weight: var(--font-weight-medium);
+ line-height: 19px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-12b {
+ font-size: 12px;
+ font-weight: var(--font-weight-bold);
+ line-height: 19px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-11r {
+ font-size: 11px;
+ font-weight: var(--font-weight-regular);
+ line-height: 18px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-11m {
+ font-size: 11px;
+ font-weight: var(--font-weight-medium);
+ line-height: 18px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-11b {
+ font-size: 11px;
+ font-weight: var(--font-weight-bold);
+ line-height: 18px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-10r {
+ font-size: 10px;
+ font-weight: var(--font-weight-regular);
+ line-height: 17px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-10m {
+ font-size: 10px;
+ font-weight: var(--font-weight-medium);
+ line-height: 17px;
+ letter-spacing: 0;
+ }
+
+ .font-designer-10b {
+ font-size: 10px;
+ font-weight: var(--font-weight-bold);
+ line-height: 17px;
+ letter-spacing: 0;
+ }
+
+ .scrollbar-hide {
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ scrollbar-width: none;
+ }
+} */
+
/* shadcn 색상 테마 */
:root {
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/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/package.json b/package.json
index d0ae9f0f..2178740b 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",
@@ -33,6 +34,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "dayjs": "^1.11.18",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.475.0",
"next": "15.1.7",
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 00000000..20bbeb24
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/icons/arrow-left-line.svg b/public/icons/arrow-left-line.svg
new file mode 100644
index 00000000..56b8b508
--- /dev/null
+++ b/public/icons/arrow-left-line.svg
@@ -0,0 +1,3 @@
+
+
+
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/camera.svg b/public/icons/camera.svg
new file mode 100644
index 00000000..cc6a70ac
--- /dev/null
+++ b/public/icons/camera.svg
@@ -0,0 +1,4 @@
+
+
+
+
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/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/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/keyboard-arrow-left.svg b/public/icons/keyboard-arrow-left.svg
new file mode 100644
index 00000000..7cae4f94
--- /dev/null
+++ b/public/icons/keyboard-arrow-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/keyboard-arrow-right.svg b/public/icons/keyboard-arrow-right.svg
new file mode 100644
index 00000000..63932aaf
--- /dev/null
+++ b/public/icons/keyboard-arrow-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/logo.svg b/public/icons/logo.svg
new file mode 100644
index 00000000..8ffddc1c
--- /dev/null
+++ b/public/icons/logo.svg
@@ -0,0 +1,3 @@
+
+
+
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/public/icons/more.svg b/public/icons/more.svg
new file mode 100644
index 00000000..b467ef1c
--- /dev/null
+++ b/public/icons/more.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/out.svg b/public/icons/out.svg
new file mode 100644
index 00000000..48f3e4d6
--- /dev/null
+++ b/public/icons/out.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/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/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/public/icons/trending-down.svg b/public/icons/trending-down.svg
new file mode 100644
index 00000000..7a615a60
--- /dev/null
+++ b/public/icons/trending-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/icons/trending-up.svg b/public/icons/trending-up.svg
new file mode 100644
index 00000000..1ea42c7d
--- /dev/null
+++ b/public/icons/trending-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/features/study/api/get-review.ts b/src/entities/review/api/get-review.ts
similarity index 97%
rename from src/features/study/api/get-review.ts
rename to src/entities/review/api/get-review.ts
index 58b3a4cb..a53c290e 100644
--- a/src/features/study/api/get-review.ts
+++ b/src/entities/review/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 './types';
+} from '@/entities/review/api/review-types';
+import { axiosInstance } from '@/shared/tanstack-query/axios';
export const getPartnerStudyReview =
async (): Promise
=> {
diff --git a/src/features/study/api/types.ts b/src/entities/review/api/review-types.ts
similarity index 51%
rename from src/features/study/api/types.ts
rename to src/entities/review/api/review-types.ts
index 70fd16c9..22123814 100644
--- a/src/features/study/api/types.ts
+++ b/src/entities/review/api/review-types.ts
@@ -1,100 +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;
- 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/lib/use-reminder-review.tsx b/src/entities/review/lib/use-reminder-review.tsx
similarity index 100%
rename from src/features/study/lib/use-reminder-review.tsx
rename to src/entities/review/lib/use-reminder-review.tsx
diff --git a/src/features/study/model/use-review-query.ts b/src/entities/review/model/use-review-query.ts
similarity index 98%
rename from src/features/study/model/use-review-query.ts
rename to src/entities/review/model/use-review-query.ts
index fe2fbfc7..26d994a0 100644
--- a/src/features/study/model/use-review-query.ts
+++ b/src/entities/review/model/use-review-query.ts
@@ -4,6 +4,10 @@ import {
useQuery,
useSuspenseQuery,
} from '@tanstack/react-query';
+import {
+ MyNegativeKeywordsRequest,
+ UserPositiveKeywordsRequest,
+} from '@/entities/review/api/review-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/ui/study-review-modal.tsx b/src/entities/review/ui/study-review-modal.tsx
similarity index 98%
rename from src/features/study/ui/study-review-modal.tsx
rename to src/entities/review/ui/study-review-modal.tsx
index 4bfd7ace..e5f7a0f8 100644
--- a/src/features/study/ui/study-review-modal.tsx
+++ b/src/entities/review/ui/study-review-modal.tsx
@@ -3,17 +3,20 @@
import { XIcon } from 'lucide-react';
import Image from 'next/image';
import { useState } from 'react';
+import {
+ EvalKeyword,
+ StudyEvaluationResponse,
+} from '@/entities/review/api/review-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 '../model/use-review-query';
+} from '@/entities/review/model/use-review-query';
interface FormState {
studySpaceId: number;
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/entities/user/ui/my-profile-card.tsx b/src/entities/user/ui/my-profile-card.tsx
index cd2eac19..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/lib/use-reminder-review';
-import StudyReviewModal from '@/features/study/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-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/entities/user/ui/user-profile-modal.tsx b/src/entities/user/ui/user-profile-modal.tsx
index 041aaa84..9385fbe2 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 '@/entities/review/model/use-review-query';
interface UserProfileModalProps {
memberId: number;
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/member-list.server.ts b/src/features/admin/api/member-list.server.ts
new file mode 100644
index 00000000..3ac9c020
--- /dev/null
+++ b/src/features/admin/api/member-list.server.ts
@@ -0,0 +1,26 @@
+import { axiosServerInstance } from '@/shared/tanstack-query/axios.server';
+import { GetMemberListRequest, GetMemberListResponse } from './types';
+
+export const getMemberListInServer = async ({
+ roleId,
+ memberStatus,
+ searchKeyword,
+ page = 1,
+}: GetMemberListRequest): Promise => {
+ // size는 10으로 고정
+ let queryString = `page=${page}&size=10`;
+
+ if (roleId) {
+ queryString += `&role-id=${roleId}`;
+ }
+ if (memberStatus) {
+ queryString += `&member-status=${memberStatus}`;
+ }
+ if (searchKeyword) {
+ queryString += `&search-keyword=${searchKeyword}`;
+ }
+
+ const res = await axiosServerInstance.get(`/admin/members?${queryString}`);
+
+ return res.data.content;
+};
diff --git a/src/features/admin/api/member-list.ts b/src/features/admin/api/member-list.ts
new file mode 100644
index 00000000..6c5251ac
--- /dev/null
+++ b/src/features/admin/api/member-list.ts
@@ -0,0 +1,56 @@
+import { axiosInstance } from '@/shared/tanstack-query/axios';
+import {
+ ChangeMemberRoleRequest,
+ ChangeMemberStatusRequest,
+ GetMemberListRequest,
+ GetMemberListResponse,
+} from './types';
+
+// 사용자 목록 조회
+export const getMemberList = async ({
+ roleId,
+ memberStatus,
+ searchKeyword,
+ page = 1,
+}: GetMemberListRequest): Promise => {
+ // size는 10으로 고정
+ let queryString = `page=${page}&size=10`;
+
+ if (roleId) {
+ queryString += `&role-id=${roleId}`;
+ }
+ if (memberStatus) {
+ queryString += `&member-status=${memberStatus}`;
+ }
+ if (searchKeyword) {
+ queryString += `&search-keyword=${searchKeyword}`;
+ }
+
+ const res = await axiosInstance.get(`/admin/members?${queryString}`);
+
+ 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;
+};
+
+// 사용자 권한 변경
+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/sincerity-temperature-history.server.ts b/src/features/admin/api/sincerity-temperature-history.server.ts
new file mode 100644
index 00000000..e8ff25be
--- /dev/null
+++ b/src/features/admin/api/sincerity-temperature-history.server.ts
@@ -0,0 +1,19 @@
+import { axiosServerInstance } from '@/shared/tanstack-query/axios.server';
+import {
+ GetSincerityTemperatureHistoryRequest,
+ GetSincerityTemperatureHistoryResponse,
+} from './types';
+
+// 성실 온도 이력 조회 API 요청 함수
+export const getSincerityTemperatureHistoryInServer = async ({
+ memberId,
+ page = 1,
+}: GetSincerityTemperatureHistoryRequest): Promise => {
+ const queryString = `page=${page}&page-size=10`;
+
+ const res = await axiosServerInstance.get(
+ `/admin/members/${memberId}/sincerity-temperature-histories?${queryString}`,
+ );
+
+ return res.data.content;
+};
diff --git a/src/features/admin/api/sincerity-temperature-history.ts b/src/features/admin/api/sincerity-temperature-history.ts
new file mode 100644
index 00000000..188f202f
--- /dev/null
+++ b/src/features/admin/api/sincerity-temperature-history.ts
@@ -0,0 +1,19 @@
+import { axiosInstance } from '@/shared/tanstack-query/axios';
+import {
+ GetSincerityTemperatureHistoryRequest,
+ GetSincerityTemperatureHistoryResponse,
+} from './types';
+
+// 성실 온도 이력 조회 API 요청 함수
+export const getSincerityTemperatureHistory = async ({
+ memberId,
+ page = 1,
+}: GetSincerityTemperatureHistoryRequest): Promise => {
+ const queryString = `page=${page}&page-size=10`;
+
+ const res = await axiosInstance.get(
+ `/admin/members/${memberId}/sincerity-temperature-histories?${queryString}`,
+ );
+
+ return res.data.content;
+};
diff --git a/src/features/admin/api/types.ts b/src/features/admin/api/types.ts
new file mode 100644
index 00000000..494cde1b
--- /dev/null
+++ b/src/features/admin/api/types.ts
@@ -0,0 +1,92 @@
+export type RoleId = 'ROLE_MEMBER' | 'ROLE_ADMIN' | 'ROLE_MENTOR';
+type RoleName = '일반' | '관리자' | '멘토';
+export type MemberStatus = 'ACTIVE' | 'PAUSED' | 'PERM_BAN' | 'DORMANT';
+
+// 사용자 리스트 조회 API 요청 타입
+export interface GetMemberListRequest {
+ roleId?: RoleId;
+ memberStatus?: MemberStatus;
+ searchKeyword?: string;
+ page?: number;
+}
+
+// 사용자 리스트 조회 API 응답 타입
+export interface GetMemberListResponse {
+ page: number;
+ size: number;
+ totalElements: number;
+ totalPages: number;
+ hasNext: boolean;
+ hasPrevious: boolean;
+ content: {
+ memberId: number;
+ memberStatus: MemberStatus;
+ memberName: string;
+ joinedAt: string;
+ loginMostRecentlyAt: string;
+ role: {
+ roleId: RoleId;
+ roleName: RoleName;
+ };
+ }[];
+}
+
+// 사용자 계정 이력 조회 API 요청 타입
+export interface GetAccountHistoriesRequest {
+ memberId: number;
+}
+
+// 사용자 계정 이력 조회 API 응답 타입
+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;
+ }[];
+}
+
+// 사용자 상태 변경 API 요청 타입
+export interface ChangeMemberStatusRequest {
+ memberId: number;
+ to: MemberStatus;
+}
+
+// 사용자 권한 변경 API 요청 타입
+export interface ChangeMemberRoleRequest {
+ memberId: number;
+ roleId: RoleId;
+}
+
+// 성실 온도 이력 조회 API 요청 타입
+export interface GetSincerityTemperatureHistoryRequest {
+ memberId: number;
+ page: number;
+}
+
+// 성실 온도 이력 조회 API 응답 타입
+export interface GetSincerityTemperatureHistoryResponse {
+ currentSincerityTemperature: number;
+ sincerityTempLevel: 'FIRST' | 'SECOND' | 'THIRD' | 'FOURTH';
+ sincerityTemperatureHistory: {
+ content: {
+ reasonType: 'STUDY_REVIEW';
+ increment: number;
+ recordedAt: string;
+ }[];
+ page: number;
+ size: number;
+ totalElements: number;
+ totalPages: number;
+ hasNext: boolean;
+ hasPrevious: boolean;
+ };
+}
diff --git a/src/features/admin/const/member.ts b/src/features/admin/const/member.ts
new file mode 100644
index 00000000..9c269162
--- /dev/null
+++ b/src/features/admin/const/member.ts
@@ -0,0 +1,24 @@
+export const ROLE_MAP = {
+ ROLE_MEMBER: '일반',
+ ROLE_MENTOR: '멘토',
+ 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/model/use-member-list-query.ts b/src/features/admin/model/use-member-list-query.ts
new file mode 100644
index 00000000..38422eb3
--- /dev/null
+++ b/src/features/admin/model/use-member-list-query.ts
@@ -0,0 +1,127 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+ changeMemberRole,
+ changeMemberStatus,
+ getMemberList,
+} from '../api/member-list';
+import {
+ ChangeMemberRoleRequest,
+ ChangeMemberStatusRequest,
+ GetMemberListRequest,
+ GetMemberListResponse,
+} from '../api/types';
+
+// 사용자 목록 조회
+export const useGetMemberListQuery = ({
+ roleId,
+ memberStatus,
+ searchKeyword,
+ page = 1,
+}: GetMemberListRequest) => {
+ return useQuery({
+ queryKey: ['memberList', roleId, memberStatus, searchKeyword, page],
+ queryFn: () =>
+ getMemberList({
+ roleId,
+ memberStatus,
+ searchKeyword,
+ page,
+ }),
+ });
+};
+
+// 사용자 계정 상태 변경
+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'] });
+ },
+ });
+};
+
+// 사용자 권한 변경
+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/model/use-sincerity-temperature-history-query.ts b/src/features/admin/model/use-sincerity-temperature-history-query.ts
new file mode 100644
index 00000000..a183962c
--- /dev/null
+++ b/src/features/admin/model/use-sincerity-temperature-history-query.ts
@@ -0,0 +1,17 @@
+import { useQuery } from '@tanstack/react-query';
+import { getSincerityTemperatureHistory } from '../api/sincerity-temperature-history';
+import { GetSincerityTemperatureHistoryRequest } from '../api/types';
+
+export const useGetSincerityTemperatureHistoryQuery = ({
+ memberId,
+ page,
+}: GetSincerityTemperatureHistoryRequest) => {
+ return useQuery({
+ queryKey: ['sincerityTemperatureHistory', memberId, page],
+ queryFn: () =>
+ getSincerityTemperatureHistory({
+ memberId,
+ page,
+ }),
+ });
+};
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..18d2cb13
--- /dev/null
+++ b/src/features/admin/ui/chage-status-modal.tsx
@@ -0,0 +1,98 @@
+'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_OPTIONS } from '../const/member';
+import { useChangeMemberStatusMutation } from '../model/use-member-list-query';
+
+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 }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+
+
+ 취소
+
+
+
+ 변경하기
+
+
+ >
+ );
+}
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..b7751d8a
--- /dev/null
+++ b/src/features/admin/ui/change-role-modal.tsx
@@ -0,0 +1,98 @@
+'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_OPTIONS } from '../const/member';
+import { useChangeMemberRoleMutation } from '../model/use-member-list-query';
+
+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 }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+
+
+ 취소
+
+
+
+ 변경하기
+
+
+ >
+ );
+}
diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx
new file mode 100644
index 00000000..2cf2a30a
--- /dev/null
+++ b/src/features/admin/ui/member-list-table.tsx
@@ -0,0 +1,299 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useEffect, useRef, useState } from 'react';
+import { formatYYYYMMDD } from '@/shared/lib/time';
+import Badge from '@/shared/ui/badge';
+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 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';
+import { MemberStatus, RoleId } from '../api/types';
+import {
+ MEMBER_STATUS_MAP,
+ MEMBER_STATUS_OPTIONS,
+ ROLE_MAP,
+ ROLE_OPTIONS,
+} from '../const/member';
+import { useGetMemberListQuery } from '../model/use-member-list-query';
+
+export default function MemberListTable() {
+ const [roleId, setRoleId] = useState(null);
+ const [memberStatus, setMemberStatus] = useState(null);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [page, setPage] = useState(1);
+
+ const { data } = useGetMemberListQuery({
+ roleId,
+ memberStatus,
+ searchKeyword,
+ page,
+ });
+
+ const memberList = data?.content || [];
+
+ const [selectedIds, setSelectedIds] = useState>(() => new Set());
+ const headerCheckboxRef = useRef(null);
+
+ const allSelected =
+ memberList.length > 0 && selectedIds.size === memberList.length; // 모든 행을 선택했는지
+ const someSelected =
+ selectedIds.size > 0 && selectedIds.size < memberList.length; // 한개 이상 행을 선택했는지
+
+ useEffect(() => {
+ if (headerCheckboxRef.current) {
+ headerCheckboxRef.current.indeterminate = someSelected;
+ }
+ }, [someSelected]);
+
+ const toggleAll = () => {
+ if (allSelected) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(memberList.map((u) => u.memberId)));
+ }
+ };
+
+ const toggleRow = (id: number) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+
+ return next;
+ });
+ };
+
+ const router = useRouter();
+
+ return (
+ <>
+
+
+
사용자 관리
+ 총
+
+ {data?.totalElements}
+
+
+ 명의 사용자
+
+
+
+
+
+
+
+
+ {someSelected && (
+
+
+
+ {selectedIds.size}
+
+ 명 선택
+
+
+
+
+ selectedIds.has(member.memberId),
+ )}
+ />
+
+ selectedIds.has(member.memberId),
+ )}
+ />
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 이름
+
+
+ 가입일
+
+
+ 최근 로그인
+
+
+ 권한
+
+
+ 상태
+
+
+
+
+ {memberList.map((user, idx) => (
+ {
+ router.push(`/admin/detail/${user.memberId}/profile`);
+ }}
+ >
+ {
+ e.stopPropagation();
+ }}
+ >
+ toggleRow(user.memberId)}
+ checked={selectedIds.has(user.memberId)}
+ />
+
+
+ {user.memberName}
+
+
+ {formatYYYYMMDD(user.joinedAt)}
+
+
+ {user.loginMostRecentlyAt
+ ? formatYYYYMMDD(user.loginMostRecentlyAt)
+ : '-'}
+
+
+ {user.role.roleId === 'ROLE_MENTOR' && }
+ {ROLE_MAP[user.role.roleId]}
+
+
+
+ {MEMBER_STATUS_MAP[user.memberStatus]}
+
+
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
+
+function MemberListFilter({
+ roleId,
+ memberStatus,
+ onSelectRoleId,
+ onSelectMemberStatus,
+}: {
+ roleId: RoleId | null;
+ memberStatus: MemberStatus | null;
+ onSelectRoleId: (roleId: RoleId | null) => void;
+ onSelectMemberStatus: (memberStatus: MemberStatus | null) => void;
+}) {
+ return (
+
+ {(roleId || memberStatus) && (
+
{
+ onSelectRoleId(null);
+ onSelectMemberStatus(null);
+ }}
+ >
+ 필터 제거
+
+ )}
+
+
+
+
+
+ );
+}
+
+function MemberListSearchInput({
+ value,
+ onChange,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+}) {
+ return (
+
+
+
+ onChange(e.target.value)}
+ className="outline-0"
+ placeholder="이름으로 검색"
+ />
+
+
+ {value.length > 0 && (
+
onChange('')}>
+
+
+ )}
+
+ );
+}
diff --git a/src/features/admin/ui/sincerity-temp-table.tsx b/src/features/admin/ui/sincerity-temp-table.tsx
new file mode 100644
index 00000000..5490dca3
--- /dev/null
+++ b/src/features/admin/ui/sincerity-temp-table.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { useState } from 'react';
+import { formatYYYYMMDD } from '@/shared/lib/time';
+import Pagination from '@/shared/ui/pagination';
+import TrendingDown from 'public/icons/trending-down.svg';
+import TrendingUp from 'public/icons/trending-up.svg';
+import { useGetSincerityTemperatureHistoryQuery } from '../model/use-sincerity-temperature-history-query';
+
+interface SincerityTempTableProps {
+ memberId: number;
+}
+
+export default function SincerityTempTable({
+ memberId,
+}: SincerityTempTableProps) {
+ const [page, setPage] = useState(1);
+
+ const { data } = useGetSincerityTemperatureHistoryQuery({
+ memberId,
+ page,
+ });
+
+ const sincerityTemperatureHistory = data?.sincerityTemperatureHistory.content;
+
+ return (
+ <>
+
+
+ 성실온도 내역
+
+
+
+ {sincerityTemperatureHistory?.length > 0 ? (
+ sincerityTemperatureHistory.map((history, idx) => (
+
+
+
+ {history.reasonType === 'STUDY_REVIEW' ? '스터디 리뷰' : ''}
+
+
+ {formatYYYYMMDD(history.recordedAt)}
+
+
+
+
+ {history.increment > 0 ? (
+ <>
+
+ +{history.increment}℃
+
+
+ >
+ ) : (
+ <>
+
+ {history.increment}℃
+
+
+ >
+ )}
+
+
+ ))
+ ) : (
+
+ 성실 온도 내역이 없습니다.
+
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/auth/ui/header-user-dropdown.tsx b/src/features/auth/ui/header-user-dropdown.tsx
index 9ccd7be5..fd7028ae 100644
--- a/src/features/auth/ui/header-user-dropdown.tsx
+++ b/src/features/auth/ui/header-user-dropdown.tsx
@@ -1,23 +1,44 @@
'use client';
import { useRouter } from 'next/navigation';
+import { decodeJwt } from '@/shared/lib/jwt';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/shared/shadcn/ui/dropdown-menu';
+import { getCookie } from '@/shared/tanstack-query/cookie';
import UserAvatar from '@/shared/ui/avatar';
import { useLogoutMutation } from '../model/use-auth-mutation';
export default function HeaderUserDropdown({ userImg }: { userImg: string }) {
const { mutateAsync: logout } = useLogoutMutation();
+
+ const jwt = getCookie('accessToken');
+ const decodedJwt = decodeJwt(jwt);
+
+ const hasAdminRole = decodedJwt && decodedJwt?.roleIds.includes('ROLE_ADMIN');
+
const router = useRouter();
const handleLogout = async () => {
await logout();
};
+ const baseOptions = [
+ {
+ label: '내 정보 수정',
+ value: '/my-page',
+ onMenuClick: () => router.push('/my-page'),
+ },
+ {
+ label: '로그아웃',
+ value: 'logout',
+ onMenuClick: handleLogout,
+ },
+ ];
+
return (
@@ -27,18 +48,17 @@ export default function HeaderUserDropdown({ userImg }: { userImg: string }) {
- {[
- {
- label: '내 정보 수정',
- value: '/my-page',
- onMenuClick: () => router.push('/my-page'),
- },
- {
- label: '로그아웃',
- value: 'logout',
- onMenuClick: handleLogout,
- },
- ].map((option) => (
+ {(hasAdminRole
+ ? [
+ ...baseOptions,
+ {
+ label: '서비스 관리',
+ value: '/admin',
+ onMenuClick: () => router.push('/admin'),
+ },
+ ]
+ : baseOptions
+ ).map((option) => (
(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/api/get-entry-list.ts b/src/features/my-page/api/get-entry-list.ts
new file mode 100644
index 00000000..070b71dd
--- /dev/null
+++ b/src/features/my-page/api/get-entry-list.ts
@@ -0,0 +1,25 @@
+import { axiosInstance } from '@/shared/tanstack-query/axios';
+import { EntryListRequest, GroupStudyApplyListResponse } from './types';
+
+// 그룹 스터디 리스트 조회
+export const getEntryList = async (
+ params: EntryListRequest,
+): Promise => {
+ const { page, size, status, groupStudyId } = params;
+
+ const { data } = await axiosInstance.get(
+ `/group-studies/${groupStudyId}/applies?applyStatus=${status}`,
+ {
+ params: {
+ page,
+ size,
+ },
+ },
+ );
+
+ 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..61b69ba4 100644
--- a/src/features/my-page/api/types.ts
+++ b/src/features/my-page/api/types.ts
@@ -80,3 +80,80 @@ 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';
+}
+
+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 ProfileImage {
+ imageId: number;
+ resizedImages: ResizedImage[];
+}
+
+export interface SincerityTemp {
+ temperature: number;
+ levelId: number;
+ levelName: '1단계' | '2단계' | '3단계' | '4단계'; // 추후 서버 기준에 맞게 확장 가능
+}
+
+export interface Applicant {
+ memberId: number;
+ memberName: string;
+ profileImage: ProfileImage;
+ sincerityTemp: SincerityTemp;
+}
+
+export interface GroupStudy {
+ groupStudyId: number;
+ title: string;
+ description: string;
+}
+
+export type ApplyRole = 'LEADER' | 'PARTICIPANT';
+export type ApplyStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
+
+export interface GroupStudyApply {
+ applyId: number;
+ applicantInfo: Applicant;
+ groupStudy: GroupStudy;
+ progressScore: number;
+ role: ApplyRole;
+ lastAccessed: string;
+ answer: string[];
+ status: ApplyStatus;
+ processedAt: string | null;
+ reason: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface GroupStudyApplyListResponse {
+ content: GroupStudyApply[];
+ page: number;
+ size: number;
+ totalElements: number;
+ totalPages: number;
+ hasNext: boolean;
+ hasPrevious: boolean;
+}
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..4ce20b5f
--- /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 updateEntryStatus = 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/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/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/entry-card.tsx b/src/features/my-page/ui/entry-card.tsx
new file mode 100644
index 00000000..eee4980a
--- /dev/null
+++ b/src/features/my-page/ui/entry-card.tsx
@@ -0,0 +1,89 @@
+import dayjs from 'dayjs';
+import Image from 'next/image';
+import React from 'react';
+import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets';
+import { cn } from '@/shared/shadcn/lib/utils';
+import Button from '@/shared/ui/button';
+import { GroupStudyApply } from '../api/types';
+
+export default function EntryCard(props: { data: GroupStudyApply }) {
+ const { data: applicant } = props;
+ const temperPreset = getSincerityPresetByLevelName(
+ applicant.applicantInfo.sincerityTemp.levelName as string,
+ );
+
+ const timeAgo = (date: string | Date): string => {
+ const now = dayjs();
+ const target = dayjs(date);
+
+ if (!target.isValid()) return '';
+
+ const diffMin = now.diff(target, 'minute');
+ if (diffMin < 1) return '방금 전';
+ if (diffMin < 60) return `${diffMin}분 전`;
+
+ const diffHour = now.diff(target, 'hour');
+ if (diffHour < 24) return `${diffHour}시간 전`;
+
+ const diffDay = now.diff(target, 'day');
+ if (diffDay < 7) return `${diffDay}일 전`;
+
+ const diffWeek = Math.floor(diffDay / 7);
+
+ return `${diffWeek}주 전`;
+ };
+
+ const ApplicantStatus = () => {};
+
+ return (
+
+
+
+
+
+
+ {applicant.applicantInfo.memberName}
+
+
+ {`${applicant.applicantInfo.sincerityTemp.temperature}`} ℃
+
+
+
+
+ {timeAgo(applicant.createdAt)}
+
+
+
+
{applicant.answer}
+
+
+ 반려
+
+
+ 승인
+
+
+
+ );
+}
diff --git a/src/features/my-page/ui/entry-list.tsx b/src/features/my-page/ui/entry-list.tsx
new file mode 100644
index 00000000..8e0a743c
--- /dev/null
+++ b/src/features/my-page/ui/entry-list.tsx
@@ -0,0 +1,44 @@
+'use client';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import React from 'react';
+import EntryCard from './entry-card';
+import { getEntryList } from '../api/get-entry-list';
+
+export default function EntryList() {
+ const { data, fetchNextPage } = useInfiniteQuery({
+ queryKey: ['entryList'],
+ queryFn: async ({ pageParam }) => {
+ const response = await getEntryList({
+ groupStudyId: 1,
+ page: pageParam,
+ size: 20,
+ status: 'PENDING',
+ });
+
+ return response;
+ },
+ getNextPageParam: (lastPage) => {
+ if (lastPage.hasNext) {
+ return lastPage.page + 1;
+ }
+
+ return null;
+ },
+ initialPageParam: 0,
+ maxPages: 3,
+ });
+
+ console.log('data', data);
+
+ return (
+
+ {data?.pages.map((page, pageIndex) => (
+
+ {page.content.map((applicant) => (
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/src/features/my-page/ui/profile-edit-modal.tsx b/src/features/my-page/ui/profile-edit-modal.tsx
index d6126b95..b9f2b7d9 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({
@@ -94,13 +90,13 @@ function ProfileEditForm({
const {
handleSubmit,
- formState: { isValid, isSubmitting, dirtyFields },
+ formState: { isValid, isSubmitting },
} = methods;
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/features/my-page/ui/profile-info-edit-modal.tsx b/src/features/my-page/ui/profile-info-edit-modal.tsx
index e3e856b5..254052fa 100644
--- a/src/features/my-page/ui/profile-info-edit-modal.tsx
+++ b/src/features/my-page/ui/profile-info-edit-modal.tsx
@@ -13,7 +13,7 @@ import { MultiDropdown, SingleDropdown } from '@/shared/ui/dropdown';
import FormField from '@/shared/ui/form/form-field';
import { TextAreaInput } from '@/shared/ui/input';
import { Modal } from '@/shared/ui/modal';
-import { ToggleGroup } from '@/shared/ui/toggle';
+import { GroupItems } from '@/shared/ui/toggle';
import {
ProfileInfoFormSchema,
@@ -210,7 +210,7 @@ function ProfileInfoEditForm({
direction="vertical"
required
>
-
+
diff --git a/src/features/study/api/get-study-data.ts b/src/features/study/api/get-study-data.ts
deleted file mode 100644
index 89f1c674..00000000
--- a/src/features/study/api/get-study-data.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import type {
- CompleteStudyRequest,
- DailyStudyDetail,
- GetDailyStudiesParams,
- GetDailyStudiesResponse,
- GetMonthlyCalendarParams,
- JoinStudyRequest,
- MonthlyCalendarResponse,
- PostDailyRetrospectRequest,
- PrepareStudyRequest,
- WeeklyParticipationResponse,
-} from '@/features/study/api/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,
-): Promise => {
- const res = await axiosInstance.get('/study/daily', { params });
-
- return res.data.content;
-};
-
-// 월 별 스터디 캘린더 조회
-export const getMonthlyStudyCalendar = async (
- params: GetMonthlyCalendarParams,
-): Promise => {
- const res = await axiosInstance.get('/study/daily/month', { params });
-
- 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,
-): Promise => {
- const res = await axiosInstance.get('/study/week/participation', {
- params: { studyDate },
- });
-
- return res.data.content;
-};
diff --git a/src/features/study/group/api/apply-group-study.ts b/src/features/study/group/api/apply-group-study.ts
new file mode 100644
index 00000000..372dfed8
--- /dev/null
+++ b/src/features/study/group/api/apply-group-study.ts
@@ -0,0 +1,17 @@
+import { axiosInstance } from '@/shared/tanstack-query/axios';
+import {
+ ApplyGroupStudyRequest,
+ ApplyGroupStudyResponse,
+} from './group-study-types';
+
+// 그룹 스터디 신청 요청
+export const applyGroupStudy = async ({
+ groupStudyId,
+ answer,
+}: ApplyGroupStudyRequest): Promise => {
+ const res = await axiosInstance.post(`/group-studies/${groupStudyId}/apply`, {
+ answer,
+ });
+
+ return res.data;
+};
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/api/get-group-study-list.ts b/src/features/study/group/api/get-group-study-list.ts
new file mode 100644
index 00000000..279a9650
--- /dev/null
+++ b/src/features/study/group/api/get-group-study-list.ts
@@ -0,0 +1,28 @@
+import { axiosInstance } from '@/shared/tanstack-query/axios';
+import {
+ GroupStudyListRequest,
+ GroupStudyListResponse,
+} from './group-study-types';
+
+// 그룹 스터디 리스트 조회
+export const getGroupStudyList = async (
+ params: GroupStudyListRequest,
+): Promise => {
+ const { page, size, status } = params;
+
+ 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');
+ }
+
+ 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
new file mode 100644
index 00000000..219f78be
--- /dev/null
+++ b/src/features/study/group/api/group-study-types.ts
@@ -0,0 +1,148 @@
+import {
+ EXPERIENCE_LEVEL_OPTIONS,
+ REGULAR_MEETINGS,
+ STUDY_METHODS,
+ STUDY_TYPES,
+ TARGET_ROLE_OPTIONS,
+ THUMBNAIL_EXTENSION,
+} from '../const/group-study-const';
+
+// 그룹 스터디 신청 상태
+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;
+}
+
+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 const EXTENSION_TO_MIME: Record<
+ Uppercase<(typeof THUMBNAIL_EXTENSION)[number]>,
+ string
+> = {
+ DEFAULT: 'image/jpeg',
+ JPG: 'image/jpeg',
+ JPEG: 'image/jpeg',
+ PNG: 'image/png',
+ GIF: 'image/gif',
+ WEBP: 'image/webp',
+ SVG: 'image/svg+xml',
+};
+
+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;
+}
+
+// 그룹 리스트 타입
+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 Method = 'ONLINE' | 'OFFLINE';
+
+export interface DetailBasicInfo {
+ 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: DetailBasicInfo;
+ 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/const/group-study-const.ts b/src/features/study/group/const/group-study-const.ts
new file mode 100644
index 00000000..3d1b7b89
--- /dev/null
+++ b/src/features/study/group/const/group-study-const.ts
@@ -0,0 +1,80 @@
+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/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/model/open-group-form.schema.ts b/src/features/study/group/model/open-group-form.schema.ts
new file mode 100644
index 00000000..96253ad4
--- /dev/null
+++ b/src/features/study/group/model/open-group-form.schema.ts
@@ -0,0 +1,109 @@
+import { z } from 'zod';
+import { OpenGroupStudyRequest } from '../api/group-study-types';
+import {
+ STUDY_TYPES,
+ 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}$/;
+
+export const OpenGroupFormSchema = z.object({
+ type: z.enum(STUDY_TYPES),
+ targetRoles: z
+ .array(z.enum(TARGET_ROLE_OPTIONS))
+ .min(1, '역할을 1개 이상 선택해 주세요.'),
+ maxMembersCount: z
+ .string()
+ .trim()
+ .regex(/^[1-9]\d*$/, '최소 1명 이상을 선택해주세요.'),
+ experienceLevels: z
+ .array(z.enum(EXPERIENCE_LEVEL_OPTIONS))
+ .min(1, '경력을 1개 이상 선택해 주세요.'),
+ method: z.enum(STUDY_METHODS),
+ location: z.string().trim(),
+ regularMeeting: z.enum(REGULAR_MEETINGS),
+ startDate: z
+ .string()
+ .trim()
+ .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 시작일을 입력해 주세요.'),
+ endDate: z
+ .string()
+ .trim()
+ .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 종료일을 입력해 주세요.'),
+ 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
+ .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', '썸네일 이미지를 선택해주세요.'),
+});
+
+// 사진 상태 저장을 위한 로컬용 state
+export type OpenGroupFormValues = z.input & {
+ thumbnailFile?: File | undefined;
+};
+export type OpenGroupParsedValues = z.output;
+
+export function buildOpenGroupDefaultValues(): OpenGroupFormValues {
+ return {
+ type: 'PROJECT',
+ targetRoles: [],
+ maxMembersCount: '',
+ experienceLevels: [],
+ method: 'ONLINE',
+ location: '',
+ regularMeeting: 'NONE',
+ startDate: '',
+ endDate: '',
+ price: '',
+ title: '',
+ description: '',
+ summary: '',
+ interviewPost: [''],
+ thumbnailExtension: 'DEFAULT',
+ };
+}
+
+export function toOpenGroupRequest(
+ v: OpenGroupParsedValues,
+): OpenGroupStudyRequest {
+ return {
+ basicInfo: {
+ type: v.type,
+ targetRoles: v.targetRoles,
+ maxMembersCount: Number(v.maxMembersCount),
+ experienceLevels: v.experienceLevels ?? [],
+ method: v.method,
+ regularMeeting: v.regularMeeting,
+ location: v.location.trim(),
+ startDate: v.startDate.trim(),
+ endDate: v.endDate.trim(),
+ price: Number(v.price),
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ detailInfo: {
+ thumbnailExtension: v.thumbnailExtension,
+ title: v.title,
+ description: v.description,
+ summary: v.summary,
+ },
+ interviewPost: {
+ interviewPost: v.interviewPost ?? [],
+ },
+ thumbnailExtension: v.thumbnailExtension,
+ };
+}
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
new file mode 100644
index 00000000..922bd197
--- /dev/null
+++ b/src/features/study/group/ui/apply-group-study-modal.tsx
@@ -0,0 +1,226 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { XIcon } from 'lucide-react';
+import { useState } from 'react';
+
+import { useController, useForm } from 'react-hook-form';
+import { z } from 'zod';
+import Button from '@/shared/ui/button';
+import Checkbox from '@/shared/ui/checkbox';
+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) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+ 신청하기
+
+
+
+
+
+
+
+ 스터디 신청서 작성하기
+
+ setOpen(false)}>
+
+
+
+
+ setOpen(false)}
+ />
+
+
+
+ );
+}
+
+const ApplyGroupStudyFormSchema = z.object({
+ answer: z.array(
+ z.string().min(1, '답변을 작성해주세요.'), // 각 항목에 최소 1글자 이상
+ ),
+ agree: z
+ .boolean()
+ .refine((val) => val === true, { message: '참여 규칙에 동의해야 합니다.' }),
+});
+
+type ApplyGroupStudyFormData = z.infer;
+
+function ApplyGroupStudyForm({
+ groupStudyId,
+ title,
+ questions,
+ onClose,
+}: {
+ groupStudyId: number;
+ title: string;
+ questions: string[];
+ onClose: () => void;
+}) {
+ const {
+ register,
+ handleSubmit,
+ control,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(ApplyGroupStudyFormSchema),
+ defaultValues: {
+ answer: Array(questions.length).fill(''),
+ },
+ });
+
+ // ✅ checkbox를 controller로 제어
+ const {
+ field: { value: checked, onChange: onToggle },
+ } = useController({
+ name: 'agree',
+ control,
+ });
+
+ const { mutate: applyGroupStudy } = useApplyGroupStudyMutation();
+
+ const onSubmit = (data: ApplyGroupStudyFormData) => {
+ const { answer } = data;
+
+ applyGroupStudy(
+ { answer, groupStudyId },
+ {
+ onSuccess: () => {
+ alert('스터디 신청이 완료되었습니다.');
+ onClose();
+ },
+ },
+ );
+ };
+
+ return (
+ <>
+
+ {title}
+
+
+
+
+
스터디 참여 규칙을 준수해주세요
+
+
+
+ ·
+ 모집 공고의 정보는 사실에 기반해 작성해야 합니다.
+
+
+ ·
+ 진행 기간과 모임 빈도를 명확히 안내해야 합니다.
+
+
+ ·
+
+ 스터디 개설 후 최소 1회 이상 정기 모임을 진행해야 합니다.
+
+
+
+ ·
+ 스터디원과의 약속을 존중하고 성실히 운영해야 합니다.
+
+
+ ·
+
+ 타인의 개인정보 및 자료를 무단으로 공유하거나 외부 유출해서는 안
+ 됩니다.
+
+
+
+ ·
+
+ 플랫폼의 운영 정책 및 커뮤니티 가이드를 준수해야 합니다.
+
+
+
+
+
+
+
+ 참여 규칙을 확인하시고 동의해 주시겠습니까?
+
+ 필수
+
+
+
+
+
+
+
+
+
+
+ 취소
+
+
+
+ 신청하기
+
+
+ >
+ );
+}
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..ee9e4df7
--- /dev/null
+++ b/src/features/study/group/ui/group-study-list.tsx
@@ -0,0 +1,175 @@
+'use client';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import Image from 'next/image';
+import React, { Fragment } from 'react';
+import Badge from '@/shared/ui/badge';
+import { getGroupStudyList } from '../api/get-group-study-list';
+import { DetailBasicInfo } from '../api/group-study-types';
+
+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 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: DetailBasicInfo,
+ currentParticipantCount: number,
+ ) => [
+ {
+ 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: `${currentParticipantCount}/${basicInfo.maxMembersCount}`,
+ },
+ {
+ label: '참가비',
+ value:
+ basicInfo.price === 0
+ ? '무료'
+ : `${basicInfo.price.toLocaleString()}원`,
+ },
+ ];
+
+ return (
+
+ {/*
fetchNextPage()}>더보기 */}
+ {data?.pages.map((page, i) => (
+
+ {page.content.map((study, index) => {
+ return (
+
+
+
+
+ {study.basicInfo.hostType === 'ZEROONE' && (
+ 제로원 스터디
+ )}
+
+
+ {study.simpleDetailInfo.title}
+
+
+
+ {study.simpleDetailInfo.summary}
+
+
+
+ {basicInfoItems(
+ study.basicInfo,
+ study.currentParticipantCount,
+ ).map((item, idx) => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+
+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/src/features/study/group/ui/group-study-thumbnail-input.tsx b/src/features/study/group/ui/group-study-thumbnail-input.tsx
new file mode 100644
index 00000000..f0a3c53e
--- /dev/null
+++ b/src/features/study/group/ui/group-study-thumbnail-input.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import Image from 'next/image';
+import { useState, DragEvent, ChangeEvent, useRef } from 'react';
+import Button from '@/shared/ui/button';
+
+const inputStyles = {
+ base: 'rounded-100 flex w-full flex-col items-center justify-center rounded-lg border-2 p-500',
+ dragging: 'border-border-brand bg-fill-brand-subtle-hover',
+ notDragging: 'border-gray-300 border-gray-300 border-dashed',
+};
+
+export default function GroupStudyThumbnailInput({
+ image,
+ onChangeImage,
+}: {
+ image?: string;
+ onChangeImage: (file: File | null) => void;
+}) {
+ const fileInputRef = useRef(null);
+
+ const handleOpenFileDialog = () => {
+ fileInputRef.current?.click();
+ };
+
+ const [isDragging, setIsDragging] = useState(false);
+
+ // 영역 안에 드래그 들어왔을 때
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ };
+ // 영역 밖으로 드래그 나갈 때
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ };
+ // 영역 안에서 드래그 중일 때
+ const handleDragOver = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (e.dataTransfer.files) {
+ setIsDragging(true);
+ }
+ };
+ // 영역 안에서 drop 했을 때
+ const handleDrop = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ const file = e.dataTransfer.files[0]; // 1장만 허용
+
+ if (file && file.type.startsWith('image/')) {
+ onChangeImage(file);
+ }
+ };
+
+ // 파일 업로드 버튼으로 파일 선택했을 때 preview 설정
+ const handleFileChange = (e: ChangeEvent) => {
+ const file = e.target.files?.[0];
+
+ if (file && file.type.startsWith('image/')) {
+ onChangeImage(file);
+ }
+ };
+
+ const handleRemove = () => {
+ onChangeImage(undefined);
+ };
+
+ return (
+
+ {!image ? (
+
+
+
+
+ 드래그하여 파일 업로드
+
+
+
+
+ 파일 업로드
+
+
+ ) : (
+
+
+
+ ✕
+
+
+ )}
+
+ );
+}
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..814b341d
--- /dev/null
+++ b/src/features/study/group/ui/open-group-modal.tsx
@@ -0,0 +1,215 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { XIcon } from 'lucide-react';
+import { useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import Button from '@/shared/ui/button';
+import { Modal } from '@/shared/ui/modal';
+
+import {
+ OpenGroupFormSchema,
+ type OpenGroupFormValues,
+ buildOpenGroupDefaultValues,
+ toOpenGroupRequest,
+} from '../model/open-group-form.schema';
+import Step1OpenGroupStudy from './step/step1-group';
+import Step2OpenGroupStudy from './step/step2-group';
+import Step3OpenGroupStudy from './step/step3-group';
+import { EXTENSION_TO_MIME } from '../api/group-study-types';
+import { useCreateGroupStudyMutation } from '../const/use-group-study-mutation';
+
+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 {
+ trigger?: React.ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}
+
+export default function OpenGroupStudyModal({
+ trigger,
+ open,
+ onOpenChange,
+}: OpenGroupStudyModalProps) {
+ return (
+
+ {trigger && {trigger} }
+
+
+
+
+
+ 그룹 개설하기
+
+
+
+
+
+ onOpenChange?.(false)} />
+
+
+
+ );
+}
+
+function OpenGroupStudyForm({ onClose }: { onClose: () => void }) {
+ const { mutateAsync: createGroupStudy } = useCreateGroupStudyMutation();
+
+ 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',
+ 'targetRoles',
+ 'maxMembersCount',
+ 'experienceLevels',
+ 'method',
+ 'location',
+ 'regularMeeting',
+ 'startDate',
+ 'endDate',
+ ],
+ 2: ['thumbnailExtension', 'title', 'description', 'summary'],
+ 3: ['interviewPost'],
+ };
+
+ const goNext = async () => {
+ const fields = STEP_FIELDS[step];
+ const ok = await trigger(fields as any, { shouldFocus: true });
+ if (!ok) {
+ console.log('trigger failed. errors:', methods.formState.errors);
+
+ 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 onValidSubmit = async (values: OpenGroupFormValues) => {
+ try {
+ const body = toOpenGroupRequest(values);
+
+ const created = await createGroupStudy(body);
+
+ const uploadUrl: string = created.content.thumbnailUploadUrl;
+ const file = methods.getValues('thumbnailFile');
+
+ if (uploadUrl && file) {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const res = await fetch(uploadUrl, {
+ method: 'PUT',
+ body: formData,
+ });
+
+ if (!res.ok) {
+ console.error('파일 업로드 실패:', res.status, res.statusText);
+ } else {
+ console.log('파일 업로드 성공!');
+ }
+ }
+ alert('그룹 스터디 개설이 완료되었습니다!');
+ onClose();
+ } catch (err) {
+ alert('그룹 스터디 개설 중 오류가 발생했습니다. 다시 시도해 주세요.');
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ {step === 1 && }
+ {step === 2 && }
+ {step === 3 && }
+
+
+
+
+
+
+ {step > 1 && (
+
+ 이전
+
+ )}
+
+
+
+
+
+ 취소
+
+
+
+ {step < 3 ? (
+
+ 다음
+
+ ) : (
+
+ {formState.isSubmitting ? '제출 중…' : '제출'}
+
+ )}
+
+
+ >
+ );
+}
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..a559baaa
--- /dev/null
+++ b/src/features/study/group/ui/step/step1-group.tsx
@@ -0,0 +1,244 @@
+'use client';
+
+import {
+ Controller,
+ useController,
+ useFormContext,
+ useWatch,
+} from 'react-hook-form';
+import { SingleDropdown } from '@/shared/ui/dropdown';
+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 {
+ STUDY_TYPES,
+ 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 methodOptions = STUDY_METHODS.map((v) => ({
+ label: STUDY_METHOD_LABELS[v],
+ value: v,
+}));
+
+const memberOptions = Array.from({ length: 20 }, (_, i) => {
+ const value = (i + 1).toString();
+
+ return { label: `${value}명`, value };
+});
+
+export default function Step1OpenGroupStudy() {
+ const { control, formState } = useFormContext();
+ const { field: typeField } = useController({
+ name: 'type',
+ control,
+ });
+ const { field: regularMeetingField } = useController({
+ name: 'regularMeeting',
+ control,
+ });
+ const methodValue = useWatch({
+ name: 'method',
+ control,
+ });
+
+ return (
+ <>
+ 기본 정보 설정
+
+ name="type"
+ label="스터디 유형"
+ helper="어떤 방식으로 진행되는 스터디인지 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+ {STUDY_TYPES.map((type, index) => (
+
+
+
+ {STUDY_TYPE_LABELS[type]}
+
+
+ ))}
+
+
+
+ name="targetRoles"
+ label="모집 대상"
+ helper="함께하고 싶은 대상(직무·관심사 등)을 선택해주세요. (복수 선택 가능)"
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+
+
+ name="maxMembersCount"
+ label="모집 인원"
+ helper="모집할 최대 참여 인원을 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+
+
+ name="experienceLevels"
+ label="경력 여부"
+ helper="스터디 참여에 필요한 경력 조건을 선택해주세요.(복수 선택 가능)"
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+
+
+
+
+
+ 스터디가 진행되는 방식을 선택해주세요.
+
+
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+ {(formState.errors.method || formState.errors.location) && (
+
+ {formState.errors.method?.message ||
+ formState.errors.location?.message}
+
+ )}
+
+
+
+ name="regularMeeting"
+ label="정기 모임"
+ helper="정기적으로 모일 빈도를 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+ {REGULAR_MEETINGS.map((type, index) => (
+
+
+
+ {REGULAR_MEETING_LABELS[type]}
+
+
+ ))}
+
+
+
+
+
+
+ 스터디 진행 시작일과 종료일을 선택해주세요.
+
+
+
+
(
+
+ )}
+ />
+ ~
+ (
+
+ )}
+ />
+
+
+ {(formState.errors.startDate || formState.errors.endDate) && (
+
+ {formState.errors.startDate?.message ||
+ formState.errors.endDate?.message}
+
+ )}
+
+
+ {/* API에는 있는데 디자인에는 없음. 뭐지??? */}
+ {/*
+ name="price"
+ label="참가비"
+ helper="참가비가 있다면 입력해주세요. (0원 가능)"
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+ */}
+ >
+ );
+}
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..572f823c
--- /dev/null
+++ b/src/features/study/group/ui/step/step2-group.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useFormContext, useWatch } from 'react-hook-form';
+import FormField from '@/shared/ui/form/form-field';
+import { BaseInput, TextAreaInput } from '@/shared/ui/input';
+import { THUMBNAIL_EXTENSION } from '../../const/group-study-const';
+import { OpenGroupFormValues } from '../../model/open-group-form.schema';
+import GroupStudyThumbnailInput from '../group-study-thumbnail-input';
+
+export default function Step2OpenGroupStudy() {
+ const { setValue } = useFormContext();
+
+ const thumbnailFile = useWatch({
+ name: 'thumbnailFile',
+ });
+ const thumbnailExtension = useWatch({
+ name: 'thumbnailExtension',
+ });
+
+ const [image, setImage] = useState(undefined);
+
+ useEffect(() => {
+ if (thumbnailFile && thumbnailFile instanceof File) {
+ setImage(URL.createObjectURL(thumbnailFile));
+ } else if (thumbnailExtension === 'DEFAULT') {
+ setImage(undefined);
+ }
+ }, [thumbnailFile, thumbnailExtension]);
+
+ const handleImageChange = (file: File | null) => {
+ if (!file) {
+ setValue('thumbnailExtension', 'DEFAULT', { shouldValidate: true });
+ setValue('thumbnailFile', null);
+ setImage(undefined);
+
+ return;
+ }
+
+ const ext = file.name.split('.').pop()?.toUpperCase();
+ const validExt =
+ ext && THUMBNAIL_EXTENSION.includes(ext as any)
+ ? (ext as OpenGroupFormValues['thumbnailExtension'])
+ : 'DEFAULT';
+
+ setValue('thumbnailExtension', validExt, { shouldValidate: true });
+ setValue('thumbnailFile', file, { shouldValidate: true });
+ setImage(URL.createObjectURL(file));
+ };
+
+ return (
+ <>
+
+ 스터디 소개 작성
+
+
+
+ name="thumbnailExtension"
+ label="썸네일"
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+
+
+
+ name="title"
+ label="스터디 제목"
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+
+
+
+ name="summary"
+ label="스터디 한 줄 소개"
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+
+
+
+ name="description"
+ label="스터디 소개"
+ direction="vertical"
+ size="medium"
+ required
+ >
+
+
+ >
+ );
+}
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..12c889ca
--- /dev/null
+++ b/src/features/study/group/ui/step/step3-group.tsx
@@ -0,0 +1,84 @@
+'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 { BaseInput } from '@/shared/ui/input';
+import { OpenGroupFormValues } from '../../model/open-group-form.schema';
+
+export default function Step3OpenGroupStudy() {
+ 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="스터디원에게 보여줄 질문을 입력하세요"
+ direction="vertical"
+ helper="스터디 지원자가 신청 시 작성해야 할 질문을 설정하세요. (예: 지원 동기, 경험, 기대하는 점 등)"
+ size="medium"
+ required
+ >
+
+ {questions.map((q, index) => (
+
+
handleChange(index, e.target.value)}
+ />
+ {index > 0 && (
+ handleRemove(index)}
+ >
+ X
+
+ )}
+
+ ))}
+
+ 질문 추가하기
+
+
+
+
+
+
+ 스터디 리더님, 개설 규칙을 준수해주세요.
+
+
+ 모집 공고의 정보는 사실에 기반해 작성해야 합니다.
+ 진행 기간과 모임 빈도를 명확히 안내해야 합니다.
+ 스터디 개설 후 최소 1회 이상 정기 모임을 진행해야 합니다.
+ 스터디원과의 약속을 존중하고 성실히 운영해야 합니다.
+
+ 타인의 개인정보 및 자료를 무단으로 공유하거나 외부 유출해서는 안
+ 됩니다.
+
+ 플랫폼의 운영 정책 및 커뮤니티 가이드를 준수해야 합니다.
+
+
+ >
+ );
+}
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..c5feef00
--- /dev/null
+++ b/src/features/study/interview/api/get-interview.ts
@@ -0,0 +1,38 @@
+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,
+ 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;
+};
diff --git a/src/features/study/interview/api/interview-types.ts b/src/features/study/interview/api/interview-types.ts
new file mode 100644
index 00000000..7556e251
--- /dev/null
+++ b/src/features/study/interview/api/interview-types.ts
@@ -0,0 +1,35 @@
+export type StudyProgressStatus =
+ | 'PENDING'
+ | 'IN_PROGRESS'
+ | 'COMPLETE'
+ | 'ABSENT';
+
+// 오늘의 스터디 상세조회 관련 타입
+export interface DailyStudyDetail {
+ dailyStudyId: number;
+ interviewerId: number;
+ interviewerName: string;
+ interviewerImage: string;
+ intervieweeId: number;
+ intervieweeName: string;
+ intervieweeImage: string;
+ partnerTel: string;
+ studySpaceId: number;
+ progressStatus: StudyProgressStatus;
+ subject: string;
+ description: string;
+ link: string;
+ feedback: string;
+}
+
+// 스터디 면접 준비 타입
+export interface PrepareStudyRequest {
+ subject: string;
+ link: string;
+}
+
+// 스터디 면접 완료 타입
+export interface CompleteStudyRequest {
+ feedback: string;
+ progressStatus: StudyProgressStatus;
+}
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/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({
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/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..48583647 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 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';
+} from '@/features/study/interview/api/interview-types';
+import { STUDY_PROGRESS_OPTIONS } from '@/features/study/interview/const/interview-const';
import {
StudyDoneFormSchema,
type StudyDoneFormValues,
buildStudyDoneDefaults,
-} from '../model/interview.schema';
-import { useUpdateDailyStudyMutation } from '../model/use-study-query';
+} 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';
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..46019b5f 100644
--- a/src/features/study/ui/study-ready-modal.tsx
+++ b/src/features/study/interview/ui/study-ready-modal.tsx
@@ -5,18 +5,20 @@ import { XIcon } from 'lucide-react';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
-import Button from '@/shared/ui/button';
-import FormField from '@/shared/ui/form/form-field';
-import { BaseInput } from '@/shared/ui/input';
-import { Modal } from '@/shared/ui/modal';
-
-import type { DailyStudyDetail, PrepareStudyRequest } from '../api/types';
+import type {
+ DailyStudyDetail,
+ PrepareStudyRequest,
+} from '@/features/study/interview/api/interview-types';
import {
StudyReadyFormSchema,
type StudyReadyFormValues,
buildStudyReadyDefaults,
-} from '../model/interview.schema';
-import { useUpdateDailyStudyMutation } from '../model/use-study-query';
+} 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;
diff --git a/src/features/study/participation/api/get-participation-data.ts b/src/features/study/participation/api/get-participation-data.ts
index 82791ff4..ea71e138 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 => {
@@ -35,10 +38,18 @@ export const getReservationMembers = async (
return res.data.content;
};
-export type StudyStatus = 'RECRUITING' | 'STUDYING';
+// 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),
+ ),
+ );
-export const getStudyStatus = async (): Promise => {
- const res = await axiosInstance.get('/matching/system-status');
+ const res = await axiosInstance.post('/matching/apply', cleanPayload);
- return res.data.content.status as StudyStatus;
+ return res.data;
};
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/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. 면접 준비',
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
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/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 96%
rename from src/features/study/ui/start-study-modal.tsx
rename to src/features/study/participation/ui/start-study-modal.tsx
index 1330e84d..4ee469a9 100644
--- a/src/features/study/ui/start-study-modal.tsx
+++ b/src/features/study/participation/ui/start-study-modal.tsx
@@ -12,22 +12,21 @@ 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';
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';
+import { GroupItems } from '@/shared/ui/toggle';
interface StartStudyModalProps {
memberId: number;
@@ -230,7 +229,7 @@ function StartStudyForm({
direction="vertical"
required
>
-
+
diff --git a/src/features/study/schedule/api/get-study-schedule.tsx b/src/features/study/schedule/api/get-study-schedule.tsx
new file mode 100644
index 00000000..b2345f0d
--- /dev/null
+++ b/src/features/study/schedule/api/get-study-schedule.tsx
@@ -0,0 +1,46 @@
+// 매칭 결과 목록, 오늘의 스터디 상세 정보, 나의 스터디 캘린더
+import {
+ GetDailyStudiesParams,
+ GetDailyStudiesResponse,
+ GetMonthlyCalendarParams,
+ MonthlyCalendarResponse,
+ StudyStatus,
+ WeeklyParticipationResponse,
+} from '@/features/study/schedule/api/schedule-types';
+import { axiosInstance } from '@/shared/tanstack-query/axios';
+
+// 스터디 전체 조회
+export const getDailyStudies = async (
+ params?: GetDailyStudiesParams,
+): Promise => {
+ const res = await axiosInstance.get('/study/daily', { params });
+
+ return res.data.content;
+};
+
+// 월 별 스터디 캘린더 조회
+export const getMonthlyStudyCalendar = async (
+ params: GetMonthlyCalendarParams,
+): Promise => {
+ const res = await axiosInstance.get('/study/daily/month', { params });
+
+ return res.data.content;
+};
+
+// 스터디 참여 유무 확인
+export const getWeeklyParticipation = async (
+ studyDate: string,
+): Promise => {
+ const res = await axiosInstance.get('/study/week/participation', {
+ params: { studyDate },
+ });
+
+ return res.data.content;
+};
+
+// 스터디 시작/종료 유무 확인
+export const getStudyStatus = async (): Promise => {
+ const res = await axiosInstance.get('/matching/system-status');
+
+ return res.data.content.status as StudyStatus;
+};
diff --git a/src/features/study/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;
+}
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,
+ });
+};
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/schedule/ui/today-study-card.tsx b/src/features/study/schedule/ui/today-study-card.tsx
new file mode 100644
index 00000000..c2fad11a
--- /dev/null
+++ b/src/features/study/schedule/ui/today-study-card.tsx
@@ -0,0 +1,300 @@
+'use client';
+
+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';
+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 memberIdInCookie = getCookie('memberId');
+ const memberId = isNumeric(memberIdInCookie)
+ ? Number(memberIdInCookie)
+ : null;
+
+ const { data: todayStudyData } = useDailyStudyDetailQuery(studyDate);
+
+ if (!todayStudyData || memberId === null) return null;
+
+ // 내가 피면접자(답변하는 사람)인지
+ const isInterviewee = memberId === todayStudyData.intervieweeId;
+
+ const partner = {
+ id: isInterviewee
+ ? todayStudyData.interviewerId
+ : todayStudyData.intervieweeId,
+ name: isInterviewee
+ ? todayStudyData.interviewerName
+ : todayStudyData.intervieweeName,
+ image: isInterviewee
+ ? todayStudyData.interviewerImage
+ : todayStudyData.intervieweeImage,
+ tel: todayStudyData.partnerTel,
+ };
+
+ return (
+
+
+
오늘의 스터디
+
+
+
+
+
+
+
+ 스터디 조
+
+
+
+ {todayStudyData.studySpaceId}조
+
+
+
+ {/* todo: phoneNumber 수정 */}
+
+
+
+ {isInterviewee ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+const renderFeedback = (
+ feedback: DailyStudyDetail['feedback'],
+ noFeedbackMessage: string,
+) => {
+ if (feedback) {
+ return (
+
+ );
+ }
+
+ return (
+
+
피드백
+
+
+
+
+ {noFeedbackMessage}
+
+
+
+ );
+};
+
+// 사용자가 면접자(질문하는 사람)이면 보여줄 컴포넌트
+function InterviewerStudyDetail({
+ studyDate,
+ ...todayStudyData
+}: DailyStudyDetail & { studyDate: string }) {
+ return (
+
+
+
+
+
+ 스터디 상세
+
+ {getStatusBadge(todayStudyData.progressStatus)}
+
+
+
+
+
+ {todayStudyData.progressStatus === 'PENDING' ? (
+
+ ) : (
+
+
+
+ {renderFeedback(
+ todayStudyData.feedback,
+ '완료하기를 눌러 피드백을 작성해주세요.',
+ )}
+
+
+
+ )}
+
+ );
+}
+
+// 사용자가 피면접자(답변하는 사람)이면 보여줄 컴포넌트
+function IntervieweeStudyDetail({
+ studyDate,
+ ...todayStudyData
+}: DailyStudyDetail & { studyDate: string }) {
+ return (
+
+
+
+
+
+ 스터디 상세
+
+ {getStatusBadge(todayStudyData.progressStatus)}
+
+
+
+
+
+ {todayStudyData.progressStatus === 'PENDING' ? (
+
+ ) : (
+
+
+
+ {renderFeedback(
+ todayStudyData.feedback,
+ '아직 면접관이 피드백을 작성하지 않았어요.',
+ )}
+
+
+
+ )}
+
+ );
+}
+
+function PartnerInfo({
+ id,
+ name,
+ image,
+ phoneNumber,
+ isInterviewee,
+}: {
+ id: number;
+ name: string;
+ image?: string;
+ phoneNumber: string;
+ isInterviewee: boolean;
+}) {
+ return (
+
+
+
+
+
+ {name}
+ {isInterviewee ? (
+ 지원자
+ ) : (
+ 면접관
+ )}
+
+
+
+
+
+
+ 전화걸기
+
+ }
+ />
+
+ 프로필 보기
+
+ }
+ />
+
+
+ );
+}
+
+function BeforeStudy({ description }: { description: string }) {
+ return (
+
+
+
+ 스터디 시작 전입니다.
+ {description}
+
+
+ );
+}
+
+function StudySubject({ subject }: Pick) {
+ return (
+
+ );
+}
+
+function StudyLink({ link }: Pick) {
+ return (
+
+
스터디 링크
+
+
+
+
+
+ {link}
+
+
+
+
+ );
+}
diff --git a/src/features/study/ui/today-study-card.tsx b/src/features/study/ui/today-study-card.tsx
deleted file mode 100644
index a1816bb4..00000000
--- a/src/features/study/ui/today-study-card.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import UserProfileModal from '@/entities/user/ui/user-profile-modal';
-import { getStatusBadge } from '@/features/study/ui/status-badge-map';
-import { getCookie } from '@/shared/tanstack-query/cookie';
-// TODO: FSD 의 import 바운더리를 넘어서 import 해야하는데,
-// 해당 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);
-
- useEffect(() => {
- const id = getCookie('memberId');
- setMemberId(id ? Number(id) : 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;
-
- return (
-
-
-
오늘의 스터디
- {memberId !== null &&
- (isInterviewee ? (
-
- ) : (
-
- ))}
-
-
-
-
- {`${todayStudyData.studySpaceId} 조`}
-
-
-
-
-
- {matchedUsername}
-
-
- }
- />
-
- {todayStudyData.subject}
-
- {getStatusBadge(todayStudyData.progressStatus ?? 'PENDING')}
-
-
-
- );
-
- function InfoBox({
- label,
- children,
- }: {
- label: string;
- children: React.ReactNode;
- }) {
- return (
-
- );
- }
-}
diff --git a/src/shared/config/sincerity-temp-presets.tsx b/src/shared/config/sincerity-temp-presets.tsx
index a915d0c3..b1d40334 100644
--- a/src/shared/config/sincerity-temp-presets.tsx
+++ b/src/shared/config/sincerity-temp-presets.tsx
@@ -55,9 +55,15 @@ export function toLevelLabel(levelName?: string): SincerityLabel {
return `${clamped}단계` as SincerityLabel;
}
+/**
+ * @param levelName - 레벨 이름 (가능한 값: "1단계" | "2단계" | "3단계" | "4단계")
+ */
+
// 매핑 안되는 값이 들어왔을 경우 FALLBACK
export function getSincerityPresetByLevelName(
levelName?: string,
): SincerityPreset {
- return SINCERITY_TEMP_PRESETS[toLevelLabel(levelName)];
+ const levelLabel = levelName as SincerityLabel;
+
+ return SINCERITY_TEMP_PRESETS[levelLabel];
}
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/shared/lib/jwt.ts b/src/shared/lib/jwt.ts
new file mode 100644
index 00000000..7b3c3b4e
--- /dev/null
+++ b/src/shared/lib/jwt.ts
@@ -0,0 +1,16 @@
+export const decodeJwt = (token: string) => {
+ if (!token) return null;
+
+ 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);
+};
diff --git a/src/shared/lib/time.ts b/src/shared/lib/time.ts
index 9ef0d565..704f878d 100644
--- a/src/shared/lib/time.ts
+++ b/src/shared/lib/time.ts
@@ -20,6 +20,20 @@ export const getKoreaDate = (targetDate?: Date) => {
return koreaNow;
};
+export const formatYYYYMMDD = (dateString: string) => {
+ const onlyDate = new Date(dateString).toISOString().slice(0, 10);
+
+ 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');
diff --git a/src/shared/ui/badge/index.tsx b/src/shared/ui/badge/index.tsx
index 9dd0978e..40dcfefa 100644
--- a/src/shared/ui/badge/index.tsx
+++ b/src/shared/ui/badge/index.tsx
@@ -17,7 +17,7 @@ const badgeVariants = cva(
blue: 'bg-background-accent-blue-subtle text-text-information border-background-accent-blue-subtle',
orange:
'bg-background-accent-orange-subtle text-background-accent-orange-strong',
- gray: 'bg-background-accent-gray-subtle text-background-accent-gray-strong',
+ gray: 'bg-background-accent-gray-subtle text-background-accent-gray-strong border-0',
purple:
'bg-background-accent-purple-subtle text-background-accent-purple-strong',
},
diff --git a/src/shared/ui/form/form-field.tsx b/src/shared/ui/form/form-field.tsx
index a922c6f7..683923c1 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 Size = '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;
+ size?: Size;
id?: string;
showCounterRight?: boolean;
@@ -47,6 +64,7 @@ export default function FormField<
description,
required = false,
direction = 'horizontal',
+ size = 'small',
id,
children,
showCounterRight = false,
@@ -79,7 +97,7 @@ export default function FormField<