diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a1fbda7..e125788 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,9 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom'; import Layout from './layout'; import ClubDetail from './pages/ClubDetail'; +import ClubInformationUpdate from './pages/ClubInformationUpdate'; +import ClubRegistration from './pages/ClubRegistration'; +import ClubRequestComplete from './pages/ClubRequestComplete'; import Home from './pages/Home'; import RegisterClub from './pages/RegisterClub'; import UniversityClubList from './pages/UniversityClubList'; @@ -17,6 +20,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/apps/web/src/apis/clubDetail/entity.ts b/apps/web/src/apis/clubDetail/entity.ts index 9adc1ac..b9d3337 100644 --- a/apps/web/src/apis/clubDetail/entity.ts +++ b/apps/web/src/apis/clubDetail/entity.ts @@ -5,7 +5,7 @@ import type { UniversitySummary } from '../universityClub/entity'; export interface ClubDetailResponse { id: number; name: string; - imageUrl: string; + categoryEmoji: string; category: ClubCategory; categoryName: string; topic: string; diff --git a/apps/web/src/apis/clubInformationUpdate/entity.ts b/apps/web/src/apis/clubInformationUpdate/entity.ts new file mode 100644 index 0000000..5acacda --- /dev/null +++ b/apps/web/src/apis/clubInformationUpdate/entity.ts @@ -0,0 +1,17 @@ +import type { ClubCategory } from '@/apis/common/club'; + +export interface ClubInformationUpdateRequest { + universityName: string; + clubName: string; + clubCategory: ClubCategory; + clubTopic: string; + clubEmoji: string; + shortDescription: string; + fullIntroduction: string; + imageUrls: string[]; +} + +export interface SubmitClubInformationUpdateRequestParams { + clubId: number; + body: ClubInformationUpdateRequest; +} diff --git a/apps/web/src/apis/clubInformationUpdate/index.ts b/apps/web/src/apis/clubInformationUpdate/index.ts new file mode 100644 index 0000000..c2dc702 --- /dev/null +++ b/apps/web/src/apis/clubInformationUpdate/index.ts @@ -0,0 +1,9 @@ +import { apiClient } from '../client'; +import type { ClubInformationUpdateRequest } from './entity'; + +export const submitClubInformationUpdateRequest = async (clubId: number, body: ClubInformationUpdateRequest) => { + const response = await apiClient.post(`clubs/${clubId}/information-update-requests`, { + body, + }); + return response; +}; diff --git a/apps/web/src/apis/clubInformationUpdate/mutations.ts b/apps/web/src/apis/clubInformationUpdate/mutations.ts new file mode 100644 index 0000000..9744245 --- /dev/null +++ b/apps/web/src/apis/clubInformationUpdate/mutations.ts @@ -0,0 +1,13 @@ +import { mutationOptions } from '@tanstack/react-query'; + +import type { SubmitClubInformationUpdateRequestParams } from './entity'; +import { submitClubInformationUpdateRequest } from '.'; + +export const clubInformationUpdateMutations = { + submit: () => + mutationOptions({ + mutationKey: ['clubInformationUpdate', 'submit'], + mutationFn: ({ clubId, body }: SubmitClubInformationUpdateRequestParams) => + submitClubInformationUpdateRequest(clubId, body), + }), +}; diff --git a/apps/web/src/apis/clubRegistration/entity.ts b/apps/web/src/apis/clubRegistration/entity.ts new file mode 100644 index 0000000..4e42e2c --- /dev/null +++ b/apps/web/src/apis/clubRegistration/entity.ts @@ -0,0 +1,7 @@ +import type { ClubInformationUpdateRequest } from '@/apis/clubInformationUpdate/entity'; + +export type ClubRegistrationRequest = ClubInformationUpdateRequest; + +export interface SubmitClubRegistrationRequestParams { + body: ClubRegistrationRequest; +} diff --git a/apps/web/src/apis/clubRegistration/index.ts b/apps/web/src/apis/clubRegistration/index.ts new file mode 100644 index 0000000..a6fb808 --- /dev/null +++ b/apps/web/src/apis/clubRegistration/index.ts @@ -0,0 +1,9 @@ +import { apiClient } from '../client'; +import type { ClubRegistrationRequest } from './entity'; + +export const submitClubRegistrationRequest = async (body: ClubRegistrationRequest) => { + const response = await apiClient.post('clubs/registration-requests', { + body, + }); + return response; +}; diff --git a/apps/web/src/apis/clubRegistration/mutations.ts b/apps/web/src/apis/clubRegistration/mutations.ts new file mode 100644 index 0000000..e5f40fa --- /dev/null +++ b/apps/web/src/apis/clubRegistration/mutations.ts @@ -0,0 +1,12 @@ +import { mutationOptions } from '@tanstack/react-query'; + +import type { SubmitClubRegistrationRequestParams } from './entity'; +import { submitClubRegistrationRequest } from '.'; + +export const clubRegistrationMutations = { + submit: () => + mutationOptions({ + mutationKey: ['clubRegistration', 'submit'], + mutationFn: ({ body }: SubmitClubRegistrationRequestParams) => submitClubRegistrationRequest(body), + }), +}; diff --git a/apps/web/src/apis/recentClub/entity.ts b/apps/web/src/apis/recentClub/entity.ts index 047400a..b4b901c 100644 --- a/apps/web/src/apis/recentClub/entity.ts +++ b/apps/web/src/apis/recentClub/entity.ts @@ -7,7 +7,7 @@ export interface RecentClubRequestParams { export interface RecentClub { id: number; name: string; - imageUrl: string; + categoryEmoji: string; category: ClubCategory; categoryName: string; topic: string; diff --git a/apps/web/src/apis/universityClub/entity.ts b/apps/web/src/apis/universityClub/entity.ts index d375fb1..14ce354 100644 --- a/apps/web/src/apis/universityClub/entity.ts +++ b/apps/web/src/apis/universityClub/entity.ts @@ -27,7 +27,7 @@ export interface ClubCategorySummary { export interface UniversityClub { id: number; name: string; - imageUrl: string; + categoryEmoji: string; category: ClubCategory; categoryName: string; topic: string; diff --git a/apps/web/src/apis/upload/entity.ts b/apps/web/src/apis/upload/entity.ts new file mode 100644 index 0000000..8f131dd --- /dev/null +++ b/apps/web/src/apis/upload/entity.ts @@ -0,0 +1,6 @@ +export type UploadTarget = 'CLUB' | 'BANK' | 'COUNCIL' | 'USER'; + +export interface UploadImageResponse { + key: string; + fileUrl: string; +} diff --git a/apps/web/src/apis/upload/index.ts b/apps/web/src/apis/upload/index.ts new file mode 100644 index 0000000..1d92bfb --- /dev/null +++ b/apps/web/src/apis/upload/index.ts @@ -0,0 +1,13 @@ +import { apiClient } from '../client'; +import type { UploadImageResponse, UploadTarget } from './entity'; + +export const uploadImage = async (file: File, target: UploadTarget) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post('upload/image', { + body: formData, + params: { target }, + }); + return response; +}; diff --git a/apps/web/src/assets/image/complete-cat.jpg b/apps/web/src/assets/image/complete-cat.jpg new file mode 100644 index 0000000..581ce13 Binary files /dev/null and b/apps/web/src/assets/image/complete-cat.jpg differ diff --git a/apps/web/src/assets/svg/arrow_drop_down-icon.svg b/apps/web/src/assets/svg/arrow_drop_down-icon.svg new file mode 100644 index 0000000..bc5223d --- /dev/null +++ b/apps/web/src/assets/svg/arrow_drop_down-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/src/components/RecentClubCard/index.tsx b/apps/web/src/components/RecentClubCard/index.tsx index 3a1f8c4..3bb3903 100644 --- a/apps/web/src/components/RecentClubCard/index.tsx +++ b/apps/web/src/components/RecentClubCard/index.tsx @@ -14,7 +14,7 @@ function RecentClubCard({ club }: RecentClubCardProps) { className="border-text-100 hover:border-primary-500 focus-visible:outline-primary-500 flex w-full items-center gap-4 overflow-hidden rounded-[20px] border bg-white px-5.5 py-5 transition-colors hover:shadow-[0_0_30px_0_rgba(105,191,223,0.30)] focus-visible:outline-2 focus-visible:outline-offset-2" to={`/clubs/${club.id}`} > - + {club.name} diff --git a/apps/web/src/pages/ClubDetail/index.tsx b/apps/web/src/pages/ClubDetail/index.tsx index 1ea50a6..7796da8 100644 --- a/apps/web/src/pages/ClubDetail/index.tsx +++ b/apps/web/src/pages/ClubDetail/index.tsx @@ -80,7 +80,7 @@ export default function ClubDetail() {
- +

{clubDetail.name}

diff --git a/apps/web/src/pages/ClubInformationUpdate/index.tsx b/apps/web/src/pages/ClubInformationUpdate/index.tsx new file mode 100644 index 0000000..2372ce5 --- /dev/null +++ b/apps/web/src/pages/ClubInformationUpdate/index.tsx @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useRef, useState, type SubmitEventHandler } from 'react'; +import { getApiErrorMessage } from '@konect/utils/api-error-message'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { clubInformationUpdateMutations } from '@/apis/clubInformationUpdate/mutations'; +import type { ClubCategory } from '@/apis/common/club'; +import type { University } from '@/apis/home/entity'; +import { universityClubQueries } from '@/apis/universityClub/queries'; +import { uploadImage } from '@/apis/upload'; +import { + ClubRequestFormLayout, + FieldGroup, + MAX_CLUB_NAME_LENGTH, + MAX_FULL_INTRODUCTION_LENGTH, + MAX_MEDIA_COUNT, + MAX_SHORT_DESCRIPTION_LENGTH, + MediaUploader, + RequestHeader, + TextInputWithCount, + UniversityCombobox, + type LocalMediaItem, +} from '@/pages/ClubRequest/components'; +import { createLocalMediaItem, getUniversityLabel } from '@/pages/ClubRequest/utils'; + +const ACCEPTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif']); + +const CLUB_CATEGORY_EMOJI: Record = { + ACADEMIC: 'πŸ“š', + SPORTS: '⚽', + HOBBY: '🎨', + RELIGION: 'πŸ™', + PERFORMANCE: '🎭', + SOCIAL_SERVICE: '🀝', + EXHIBITION_CREATION: 'πŸ–ΌοΈ', + ETC: '🏫', + JUNIOR: '🌱', +}; + +function ClubInformationUpdate() { + const navigate = useNavigate(); + const [universityInput, setUniversityInput] = useState(''); + const [selectedUniversity, setSelectedUniversity] = useState(null); + const [clubName, setClubName] = useState(''); + const [clubQuery, setClubQuery] = useState(''); + const [shortDescription, setShortDescription] = useState(''); + const [fullIntroduction, setFullIntroduction] = useState(''); + const [mediaItems, setMediaItems] = useState([]); + const [mediaError, setMediaError] = useState(''); + const [formMessage, setFormMessage] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const mediaItemsRef = useRef([]); + + const selectedUniversityId = selectedUniversity?.id; + const { data: clubListData, isFetching: isFetchingClubs } = useQuery({ + ...universityClubQueries.list(selectedUniversityId ?? 0, { limit: 20, query: clubQuery }), + enabled: Boolean(selectedUniversityId && clubQuery), + }); + + const matchedClub = useMemo(() => { + const normalizedClubName = clubName.trim(); + if (!normalizedClubName) return undefined; + + return clubListData?.clubs.find((club) => club.name.trim() === normalizedClubName); + }, [clubListData?.clubs, clubName]); + + const { mutateAsync: submitUpdateRequest, isPending } = useMutation(clubInformationUpdateMutations.submit()); + const isSubmitting = isPending || isUploading; + const isFormValid = Boolean( + selectedUniversity && clubName.trim() && shortDescription.trim() && fullIntroduction.trim() + ); + + useEffect(() => { + mediaItemsRef.current = mediaItems; + }, [mediaItems]); + + useEffect(() => { + return () => { + mediaItemsRef.current.forEach((item) => URL.revokeObjectURL(item.previewUrl)); + }; + }, []); + + const handleUniversityInputChange = (value: string) => { + setUniversityInput(value); + setSelectedUniversity(null); + }; + + const handleUniversitySelect = (university: University, universityLabel: string) => { + setSelectedUniversity(university); + setUniversityInput(universityLabel); + setClubName(''); + setClubQuery(''); + setFormMessage(''); + }; + + const handleAppendMediaItems = (files: File[]) => { + setMediaError(''); + + const validFiles = files.filter((file) => ACCEPTED_IMAGE_TYPES.has(file.type)); + if (validFiles.length < files.length) { + setMediaError('JPG, PNG, GIF ν˜•μ‹μ˜ μ΄λ―Έμ§€λ§Œ 첨뢀할 수 μžˆμŠ΅λ‹ˆλ‹€.'); + } + + setMediaItems((prevItems) => { + const remainingCount = MAX_MEDIA_COUNT - prevItems.length; + if (remainingCount <= 0) { + setMediaError(`사진은 μ΅œλŒ€ ${MAX_MEDIA_COUNT}κ°œκΉŒμ§€ 첨뢀할 수 μžˆμŠ΅λ‹ˆλ‹€.`); + return prevItems; + } + + if (validFiles.length > remainingCount) { + setMediaError(`사진은 μ΅œλŒ€ ${MAX_MEDIA_COUNT}κ°œκΉŒμ§€ 첨뢀할 수 μžˆμŠ΅λ‹ˆλ‹€.`); + } + + return [...prevItems, ...validFiles.slice(0, remainingCount).map(createLocalMediaItem)]; + }); + }; + + const handleClearMediaItems = () => { + setMediaItems((prevItems) => { + prevItems.forEach((item) => URL.revokeObjectURL(item.previewUrl)); + return []; + }); + setMediaError(''); + }; + + const handleSubmit: SubmitEventHandler = async (event) => { + event.preventDefault(); + setFormMessage(''); + + if (!selectedUniversity) { + setFormMessage('λŒ€ν•™κ΅λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + if (!matchedClub) { + setFormMessage( + isFetchingClubs + ? '동아리 정보λ₯Ό 확인 μ€‘μž…λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.' + : 'μ„ νƒν•œ 학ꡐ에 λ“±λ‘λœ 동아리λͺ…을 μ •ν™•νžˆ μž…λ ₯ν•΄μ£Όμ„Έμš”.' + ); + return; + } + + try { + setIsUploading(true); + const uploadedImages = await Promise.all(mediaItems.map((item) => uploadImage(item.file, 'CLUB'))); + setIsUploading(false); + + await submitUpdateRequest({ + clubId: matchedClub.id, + body: { + universityName: getUniversityLabel(selectedUniversity), + clubName: clubName.trim(), + clubCategory: matchedClub.category, + clubTopic: matchedClub.topic, + clubEmoji: CLUB_CATEGORY_EMOJI[matchedClub.category], + shortDescription: shortDescription.trim(), + fullIntroduction: fullIntroduction.trim(), + imageUrls: uploadedImages.map(({ fileUrl }) => fileUrl), + }, + }); + + handleClearMediaItems(); + setClubName(''); + setClubQuery(''); + setShortDescription(''); + setFullIntroduction(''); + navigate('/clubs/register/complete'); + } catch (error) { + setIsUploading(false); + setFormMessage(getApiErrorMessage(error, '동아리 정보 μˆ˜μ • μš”μ²­μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.')); + } + }; + + return ( +
+
+ + + + + + + + + { + const value = event.target.value; + setClubName(value); + setClubQuery(value.trim()); + setFormMessage(''); + }} + placeholder="(ν•„μˆ˜) 동아리λͺ…을 μž…λ ₯ν•΄μ£Όμ„Έμš”." + maxLength={MAX_CLUB_NAME_LENGTH} + ariaLabel="동아리λͺ…" + /> + + + + setShortDescription(event.target.value)} + placeholder="(ν•„μˆ˜) λ™μ•„λ¦¬μ˜ ν•œ 쀄 μ†Œκ°œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”." + maxLength={MAX_SHORT_DESCRIPTION_LENGTH} + ariaLabel="ν•œ 쀄 μ†Œκ°œ" + /> + + + + + + setFullIntroduction(event.target.value)} + placeholder="(ν•„μˆ˜) 동아리λ₯Ό μ†Œκ°œν•  수 μžˆλŠ” λ‚΄μš©μ„ 자유둭게 μž‘μ„±ν•΄μ£Όμ„Έμš”." + maxLength={MAX_FULL_INTRODUCTION_LENGTH} + ariaLabel="동아리 μ†Œκ°œ" + /> + + +
+
+ ); +} + +export default ClubInformationUpdate; diff --git a/apps/web/src/pages/ClubRegistration/index.tsx b/apps/web/src/pages/ClubRegistration/index.tsx new file mode 100644 index 0000000..a353f5b --- /dev/null +++ b/apps/web/src/pages/ClubRegistration/index.tsx @@ -0,0 +1,272 @@ +import { useEffect, useRef, useState, type ChangeEvent, type SubmitEventHandler } from 'react'; +import { getApiErrorMessage } from '@konect/utils/api-error-message'; +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { clubRegistrationMutations } from '@/apis/clubRegistration/mutations'; +import { CLUB_CATEGORY, type ClubCategory } from '@/apis/common/club'; +import type { University } from '@/apis/home/entity'; +import { uploadImage } from '@/apis/upload'; +import { + ClubRequestFormLayout, + FieldGroup, + MAX_CLUB_NAME_LENGTH, + MAX_FULL_INTRODUCTION_LENGTH, + MAX_MEDIA_COUNT, + MAX_SHORT_DESCRIPTION_LENGTH, + MediaUploader, + PlainTextInput, + RequestHeader, + TextInputWithCount, + UniversityCombobox, + type LocalMediaItem, +} from '@/pages/ClubRequest/components'; +import { createLocalMediaItem, getUniversityLabel } from '@/pages/ClubRequest/utils'; + +const ACCEPTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif']); + +const CLUB_CATEGORY_OPTIONS: { label: string; value: ClubCategory }[] = [ + { label: 'ν•™μˆ ', value: CLUB_CATEGORY.ACADEMIC }, + { label: 'μš΄λ™', value: CLUB_CATEGORY.SPORTS }, + { label: 'μ·¨λ―Έ', value: CLUB_CATEGORY.HOBBY }, + { label: '쒅ꡐ', value: CLUB_CATEGORY.RELIGION }, + { label: '곡연', value: CLUB_CATEGORY.PERFORMANCE }, + { label: '봉사', value: CLUB_CATEGORY.SOCIAL_SERVICE }, + { label: 'μ „μ‹œ/μ°½μž‘', value: CLUB_CATEGORY.EXHIBITION_CREATION }, + { label: '기타', value: CLUB_CATEGORY.ETC }, + { label: 'μ£Όλ‹ˆμ–΄', value: CLUB_CATEGORY.JUNIOR }, +]; + +function ClubRegistration() { + const navigate = useNavigate(); + const [universityInput, setUniversityInput] = useState(''); + const [selectedUniversity, setSelectedUniversity] = useState(null); + const [clubName, setClubName] = useState(''); + const [clubEmoji, setClubEmoji] = useState('πŸ€'); + const [clubCategory, setClubCategory] = useState(''); + const [clubTopic, setClubTopic] = useState(''); + const [shortDescription, setShortDescription] = useState(''); + const [fullIntroduction, setFullIntroduction] = useState(''); + const [mediaItems, setMediaItems] = useState([]); + const [mediaError, setMediaError] = useState(''); + const [formMessage, setFormMessage] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const mediaItemsRef = useRef([]); + + const { mutateAsync: submitRegistrationRequest, isPending } = useMutation(clubRegistrationMutations.submit()); + const isSubmitting = isPending || isUploading; + const isFormValid = Boolean( + selectedUniversity && + clubName.trim() && + clubEmoji.trim() && + clubCategory && + clubTopic.trim() && + shortDescription.trim() && + fullIntroduction.trim() + ); + + useEffect(() => { + mediaItemsRef.current = mediaItems; + }, [mediaItems]); + + useEffect(() => { + return () => { + mediaItemsRef.current.forEach((item) => URL.revokeObjectURL(item.previewUrl)); + }; + }, []); + + const handleAppendMediaItems = (files: File[]) => { + setMediaError(''); + + const validFiles = files.filter((file) => ACCEPTED_IMAGE_TYPES.has(file.type)); + if (validFiles.length < files.length) { + setMediaError('JPG, PNG, GIF ν˜•μ‹μ˜ μ΄λ―Έμ§€λ§Œ 첨뢀할 수 μžˆμŠ΅λ‹ˆλ‹€.'); + } + + setMediaItems((prevItems) => { + const remainingCount = MAX_MEDIA_COUNT - prevItems.length; + if (remainingCount <= 0) { + setMediaError(`사진은 μ΅œλŒ€ ${MAX_MEDIA_COUNT}κ°œκΉŒμ§€ 첨뢀할 수 μžˆμŠ΅λ‹ˆλ‹€.`); + return prevItems; + } + + if (validFiles.length > remainingCount) { + setMediaError(`사진은 μ΅œλŒ€ ${MAX_MEDIA_COUNT}κ°œκΉŒμ§€ 첨뢀할 수 μžˆμŠ΅λ‹ˆλ‹€.`); + } + + return [...prevItems, ...validFiles.slice(0, remainingCount).map(createLocalMediaItem)]; + }); + }; + + const handleClearMediaItems = () => { + setMediaItems((prevItems) => { + prevItems.forEach((item) => URL.revokeObjectURL(item.previewUrl)); + return []; + }); + setMediaError(''); + }; + + const handleCategoryChange = (event: ChangeEvent) => { + setClubCategory(event.target.value as ClubCategory | ''); + }; + + const handleSubmit: SubmitEventHandler = async (event) => { + event.preventDefault(); + setFormMessage(''); + + if (!selectedUniversity || !clubCategory) { + setFormMessage('ν•„μˆ˜ 정보λ₯Ό λͺ¨λ‘ μž…λ ₯ν•΄μ£Όμ„Έμš”.'); + return; + } + + try { + setIsUploading(true); + const uploadedImages = await Promise.all(mediaItems.map((item) => uploadImage(item.file, 'CLUB'))); + setIsUploading(false); + + await submitRegistrationRequest({ + body: { + universityName: getUniversityLabel(selectedUniversity), + clubName: clubName.trim(), + clubCategory, + clubTopic: clubTopic.trim(), + clubEmoji: clubEmoji.trim(), + shortDescription: shortDescription.trim(), + fullIntroduction: fullIntroduction.trim(), + imageUrls: uploadedImages.map(({ fileUrl }) => fileUrl), + }, + }); + + handleClearMediaItems(); + setClubName(''); + setClubEmoji('πŸ€'); + setClubCategory(''); + setClubTopic(''); + setShortDescription(''); + setFullIntroduction(''); + navigate('/clubs/register/complete'); + } catch (error) { + setIsUploading(false); + setFormMessage(getApiErrorMessage(error, 'μ‹ κ·œ 동아리 등둝 μš”μ²­μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.')); + } + }; + + return ( +
+
+ + + + + { + setUniversityInput(value); + setSelectedUniversity(null); + }} + onSelect={(university, universityLabel) => { + setSelectedUniversity(university); + setUniversityInput(universityLabel); + setFormMessage(''); + }} + /> + + + + setClubName(event.target.value)} + placeholder="(ν•„μˆ˜) 동아리λͺ…을 μž…λ ₯ν•΄μ£Όμ„Έμš”." + maxLength={MAX_CLUB_NAME_LENGTH} + ariaLabel="동아리λͺ…" + /> + + +
+ +
+ setClubEmoji(event.target.value)} + maxLength={8} + aria-label="동아리 이λͺ¨μ§€" + /> +
+
+ + +
+ +
+
+ + + setClubTopic(event.target.value)} + placeholder="(ν•„μˆ˜) ex) 농ꡬ, λ°΄λ“œ, 사진, λŒ„μŠ€" + ariaLabel="동아리 주제" + maxLength={20} + /> + +
+ + + setShortDescription(event.target.value)} + placeholder="(ν•„μˆ˜) λ™μ•„λ¦¬μ˜ ν•œ 쀄 μ†Œκ°œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”." + maxLength={MAX_SHORT_DESCRIPTION_LENGTH} + ariaLabel="ν•œ 쀄 μ†Œκ°œ" + /> + + + + + + setFullIntroduction(event.target.value)} + placeholder="(ν•„μˆ˜) 동아리λ₯Ό μ†Œκ°œν•  수 μžˆλŠ” λ‚΄μš©μ„ 자유둭게 μž‘μ„±ν•΄μ£Όμ„Έμš”." + maxLength={MAX_FULL_INTRODUCTION_LENGTH} + ariaLabel="동아리 μ†Œκ°œ" + /> + +
+
+
+ ); +} + +export default ClubRegistration; diff --git a/apps/web/src/pages/ClubRequest/components.tsx b/apps/web/src/pages/ClubRequest/components.tsx new file mode 100644 index 0000000..529e580 --- /dev/null +++ b/apps/web/src/pages/ClubRequest/components.tsx @@ -0,0 +1,360 @@ +import { useId, useState, type ChangeEvent, type DragEvent, type ReactNode, type SubmitEventHandler } from 'react'; +import { cn } from '@konect/utils/cn'; +import { useDebouncedCallback } from '@konect/utils/use-debounced-callback'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +import type { University } from '@/apis/home/entity'; +import { homeQueries } from '@/apis/home/queries'; +import ArrowDropdownIcon from '@/assets/svg/arrow_drop_down-icon.svg'; + +import { getUniversityLabel } from './utils'; + +export const MAX_CLUB_NAME_LENGTH = 20; +export const MAX_SHORT_DESCRIPTION_LENGTH = 100; +export const MAX_FULL_INTRODUCTION_LENGTH = 100; +export const MAX_MEDIA_COUNT = 5; + +export interface LocalMediaItem { + file: File; + id: string; + previewUrl: string; +} + +interface UniversityComboboxProps { + inputValue: string; + onInputChange: (value: string) => void; + onSelect: (university: University, universityLabel: string) => void; + selectedUniversity: University | null; +} + +export function RequestHeader({ description, title }: { description: string; title: string }) { + return ( +
+
+
+

{description}

+
+ ); +} + +export function ClubRequestFormLayout({ + children, + formMessage, + isSubmitDisabled, + isSubmitting, + onSubmit, +}: { + children: ReactNode; + formMessage: string; + isSubmitDisabled: boolean; + isSubmitting: boolean; + onSubmit: SubmitEventHandler; +}) { + return ( +
+
+ {children} + +
+ +
+ + {formMessage && ( +

+ {formMessage} +

+ )} +
+
+ ); +} + +export function FieldGroup({ + children, + helperText, + label, + required = false, + trailingText, +}: { + children: ReactNode; + helperText?: string; + label: string; + required?: boolean; + trailingText?: string; +}) { + return ( +
+
+ + {trailingText && ( + {trailingText} + )} +
+ {children} + {helperText &&

{helperText}

} +
+ ); +} + +export function TextInputWithCount({ + ariaLabel, + maxLength, + onChange, + placeholder, + value, +}: { + ariaLabel: string; + maxLength: number; + onChange: (event: ChangeEvent) => void; + placeholder: string; + value: string; +}) { + return ( +
+ + + {value.length}/{maxLength} + +
+ ); +} + +export function PlainTextInput({ + ariaLabel, + className, + maxLength, + onChange, + placeholder, + value, +}: { + ariaLabel: string; + className?: string; + maxLength?: number; + onChange: (event: ChangeEvent) => void; + placeholder: string; + value: string; +}) { + return ( +
+ +
+ ); +} + +export function UniversityCombobox({ + inputValue, + onInputChange, + onSelect, + selectedUniversity, +}: UniversityComboboxProps) { + const [query, setQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const listboxId = useId(); + const updateQuery = useDebouncedCallback((value: string) => { + setQuery(value.trim()); + }); + + const { data: homeData } = useSuspenseQuery(homeQueries.detail(query ? { query } : {})); + const universityOptions = homeData.universities ?? []; + + const handleInputChange = (event: ChangeEvent) => { + const value = event.target.value; + onInputChange(value); + setIsOpen(true); + updateQuery(value); + }; + + const handleUniversitySelect = (university: University) => { + const universityLabel = getUniversityLabel(university); + onSelect(university, universityLabel); + setQuery(universityLabel); + setIsOpen(false); + }; + + return ( +
{ + const nextTarget = event.relatedTarget; + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setIsOpen(false); + } + }} + > +
+ setIsOpen(true)} + placeholder="(ν•„μˆ˜) λŒ€ν•™κ΅λ₯Ό μ„ νƒν•˜μ„Έμš”." + aria-autocomplete="list" + aria-controls={isOpen ? listboxId : undefined} + aria-expanded={isOpen} + aria-label="λŒ€ν•™κ΅λͺ…" + autoComplete="off" + role="combobox" + /> + + +
+ {isOpen && ( +
+ {universityOptions.length > 0 ? ( +
    + {universityOptions.map((university) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ 검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€. +

+ )} +
+ )} +
+ ); +} + +export function MediaUploader({ + mediaError, + mediaItems, + onAppendMediaFiles, + onClearMediaItems, +}: { + mediaError: string; + mediaItems: LocalMediaItem[]; + onAppendMediaFiles: (files: File[]) => void; + onClearMediaItems: () => void; +}) { + const handleMediaInputChange = (event: ChangeEvent) => { + onAppendMediaFiles(Array.from(event.currentTarget.files ?? [])); + event.currentTarget.value = ''; + }; + + const handleMediaDrop = (event: DragEvent) => { + event.preventDefault(); + onAppendMediaFiles(Array.from(event.dataTransfer.files)); + }; + + return ( + + + {mediaItems.length > 0 && ( + + )} +

+ 동아리λ₯Ό μ†Œκ°œν•  수 μžˆλŠ” μ‚¬μ§„μ΄λ‚˜ μ˜μƒμ„ μ²¨λΆ€ν•΄μ£Όμ„Έμš”. +
+ μ²¨λΆ€ν•œ 사진은 상세 νŽ˜μ΄μ§€μ—μ„œ 16:9 μ˜μ—­ μ•ˆμ— ν‘œμ‹œλ˜λ©°, 이미지 λΉ„μœ¨μ— 따라 쒌우 λ˜λŠ” μƒν•˜ 여백이 생길 수 + μžˆμŠ΅λ‹ˆλ‹€. +

+ {mediaError &&

{mediaError}

} +
+ ); +} + +export function RequestNotice() { + return ( +
+

μž…λ ₯ν•΄μ£Όμ‹  μ •λ³΄λŠ” λ‚΄λΆ€ 확인 ν›„ 동아리 상세 νŽ˜μ΄μ§€μ— λ°˜μ˜λ©λ‹ˆλ‹€.

+

ν—ˆμœ„μ •λ³΄ ν˜Ήμ€ λΆ€μ μ ˆν•œ λ‚΄μš©μ€ 반영이 μ œν•œλ  수 μžˆμŠ΅λ‹ˆλ‹€.

+
+ ); +} diff --git a/apps/web/src/pages/ClubRequest/utils.ts b/apps/web/src/pages/ClubRequest/utils.ts new file mode 100644 index 0000000..e83eaed --- /dev/null +++ b/apps/web/src/pages/ClubRequest/utils.ts @@ -0,0 +1,15 @@ +import type { University } from '@/apis/home/entity'; + +import type { LocalMediaItem } from './components'; + +export function createLocalMediaItem(file: File): LocalMediaItem { + return { + file, + id: `${file.name}-${file.lastModified}-${Date.now()}-${Math.random()}`, + previewUrl: URL.createObjectURL(file), + }; +} + +export function getUniversityLabel(university: University) { + return university.campusName ? `${university.name} ${university.campusName}` : university.name; +} diff --git a/apps/web/src/pages/ClubRequestComplete/index.tsx b/apps/web/src/pages/ClubRequestComplete/index.tsx new file mode 100644 index 0000000..6380e37 --- /dev/null +++ b/apps/web/src/pages/ClubRequestComplete/index.tsx @@ -0,0 +1,36 @@ +import { Link } from 'react-router-dom'; + +import CompleteImage from '@/assets/image/complete-cat.jpg'; + +function ClubRequestComplete() { + return ( +
+
+
+ +
+

동아리 μ†Œκ°œ 전솑이 μ™„λ£Œλ˜μ—ˆμ–΄μš”!

+

+ 보내주신 동아리 μ†Œκ°œ λ‚΄μš©μ€ 확인 ν›„ 동아리 상세 νŽ˜μ΄μ§€μ— λ°˜μ˜λ©λ‹ˆλ‹€. +
+ κ²€ν†  과정에 따라 λ°˜μ˜κΉŒμ§€ μ‹œκ°„μ΄ μ†Œμš”λ©λ‹ˆλ‹€. +

+
+
+ + λ©”μΈμœΌλ‘œ + +
+
+ ); +} + +export default ClubRequestComplete; diff --git a/apps/web/src/pages/RegisterClub/index.tsx b/apps/web/src/pages/RegisterClub/index.tsx index 48b5051..0cd53b6 100644 --- a/apps/web/src/pages/RegisterClub/index.tsx +++ b/apps/web/src/pages/RegisterClub/index.tsx @@ -1,6 +1,10 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + import EditClub from '@/assets/edit-club-detail.png'; import NewClub from '@/assets/new-club.png'; import Register from '@/assets/register-club.png'; + export default function RegisterClub() { const registerClubCards = [ { @@ -9,6 +13,7 @@ export default function RegisterClub() { title: '동아리 정보 μˆ˜μ •', description: '이미 KONECT에 λ“±λ‘λœ λ™μ•„λ¦¬μ˜ μ†Œκ°œ, 사진, 상세정보λ₯Ό μΆ”κ°€ν•˜κ±°λ‚˜ μˆ˜μ •ν•  수 μžˆμ–΄μš”', target: 'λŒ€μƒ : 동아리 회μž₯, μž„μ›μ§„', + to: '/clubs/information-update-requests', }, { image: NewClub, @@ -16,6 +21,7 @@ export default function RegisterClub() { title: 'μ‹ κ·œ 동아리 등둝', description: '아직 KONECT에 λ“±λ‘λ˜μ§€ μ•Šμ€ λ™μ•„λ¦¬μ˜ κΈ°λ³Έ 정보와 μ†Œκ°œ 정보λ₯Ό μ œμΆœν•  수 μžˆμ–΄μš”.', target: 'λŒ€μƒ : λ―Έλ“±λ‘λœ λ™μ•„λ¦¬μ˜ κ΄€κ³„μž', + to: '/clubs/registration-requests', }, { image: Register, @@ -38,19 +44,44 @@ export default function RegisterClub() {
{registerClubCards.map((card) => ( -
+ {card.imageAlt}

{card.title}

{card.description} {card.target}
-
+ ))}
); } + +function RegisterClubCard({ + card, + children, +}: { + card: { + description: string; + image: string; + imageAlt: string; + target: string; + title: string; + to?: string; + }; + children: ReactNode; +}) { + const className = + 'border-text-100 focus-visible:outline-primary-500 flex h-92.75 w-82.75 flex-col items-center gap-10 rounded-[20px] border bg-[#ffffff] px-7.5 py-10 transition-[border-color,box-shadow] hover:border-primary-500 hover:shadow-[0_0_30px_0_rgba(105,191,223,0.30)] focus-visible:outline-2 focus-visible:outline-offset-2'; + + if (card.to) { + return ( + + {children} + + ); + } + + return
{children}
; +} diff --git a/apps/web/src/pages/UniversityClubList/index.tsx b/apps/web/src/pages/UniversityClubList/index.tsx index 067dadf..900d6b9 100644 --- a/apps/web/src/pages/UniversityClubList/index.tsx +++ b/apps/web/src/pages/UniversityClubList/index.tsx @@ -175,7 +175,7 @@ function ClubCard({ club }: { club: UniversityClub }) { type="button" to={`/clubs/${club.id}`} > - + {club.name}