diff --git a/app/(service)/(my)/my-study/entry-list/page.tsx b/app/(service)/(my)/my-study/entry-list/page.tsx new file mode 100644 index 00000000..9fde4dc7 --- /dev/null +++ b/app/(service)/(my)/my-study/entry-list/page.tsx @@ -0,0 +1,20 @@ +import EntryList from '@/features/my-page/ui/entry-list'; +import Image from 'next/image'; + +export default function EntryPage() { + return ( +
+
+ arrow-left +
새로운 신청자 확인하기
+
+ + +
+ ); +} diff --git a/app/(service)/(nav)/study/[id]/page.tsx b/app/(service)/(nav)/study/[id]/page.tsx new file mode 100644 index 00000000..7addef93 --- /dev/null +++ b/app/(service)/(nav)/study/[id]/page.tsx @@ -0,0 +1,3 @@ +export default function StudyPage() { + return
Study Page
; +} diff --git a/app/(service)/(nav)/study/page.tsx b/app/(service)/(nav)/study/page.tsx new file mode 100644 index 00000000..ed9ab587 --- /dev/null +++ b/app/(service)/(nav)/study/page.tsx @@ -0,0 +1,28 @@ +import GroupStudyList from '@/features/study/group/ui/group-study-list'; +import IconPlus from '@/shared/icons/plus.svg'; +import Button from '@/shared/ui/button'; +import Sidebar from '@/widgets/home/sidebar'; + +export default function Study() { + return ( +
+
+
+ + 스터디 둘러보기 + + +
+ +
+ +
+ ); +} diff --git a/app/(service)/layout.tsx b/app/(service)/layout.tsx index 8be25193..c78a2b57 100644 --- a/app/(service)/layout.tsx +++ b/app/(service)/layout.tsx @@ -35,9 +35,9 @@ export default function ServiceLayout({
{/** 1400 + 48*2 패딩 양옆 48로 임의적용 */} -
+
-
{children}
+
{children}
diff --git a/package.json b/package.json index 110b87bc..2178740b 100644 --- a/package.json +++ b/package.json @@ -34,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/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/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/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 ( +
+
+ profile +
+
+ + {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/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..8c30ac42 --- /dev/null +++ b/src/features/study/group/api/group-study-types.ts @@ -0,0 +1,80 @@ +export interface GroupStudyListRequest { + page: number; + size: number; + status: GroupStudyStatus; +} + +export interface ImageSizeType { + imageTypeName: 'ORIGINAL' | 'SMALL' | 'MEDIUM' | 'LARGE'; + width: number | null; + height: number | null; +} + +export interface ResizedImage { + resizedImageId: number; + resizedImageUrl: string; + imageSizeType: ImageSizeType; +} + +export interface Thumbnail { + imageId: number; + resizedImages: ResizedImage[]; +} + +export interface SimpleDetailInfo { + thumbnail: Thumbnail; + title: string; + summary: string; +} + +export type GroupStudyStatus = 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; +export type GroupStudyType = 'PROJECT' | 'STUDY'; +export type HostType = 'ZEROONE' | 'GENERAL' | 'METOR'; +export type TargetRole = 'PLANNER' | 'BACKEND' | 'FRONTEND' | 'DESIGNER'; +export type ExperienceLevel = + | 'JUNIOR' + | 'MIDDLE' + | 'SENIOR' + | 'BEGINNER' + | 'JOB_SEEKER'; +export type Method = 'ONLINE' | 'OFFLINE'; +export type RegularMeeting = + | 'WEEKLY' + | 'BIWEEKLY' + | 'TRIPLE_WEEKLY_OR_MORE' + | 'NONE'; + +export interface BasicInfo { + groupStudyId: number; + type: GroupStudyType; + hostType: HostType; + targetRoles: TargetRole[]; + maxMembersCount: number; + experienceLevels: ExperienceLevel[]; + method: Method; + regularMeeting: RegularMeeting; + location: string; + startDate: string; + endDate: string; + price: number; + status: GroupStudyStatus; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface GroupStudyData { + basicInfo: BasicInfo; + simpleDetailInfo: SimpleDetailInfo; + currentParticipantCount: number; +} + +export interface GroupStudyListResponse { + content: GroupStudyData[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; +} diff --git a/src/features/study/group/ui/group-study-list.tsx b/src/features/study/group/ui/group-study-list.tsx new file mode 100644 index 00000000..28aa91f2 --- /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 React, { Fragment } from 'react'; +import Badge from '@/shared/ui/badge'; +import { getGroupStudyList } from '../api/get-group-study-list'; +import { BasicInfo } from '../api/group-study-types'; +import Image from 'next/image'; + +enum Method { + ONLINE = '온라인', + 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: BasicInfo, + 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 ( +
+ {/* */} + {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} + +
+ ))} +
+
+ thumbnail +
+ ); + })} +
+ ))} +
+ ); +} 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/widgets/home/header.tsx b/src/widgets/home/header.tsx index b120dcc6..8d441f23 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -1,3 +1,5 @@ +import clsx from 'clsx'; +import Image from 'next/image'; import Link from 'next/link'; import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; import HeaderUserDropdown from '@/features/auth/ui/header-user-dropdown'; @@ -5,8 +7,6 @@ import LoginModal from '@/features/auth/ui/login-modal'; import { getServerCookie } from '@/shared/lib/server-cookie'; import { isNumeric } from '@/shared/lib/validation'; import Button from '@/shared/ui/button'; -import Image from 'next/image'; -import clsx from 'clsx'; export default async function Header() { const memberIdStr = await getServerCookie('memberId'); @@ -28,16 +28,16 @@ export default async function Header() { return (
- Logo + Logo Logo-title {/* 1차 MVP에선 사용하지 않아 제외 */} - {/* */} + {/* 알림 기능을 구현하지 못해 주석 처리 */} {/*
diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index 2c95571a..904d8d0f 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -14,7 +14,7 @@ export default async function Sidebar() { const userProfile = await getUserProfileInServer(memberId); return ( -