diff --git a/app/global.css b/app/global.css index 1cb1b7fa..6a5591ba 100644 --- a/app/global.css +++ b/app/global.css @@ -714,6 +714,375 @@ 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/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/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/group-study-types.ts b/src/features/study/group/api/group-study-types.ts index 8c30ac42..4edc08b5 100644 --- a/src/features/study/group/api/group-study-types.ts +++ b/src/features/study/group/api/group-study-types.ts @@ -1,3 +1,65 @@ +import { + EXPERIENCE_LEVEL_OPTIONS, + REGULAR_MEETINGS, + STUDY_METHODS, + STUDY_TYPES, + TARGET_ROLE_OPTIONS, + THUMBNAIL_EXTENSION, +} from '../const/group-study-const'; + +export type StudyType = (typeof STUDY_TYPES)[number]; +export type TargetRole = (typeof TARGET_ROLE_OPTIONS)[number]; +export type ExperienceLevel = (typeof EXPERIENCE_LEVEL_OPTIONS)[number]; +export type StudyMethod = (typeof STUDY_METHODS)[number]; +export type RegularMeeting = (typeof REGULAR_MEETINGS)[number]; +export type ThumbnailExtension = (typeof THUMBNAIL_EXTENSION)[number]; +export 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; @@ -30,21 +92,9 @@ export interface SimpleDetailInfo { 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 { +export interface DetailBasicInfo { groupStudyId: number; type: GroupStudyType; hostType: HostType; @@ -64,7 +114,7 @@ export interface BasicInfo { } export interface GroupStudyData { - basicInfo: BasicInfo; + basicInfo: DetailBasicInfo; simpleDetailInfo: SimpleDetailInfo; currentParticipantCount: number; } 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/ui/group-study-list.tsx b/src/features/study/group/ui/group-study-list.tsx index 28aa91f2..ee9e4df7 100644 --- a/src/features/study/group/ui/group-study-list.tsx +++ b/src/features/study/group/ui/group-study-list.tsx @@ -1,10 +1,10 @@ '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 { BasicInfo } from '../api/group-study-types'; -import Image from 'next/image'; +import { DetailBasicInfo } from '../api/group-study-types'; enum Method { ONLINE = '온라인', @@ -52,7 +52,7 @@ export default function GroupStudyList() { }); const basicInfoItems = ( - basicInfo: BasicInfo, + basicInfo: DetailBasicInfo, currentParticipantCount: number, ) => [ { diff --git a/src/features/study/group/ui/group-study-thumbnail-input.tsx b/src/features/study/group/ui/group-study-thumbnail-input.tsx index 319193e2..f0a3c53e 100644 --- a/src/features/study/group/ui/group-study-thumbnail-input.tsx +++ b/src/features/study/group/ui/group-study-thumbnail-input.tsx @@ -15,7 +15,7 @@ export default function GroupStudyThumbnailInput({ onChangeImage, }: { image?: string; - onChangeImage: (image?: string) => void; + onChangeImage: (file: File | null) => void; }) { const fileInputRef = useRef(null); @@ -35,7 +35,6 @@ export default function GroupStudyThumbnailInput({ const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - console.log('drag leave'); setIsDragging(false); }; // 영역 안에서 드래그 중일 때 @@ -43,8 +42,6 @@ export default function GroupStudyThumbnailInput({ e.preventDefault(); e.stopPropagation(); - console.log('drag over'); - if (e.dataTransfer.files) { setIsDragging(true); } @@ -53,13 +50,12 @@ export default function GroupStudyThumbnailInput({ const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); - console.log('drop'); setIsDragging(false); const file = e.dataTransfer.files[0]; // 1장만 허용 if (file && file.type.startsWith('image/')) { - onChangeImage(URL.createObjectURL(file)); + onChangeImage(file); } }; @@ -68,7 +64,7 @@ export default function GroupStudyThumbnailInput({ const file = e.target.files?.[0]; if (file && file.type.startsWith('image/')) { - onChangeImage(URL.createObjectURL(file)); + onChangeImage(file); } }; @@ -118,8 +114,8 @@ export default function GroupStudyThumbnailInput({ preview + )} + + +
+ + + + + {step < 3 ? ( + + ) : ( + + )} +
+ + + ); +} 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) => ( +
+ + +
+ ))} +
+ + + 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) => ( +
+ + +
+ ))} +
+ +
+
+
+ +
필수
+
+
+ 스터디 진행 시작일과 종료일을 선택해주세요. +
+ +
+ ( + + )} + /> +
~
+ ( + + )} + /> +
+ + {(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/participation/ui/start-study-modal.tsx b/src/features/study/participation/ui/start-study-modal.tsx index af53ff0d..4ee469a9 100644 --- a/src/features/study/participation/ui/start-study-modal.tsx +++ b/src/features/study/participation/ui/start-study-modal.tsx @@ -26,7 +26,7 @@ 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 { GroupItems } from '@/shared/ui/toggle'; interface StartStudyModalProps { memberId: number; @@ -229,7 +229,7 @@ function StartStudyForm({ direction="vertical" required > - + 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<
@@ -90,7 +108,7 @@ export default function FormField<
{helper && ( -
+
{helper}
)} @@ -104,10 +122,13 @@ export default function FormField< {children} -
+
@@ -115,7 +136,7 @@ export default function FormField<
{showCounterRight && typeof counterMax === 'number' && ( -
+
{currentLen}/{counterMax}
)} diff --git a/src/shared/ui/input/base.tsx b/src/shared/ui/input/base.tsx index c0bbffae..1d6d667b 100644 --- a/src/shared/ui/input/base.tsx +++ b/src/shared/ui/input/base.tsx @@ -40,6 +40,9 @@ const inputVariants = cva( boxed: 'rounded-100 border px-150 border-border-default', bare: 'border-0 rounded-none px-0 focus-visible:outline-none shadow-none', }, + disabled: { + true: 'bg-background-disabled text-text-disabled border-border-disabled cursor-not-allowed', + }, }, defaultVariants: { color: 'default', @@ -64,7 +67,16 @@ export const BaseInput = React.forwardRef( return ( { onChange?.(e); diff --git a/src/shared/ui/toggle/group.tsx b/src/shared/ui/toggle/group.tsx index 45076634..7a2c06da 100644 --- a/src/shared/ui/toggle/group.tsx +++ b/src/shared/ui/toggle/group.tsx @@ -1,3 +1,4 @@ +import type { ComponentType, ReactNode } from 'react'; import ToggleButton from './button'; export interface ToggleOption { @@ -5,20 +6,63 @@ export interface ToggleOption { label: string; } -export interface ToggleGroupProps { +interface MultiProps { options: ToggleOption[]; + multiple?: true; value?: string[]; onChange?: (v: string[]) => void; + renderItem?: ComponentType; } -function ToggleGroup({ options, value, onChange }: ToggleGroupProps) { - const selected = value ?? []; +interface SingleProps { + options: ToggleOption[]; + multiple: false; + value?: string; + onChange?: (v: string | undefined) => void; + allowDeselect?: boolean; + emptyValue?: string | undefined; + renderItem?: ComponentType; +} + +export type ToggleGroupProps = MultiProps | SingleProps; + +export interface ItemRendererProps { + pressed: boolean; + onPress: () => void; + children: ReactNode; +} + +function ToggleGroup({ pressed, onPress, children }: ItemRendererProps) { + return ( + + {children} + + ); +} + +function GroupItems(props: ToggleGroupProps) { + const { options } = props; + const isMulti = props.multiple !== false; + const Item = props.renderItem ?? ToggleGroup; + + const selectedSet = new Set( + isMulti ? (props.value ?? []) : props.value ? [props.value] : [], + ); const toggle = (key: string) => { - const next = selected.includes(key) - ? selected.filter((x) => x !== key) - : [...selected, key]; - onChange?.(next); + if (isMulti) { + const curr = (props.value ?? []) as string[]; + const next = curr.includes(key) + ? curr.filter((x) => x !== key) + : [...curr, key]; + (props.onChange as ((v: string[]) => void) | undefined)?.(next); + } else { + const curr = props.value as string | undefined; + const allowDeselect = props.allowDeselect ?? true; + const cleared = 'emptyValue' in props ? props.emptyValue : undefined; + const next = curr === key ? (allowDeselect ? cleared : key) : key; + (props.onChange as ((v: string | undefined) => void) | undefined)?.(next); + } }; return ( @@ -28,17 +72,12 @@ function ToggleGroup({ options, value, onChange }: ToggleGroupProps) { aria-label="toggle-group" > {options.map(({ value: v, label }) => ( - toggle(v)} - > + toggle(v)}> {label} - + ))}
); } -export default ToggleGroup; +export default GroupItems; diff --git a/src/shared/ui/toggle/index.tsx b/src/shared/ui/toggle/index.tsx index 8d9c83e8..e3372b09 100644 --- a/src/shared/ui/toggle/index.tsx +++ b/src/shared/ui/toggle/index.tsx @@ -1,3 +1,3 @@ export * from './switch'; export { default as ToggleButton } from './button'; -export { default as ToggleGroup } from './group'; +export { default as GroupItems } from './group'; diff --git a/src/stories/ui/group-study-thumbnail-input.stories.tsx b/src/stories/ui/group-study-thumbnail-input.stories.tsx index 948fd35d..08422ac7 100644 --- a/src/stories/ui/group-study-thumbnail-input.stories.tsx +++ b/src/stories/ui/group-study-thumbnail-input.stories.tsx @@ -22,8 +22,9 @@ type Story = StoryObj; export const Default: Story = { render: () => { - const [image, setImage] = useState(undefined); + const [file, setFile] = useState(null); + const image = file ? URL.createObjectURL(file) : undefined; - return ; + return ; }, }; diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index 904d8d0f..a32dd466 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; import MyProfileCard from '@/entities/user/ui/my-profile-card'; +import OpenGroupStudyModal from '@/features/study/group/ui/open-group-modal'; import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; import { getServerCookie } from '@/shared/lib/server-cookie'; import Calendar from '@/widgets/home/calendar'; @@ -59,6 +60,21 @@ export default async function Sidebar() { } /> )} + {/* todo: 편하게 확인하시라고 추가했습니다. 이후 목록 추가되고 연결되면 제가 지워서 커밋 올리도록 하겠습니다. */} + {/* +

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

+ + } + /> */}