diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/Dockerfile b/Dockerfile index 512be29b..289592f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # 운영환경(main branch)에서 사용하는 Dockerfile # 1단계: build -FROM node:18-alpine AS builder +FROM node:20-alpine AS builder WORKDIR /app COPY . . @@ -12,7 +12,7 @@ COPY .env .env RUN yarn install && yarn build # 2단계: production -FROM node:18-alpine AS runner +FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production diff --git a/Dockerfile.dev b/Dockerfile.dev index d8e6b9b8..cc21afa1 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,7 @@ # 운영환경(main branch)에서 사용하는 Dockerfile # 1단계: build -FROM node:18-alpine AS builder +FROM node:20-alpine AS builder WORKDIR /app COPY . . @@ -12,7 +12,7 @@ COPY .env .env RUN yarn install && yarn build # 2단계: production -FROM node:18-alpine AS runner +FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production @@ -26,6 +26,9 @@ COPY --from=builder /app/next.config.ts ./next.config.ts # 런타임시점에도 .env 파일 복사 COPY --from=builder /app/.env .env +# 빌드 시 Node 힙 메모리를 4GB까지 허용 (4096 = 4GB) +ENV NODE_OPTIONS="--max-old-space-size=4096" + # devDependencies는 설치하지 않고 dependencies만 설치 RUN yarn install --production diff --git a/Dockerfile.prod b/Dockerfile.prod index d8e6b9b8..cc21afa1 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,7 +1,7 @@ # 운영환경(main branch)에서 사용하는 Dockerfile # 1단계: build -FROM node:18-alpine AS builder +FROM node:20-alpine AS builder WORKDIR /app COPY . . @@ -12,7 +12,7 @@ COPY .env .env RUN yarn install && yarn build # 2단계: production -FROM node:18-alpine AS runner +FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production @@ -26,6 +26,9 @@ COPY --from=builder /app/next.config.ts ./next.config.ts # 런타임시점에도 .env 파일 복사 COPY --from=builder /app/.env .env +# 빌드 시 Node 힙 메모리를 4GB까지 허용 (4096 = 4GB) +ENV NODE_OPTIONS="--max-old-space-size=4096" + # devDependencies는 설치하지 않고 dependencies만 설치 RUN yarn install --production diff --git a/app/(my)/my-study-review/page.tsx b/app/(my)/my-study-review/page.tsx new file mode 100644 index 00000000..730daa9d --- /dev/null +++ b/app/(my)/my-study-review/page.tsx @@ -0,0 +1,212 @@ +'use client'; + +import Image from 'next/image'; +import { useState } from 'react'; +import KeywordReview from '@/entities/user/ui/keyword-review'; +import { MyReviewItem } from '@/features/study/api/types'; +import { + useMyNegativeKeywordsQuery, + useMyReviewsInfinityQuery, + useUserPositiveKeywordsQuery, +} from '@/features/study/model/use-review-query'; +import { formatKoreaRelativeTime } from '@/shared/lib/time'; + +export default function MyStudyReview() { + const { data: positiveKeywordsData } = useUserPositiveKeywordsQuery({ + pageSize: 5, + }); + const { data: negativeKeywordsData } = useMyNegativeKeywordsQuery({ + pageSize: 5, + }); + const { + data: myReviewsData, + fetchNextPage, + hasNextPage, + } = useMyReviewsInfinityQuery(); + + const positiveKeywords = positiveKeywordsData?.keywords || []; + const negativeKeywords = negativeKeywordsData?.keywords || []; + + const positiveKeywordsCount = positiveKeywordsData?.totalCount || 0; + const negativeKeywordsCount = negativeKeywordsData?.totalCount || 0; + + const totalKeywordsCount = positiveKeywordsCount + negativeKeywordsCount; + + const myReviews = myReviewsData?.reviews || []; + + return ( + <> +
+
+
+
받은 평가
+
+ {totalKeywordsCount} +
+
+ + + 개선이 필요한 점은 나에게만 보여요 + +
+ +
+
+
+

좋았던 점

+ + {positiveKeywords.length > 5 && ( + + )} +
+ +
    + {positiveKeywords.length > 0 ? ( + positiveKeywords.map((keyword) => ( + + )) + ) : ( + + 아직 받은 평가가 없습니다. + + )} +
+
+ +
+
+

+ 개선이 필요한 점 +

+ + {negativeKeywords.length > 5 && ( + + )} +
+ +
    + {negativeKeywords.length > 0 ? ( + negativeKeywords.map((keyword) => ( + + )) + ) : ( + + 아직 받은 평가가 없습니다. + + )} +
+
+
+
+ +
+
+
후기
+
+ {myReviewsData?.totalCount || 0} +
+
+ + + 모든 후기는 나에게만 보여요 + + + +
+ + ); +} + +function Review({ data }: { data: MyReviewItem }) { + const [expanded, setExpanded] = useState(false); + + return ( +
  • +
    + {`${data.writer.memberName} + +
    + + {data.writer.memberName} + + · + + {formatKoreaRelativeTime(data.reviewedAt)} + +
    +
    + +
    +

    + {data.content} +

    + +
    + +
    +
    + 스터디 기간 + + {data.startDate.replace(/-/g, '.')} ~{' '} + {data.endDate.replace(/-/g, '.')} + +
    +
    + 스터디 주제 + + {data.studySubjects.join(', ')} + +
    +
    +
  • + ); +} diff --git a/app/global.css b/app/global.css index 74119091..1cb1b7fa 100644 --- a/app/global.css +++ b/app/global.css @@ -192,6 +192,10 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E --color-background-brand-default: var(--color-rose-500); --color-background-brand-strong: var(--color-rose-700); + --color-background-neutral-subtle: var(--color-gray-200); + --color-background-neutral-default: var(--color-gray-500); + --color-background-neutral-strong: var(--color-gray-900); + --color-background-accent-blue-subtle: var(--color-blue-50); --color-background-accent-blue-default: var(--color-blue-100); --color-background-accent-blue-strong: var(--color-blue-600); @@ -226,6 +230,10 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E --color-background-accent-yellow-default: var(--color-yellow-100); --color-background-accent-yellow-strong: var(--color-yellow-600); + --color-background-success-subtle: var(--color-green-300); + --color-background-success-default: var(--color-green-500); + --color-background-success-strong: var(--color-green-700); + --color-fill-brand-default-default: var(--color-rose-500); --color-fill-brand-default-hover: var(--color-rose-600); --color-fill-brand-default-pressed: var(--color-rose-700); diff --git a/next.config.ts b/next.config.ts index e1dcedae..108ea078 100644 --- a/next.config.ts +++ b/next.config.ts @@ -15,6 +15,11 @@ const nextConfig: NextConfig = { hostname: 'test-api.zeroone.it.kr', pathname: '/profile-image/**', }, + { + protocol: 'https', + hostname: 'api.zeroone.it.kr', + pathname: '/profile-image/**', + }, { protocol: 'https', hostname: 'lh3.googleusercontent.com', diff --git a/package.json b/package.json index 76b2ac30..4334a658 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "api:logs": "docker-compose -f ../study-platform-mvp/docker-compose.yml logs -f mvp-app" }, "dependencies": { + "@hookform/resolvers": "^5.2.1", "@next/third-parties": "^15.3.3", "@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-dialog": "^1.1.10", @@ -37,10 +38,12 @@ "react": "^19.0.0", "react-day-picker": "9.4.3", "react-dom": "^19.0.0", + "react-hook-form": "^7.62.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.0.6", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", + "zod": "^4.0.17", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/public/apply-study.svg b/public/apply-study.svg new file mode 100644 index 00000000..36d1f6ac --- /dev/null +++ b/public/apply-study.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/feedback.svg b/public/feedback.svg new file mode 100644 index 00000000..60d352ad --- /dev/null +++ b/public/feedback.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/arrow-down.svg b/public/icons/arrow-down.svg new file mode 100644 index 00000000..10f6a659 --- /dev/null +++ b/public/icons/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/fine-review.svg b/public/icons/fine-review.svg new file mode 100644 index 00000000..13307185 --- /dev/null +++ b/public/icons/fine-review.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/good-review.svg b/public/icons/good-review.svg new file mode 100644 index 00000000..e4b9aeeb --- /dev/null +++ b/public/icons/good-review.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/shame-review.svg b/public/icons/shame-review.svg new file mode 100644 index 00000000..50ee650e --- /dev/null +++ b/public/icons/shame-review.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/shape.svg b/public/icons/shape.svg new file mode 100644 index 00000000..af8ad1d5 --- /dev/null +++ b/public/icons/shape.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/start-study.png b/public/images/start-study.png deleted file mode 100644 index 52b6682a..00000000 Binary files a/public/images/start-study.png and /dev/null differ diff --git a/public/profile-default.svg b/src/entities/user/ui/icon/profile-default.svg similarity index 74% rename from public/profile-default.svg rename to src/entities/user/ui/icon/profile-default.svg index 04ddfe2c..5e77401f 100644 --- a/public/profile-default.svg +++ b/src/entities/user/ui/icon/profile-default.svg @@ -1,12 +1,12 @@ - + - + - + - + diff --git a/src/entities/user/ui/keyword-review.tsx b/src/entities/user/ui/keyword-review.tsx new file mode 100644 index 00000000..89ecda2a --- /dev/null +++ b/src/entities/user/ui/keyword-review.tsx @@ -0,0 +1,14 @@ +export default function KeywordReview({ + content, + count, +}: { + content: string; + count: number; +}) { + return ( +
  • + {content} + {count} +
  • + ); +} diff --git a/src/features/study/ui/my-profile-card.tsx b/src/entities/user/ui/my-profile-card.tsx similarity index 100% rename from src/features/study/ui/my-profile-card.tsx rename to src/entities/user/ui/my-profile-card.tsx diff --git a/src/widgets/my-page/profileinfo-card.tsx b/src/entities/user/ui/profile-info-card.tsx similarity index 82% rename from src/widgets/my-page/profileinfo-card.tsx rename to src/entities/user/ui/profile-info-card.tsx index fccee5b8..4e5e1db9 100644 --- a/src/widgets/my-page/profileinfo-card.tsx +++ b/src/entities/user/ui/profile-info-card.tsx @@ -6,7 +6,7 @@ interface Props { export default function ProfileInfoCard({ title, content }: Props) { return (
    -
    +
    {title}
    diff --git a/src/features/my-page/ui/user-profile-modal.tsx b/src/entities/user/ui/user-profile-modal.tsx similarity index 72% rename from src/features/my-page/ui/user-profile-modal.tsx rename to src/entities/user/ui/user-profile-modal.tsx index 0cd0f824..2d98c663 100644 --- a/src/features/my-page/ui/user-profile-modal.tsx +++ b/src/entities/user/ui/user-profile-modal.tsx @@ -2,14 +2,16 @@ import { XIcon } from 'lucide-react'; import { useUserProfileQuery } from '@/entities/user/model/use-user-profile-query'; +import KeywordReview from '@/entities/user/ui/keyword-review'; +import ProfileInfoCard from '@/entities/user/ui/profile-info-card'; import CakeIcon from '@/features/my-page/ui/icon/cake.svg'; import GithubIcon from '@/features/my-page/ui/icon/github-logo.svg'; import GlobeIcon from '@/features/my-page/ui/icon/globe-simple.svg'; import PhoneIcon from '@/features/my-page/ui/icon/phone.svg'; +import { useUserPositiveKeywordsQuery } from '@/features/study/model/use-review-query'; import UserAvatar from '@/shared/ui/avatar'; import Badge from '@/shared/ui/badge'; import { Modal } from '@/shared/ui/modal'; -import ProfileInfoCard from '@/widgets/my-page/profileinfo-card'; interface UserProfileModalProps { memberId: number; @@ -21,8 +23,13 @@ export default function UserProfileModal({ trigger, }: UserProfileModalProps) { const { data: profile, isLoading, isError } = useUserProfileQuery(memberId); + const { data: positiveKeywordsData } = useUserPositiveKeywordsQuery({ + memberId, + }); - if (isLoading || isError || !profile) return null; + if (isLoading || isError || !profile || !positiveKeywordsData) return null; + + const positiveKeywords = positiveKeywordsData?.keywords || []; return ( @@ -71,25 +78,22 @@ export default function UserProfileModal({
    - {profile.memberProfile.birthDate ?? - '생일을 입력해주세요!'} + {profile.memberProfile.birthDate ?? ''}
    - {profile.memberProfile.tel ?? '번호를 입력해주세요!'} + {profile.memberProfile.tel ?? ''}
    - {profile.memberProfile.githubLink?.url ?? - '깃허브 링크를 입력해주세요!'} + {profile.memberProfile.githubLink?.url ?? ''}
    - {profile.memberProfile.blogOrSnsLink?.url ?? - '블로그 링크를 입력해주세요!'} + {profile.memberProfile.blogOrSnsLink?.url ?? ''}
    @@ -122,6 +126,35 @@ export default function UserProfileModal({ content={profile.memberInfo.studyPlan} />
    + +
    + +
    + + 받은 평가 + + +
    + {/* todo: 기획 fix되면 수정 */} + {/* n명의 유저들이 이런 점이 좋다고 했어요. */} + +
      + {positiveKeywords.length > 0 ? ( + positiveKeywords.map((keyword) => ( + + )) + ) : ( + + 아직 받은 평가가 없습니다. + + )} +
    +
    +
    diff --git a/src/features/auth/ui/login-modal.tsx b/src/features/auth/ui/login-modal.tsx index 0a53946d..8abdf035 100644 --- a/src/features/auth/ui/login-modal.tsx +++ b/src/features/auth/ui/login-modal.tsx @@ -22,21 +22,14 @@ export default function LoginModal({ } const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1'); // 로컬환경 테스트용 - localStorage.setItem('isLocal', JSON.stringify(isLocal)); const API_BASE_URL = isLocal ? 'https://test-api.zeroone.it.kr' : process.env.NEXT_PUBLIC_API_BASE_URL; - const KAKAO_CLIENT_ID = isLocal - ? '4c946e1082820ff075aee8d65f3d4a30' - : process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID; + const KAKAO_CLIENT_ID = process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID; + const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; - const GOOGLE_CLIENT_ID = isLocal - ? '616205933420-b45d510q23togkaqo069j8igmsjhp9v0.apps.googleusercontent.com' - : process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; - - const NAVER_LOGIN_URL = ''; const KAKAO_LOGIN_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${API_BASE_URL}/api/v1/auth/kakao/redirect-uri&response_type=code&state=${state}`; const GOOGLE_LOGIN_URL = `https://accounts.google.com/o/oauth2/v2/auth?scope=openid%20profile&access_type=offline&prompt=consent&include_granted_scopes=true&response_type=code&redirect_uri=${API_BASE_URL}/api/v1/auth/google/redirect-uri&client_id=${GOOGLE_CLIENT_ID}&state=${state}`; diff --git a/src/features/auth/ui/sign-up-image-selector.tsx b/src/features/auth/ui/sign-up-image-selector.tsx index 8bd21034..8055000a 100644 --- a/src/features/auth/ui/sign-up-image-selector.tsx +++ b/src/features/auth/ui/sign-up-image-selector.tsx @@ -1,6 +1,6 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { PencilIcon } from 'lucide-react'; -import Image from 'next/image'; +import UserAvatar from '@/shared/ui/avatar'; export default function SignupImageSelector({ image, @@ -8,19 +8,17 @@ export default function SignupImageSelector({ fileInputRef, handleImageChange, }: { - image: string; - setImage: (image: string) => void; + image?: string; + setImage: (image?: string) => void; fileInputRef: React.RefObject; handleImageChange: (event: React.ChangeEvent) => void; }) { - const setDefaultImage = () => setImage('/profile-default.svg'); + const setDefaultImage = () => setImage(undefined); const openFileFolder = () => fileInputRef.current?.click(); return (
    -
    - 프로필 -
    +
    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 f1ed5a2b..e3e856b5 100644 --- a/src/features/my-page/ui/profile-info-edit-modal.tsx +++ b/src/features/my-page/ui/profile-info-edit-modal.tsx @@ -1,14 +1,26 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import { sendGTMEvent } from '@next/third-parties/google'; import { XIcon } from 'lucide-react'; -import { useState } from 'react'; -import { MemberInfo } from '@/entities/user/api/types'; +import { useMemo, useState } from 'react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; + +import type { MemberInfo } from '@/entities/user/api/types'; import { hashValue } from '@/shared/lib/hash'; import Button from '@/shared/ui/button'; -import { FormField } from '@/shared/ui/form/form-field'; +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 { UpdateUserProfileInfoRequest } from '../api/types'; +import { ToggleGroup } from '@/shared/ui/toggle'; + +import { + ProfileInfoFormSchema, + type ProfileInfoFormValues, + buildProfileInfoDefaultValues, + toUpdateUserProfileInfoRequest, +} from '../model/profile-info-form.schema'; import { useAvailableStudyTimesQuery, useStudySubjectsQuery, @@ -22,7 +34,7 @@ interface Props { } export default function ProfileInfoEditModal({ memberId, memberInfo }: Props) { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); return ( @@ -31,6 +43,7 @@ export default function ProfileInfoEditModal({ memberId, memberInfo }: Props) { 편집
    + @@ -42,6 +55,7 @@ export default function ProfileInfoEditModal({ memberId, memberInfo }: Props) {
    + void; }) { - const [infoForm, setInfoForm] = useState({ - selfIntroduction: memberInfo.selfIntroduction ?? '', - studyPlan: memberInfo.studyPlan ?? '', - preferredStudySubjectId: memberInfo.preferredStudySubject?.studySubjectId, - availableStudyTimeIds: (memberInfo.availableStudyTimes ?? []).map( - (time) => time?.id ?? 0, - ), - techStackIds: (memberInfo.techStacks ?? []).map( - (tech) => tech?.techStackId ?? 0, - ), - }); - - const { data: availableStudyTimes } = useAvailableStudyTimesQuery(); - const { data: studySubjects } = useStudySubjectsQuery(); - const { data: techStacks } = useTechStacksQuery(); + const { data: availableStudyTimes = [] } = useAvailableStudyTimesQuery(); + const { data: studySubjects = [] } = useStudySubjectsQuery(); + const { data: techStacks = [] } = useTechStacksQuery(); const { mutate: updateProfileInfo } = useUpdateUserProfileInfoMutation(memberId); - const handleSubmit = () => { - const formData: UpdateUserProfileInfoRequest = { - selfIntroduction: infoForm.selfIntroduction, - studyPlan: infoForm.studyPlan, - preferredStudySubjectId: infoForm.preferredStudySubjectId, - availableStudyTimeIds: infoForm.availableStudyTimeIds - .filter((id) => id) - .map((id) => Number(id)), - techStackIds: infoForm.techStackIds - .filter((id) => id) - .map((id) => Number(id)), - }; + const methods = useForm({ + resolver: zodResolver(ProfileInfoFormSchema), + mode: 'onChange', + defaultValues: buildProfileInfoDefaultValues(memberInfo), + }); + + const { + handleSubmit, + control, + formState: { isValid, isSubmitting }, + } = methods; + + const preferredStudySubjectId = useWatch({ + control, + name: 'preferredStudySubjectId', + }); + const subjectOk = Boolean(preferredStudySubjectId); + const isDisabled = !isValid || isSubmitting || !subjectOk; + + const onValidSubmit = (values: ProfileInfoFormValues) => { + const formData = toUpdateUserProfileInfoRequest(values); updateProfileInfo(formData, { onSuccess: () => { - const selectedSkillNames = techStacks.filter((techStack) => - infoForm.techStackIds.includes(techStack.techStackId), - ); + const selectedNames = + techStacks + .filter((t) => values.techStackIds?.includes(String(t.techStackId))) + .map((t) => t.techStackName) ?? []; sendGTMEvent({ event: 'custom_member_card', dl_timestamp: new Date().toISOString(), dl_member_id: hashValue(String(memberId)), - dl_tags: selectedSkillNames, + dl_tags: selectedNames, }); onClose(); @@ -111,118 +123,122 @@ function ProfileInfoEditForm({ }); }; + const subjectOptions = useMemo( + () => + studySubjects.map(({ studySubjectId, name }) => ({ + value: String(studySubjectId), + label: name, + })), + [studySubjects], + ); + + const timeOptions = useMemo( + () => + availableStudyTimes.map(({ availableTimeId, display }) => ({ + value: String(availableTimeId), + label: display, + })), + [availableStudyTimes], + ); + + const techOptions = useMemo( + () => + techStacks.map(({ techStackId, techStackName }) => ({ + value: String(techStackId), + label: techStackName, + })), + [techStacks], + ); + return ( <> -
    - - setInfoForm((prev) => ({ - ...prev, - selfIntroduction: value, - })) - } - direction="vertical" - maxLength={500} - /> - - setInfoForm((prev) => ({ - ...prev, - studyPlan: value, - })) - } - direction="vertical" - maxLength={500} - required - /> - - setInfoForm((prev) => ({ - ...prev, - preferredStudySubjectId: value, - })) - } - direction="vertical" - required - options={ - studySubjects?.map(({ studySubjectId, name }) => ({ - value: studySubjectId, - label: name, - })) ?? [] - } - /> - ({ - value: availableTimeId.toString(), - label: display, - })) ?? [] - } - onChange={(availableStudyTimeIds) => - setInfoForm((prev) => ({ - ...prev, - availableStudyTimeIds: availableStudyTimeIds.map(Number), - })) - } - required - /> - - setInfoForm((prev) => ({ - ...prev, - techStackIds: value, - })) - } - direction="vertical" - required - options={(techStacks ?? []).map( - ({ techStackId, techStackName }) => ({ - value: techStackId, - label: techStackName, - }), - )} - /> -
    + +
    + + name="selfIntroduction" + label="자기소개" + description="간단한 자기소개를 입력해 주세요." + direction="vertical" + showCounterRight + counterMax={500} + > + + + + + name="studyPlan" + label="공부 주제 및 계획" + description="스터디에서 다루고 싶은 주제와 학습 목표를 알려주세요." + direction="vertical" + showCounterRight + counterMax={500} + required + > + + + + + name="preferredStudySubjectId" + label="선호하는 스터디 주제" + description="관심있는 스터디 유형을 선택해주세요." + direction="vertical" + required + > + + + + + name="availableStudyTimeIds" + label="가능 시간대" + helper="스터디 참여가 가능한 시간대를 모두 선택해 주세요." + direction="vertical" + required + > + + + + + name="techStackIds" + label="사용 가능한 기술 스택" + helper="현재 본인이 사용할 수 있는 기술 스택을 모두 선택해 주세요." + direction="vertical" + required + > + + + +
    +
    -
    diff --git a/src/features/my-page/ui/profile-info.tsx b/src/features/my-page/ui/profile-info.tsx index 93e87afe..9b0047cc 100644 --- a/src/features/my-page/ui/profile-info.tsx +++ b/src/features/my-page/ui/profile-info.tsx @@ -1,8 +1,8 @@ 'use client'; import { MemberInfo } from '@/entities/user/api/types'; +import ProfileInfoCard from '@/entities/user/ui/profile-info-card'; import ProfileInfoEditModal from '@/features/my-page/ui/profile-info-edit-modal'; -import ProfileInfoCard from '@/widgets/my-page/profileinfo-card'; interface ProfileInfoProps { memberId: number; diff --git a/src/features/study/api/get-review.ts b/src/features/study/api/get-review.ts new file mode 100644 index 00000000..e188d28d --- /dev/null +++ b/src/features/study/api/get-review.ts @@ -0,0 +1,81 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import type { + AddStudyReviewRequest, + UserPositiveKeywordsResponse, + UserPositiveKeywordsRequest, + StudyEvaluationResponse, + MyNegativeKeywordsRequest, + MyNegativeKeywordsResponse, + MyReviewsResponse, + MyReviewsRequest, +} from './types'; + +export const getPartnerStudyReview = + async (): Promise => { + const res = await axiosInstance.get( + '/study/reviews/this-week/target-study', + ); + + return res.data.content; + }; + +export const addStudyReview = async (data: AddStudyReviewRequest) => { + const res = await axiosInstance.post('/study/reviews', data); + + return res.data.content; +}; + +export const getUserPositiveKeywords = async ({ + memberId, + pageSize, +}: UserPositiveKeywordsRequest): Promise => { + const params: Record = {}; + + if (memberId) { + params['member-id'] = memberId; + } + if (pageSize) { + params['page-size'] = pageSize; + } + + const res = await axiosInstance.get( + '/study/reviews/members/keywords/positive', + { params }, + ); + + return res.data.content; +}; + +export const getMyNegativeKeywords = async ({ + pageSize, +}: MyNegativeKeywordsRequest): Promise => { + const params: Record = {}; + + if (pageSize) { + params['page-size'] = pageSize; + } + + const res = await axiosInstance.get( + '/study/reviews/members/keywords/negative', + { params }, + ); + + return res.data.content; +}; + +export const getMyReviews = async ({ + cursor, +}: MyReviewsRequest): Promise => { + const params: Record = { + 'page-size': 10, + }; + + // cursor 전송하지 않는 경우 첫 데이터부터 조회 + if (cursor) { + params.cursor = cursor; + } + + const res = await axiosInstance.get('/study/reviews/members', { params }); + + return res.data.content; +}; diff --git a/src/features/study/api/types.ts b/src/features/study/api/types.ts index f790661b..77cfa766 100644 --- a/src/features/study/api/types.ts +++ b/src/features/study/api/types.ts @@ -94,3 +94,92 @@ export interface CompleteStudyRequest { feedback: string; progressStatus: StudyProgressStatus; } + +export interface EvalKeyword { + id: number; + keyword: string; + satisfactionId: number; + satisfactionLabel: string; +} + +interface Partner { + memberId: number; + memberName: string; + profileImageUrl: string; +} + +export interface StudyEvaluationResponse { + studySpaceId: number; + targetMembers: Partner[]; + studySubject: string; + startDate: string; // "yyyy-MM-dd" 형식 + endDate: string; // "yyyy-MM-dd" 형식 + satisfiedEvalKeywords: EvalKeyword[]; + notBadEvalKeywords: EvalKeyword[]; + unsatisfiedEvalKeywords: EvalKeyword[]; +} + +export interface AddStudyReviewRequest { + studySpaceId: number; + targetMemberId: number; + satisfactionId: 10 | 20 | 30; + keywordIds: number[]; + content: string; +} + +interface Keyword { + id: number; + content: string; + count: number; +} + +export interface UserPositiveKeywordsRequest { + memberId?: number; + pageSize?: number; +} + +export interface UserPositiveKeywordsResponse { + totalCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null + reviewerCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null + keywords: Keyword[]; +} + +export interface MyNegativeKeywordsRequest { + pageSize?: number; +} + +export interface MyNegativeKeywordsResponse { + totalCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null + reviewerCount: number | null; // params에 pageSize 값을 보내지 않는 경우 null + keywords: Keyword[]; +} + +export interface MyReviewWriter { + memberId: number; + memberName: string; + profileImageUrl: string; +} + +export interface MyReviewItem { + id: number; + writer: MyReviewWriter; + reviewedAt: string; // ISO 날짜 문자열 + content: string; + studySpaceId: number; + startDate: string; // YYYY-MM-DD + endDate: string; // YYYY-MM-DD + studySubjects: string[]; +} + +export interface MyReviewsRequest { + cursor: number | null; +} + +export interface MyReviewsResponse { + totalCount: number; + reviews: { + items: MyReviewItem[]; + nextCursor: number; + hasNext: boolean; + }; +} diff --git a/src/features/study/model/interview.schema.ts b/src/features/study/model/interview.schema.ts new file mode 100644 index 00000000..4f8bef55 --- /dev/null +++ b/src/features/study/model/interview.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +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({ + subject: z.string().trim().min(1, '면접 주제를 입력해 주세요.'), + link: UrlSchema, +}); + +export type StudyReadyFormValues = z.infer; + +export function buildStudyReadyDefaults( + d: DailyStudyDetail, +): StudyReadyFormValues { + return { + subject: d.subject ?? '', + link: d.link ?? '', + }; +} + +// 스터디 완료 스키마 +const STUDY_PROGRESS_VALUES = STUDY_PROGRESS_OPTIONS.map((o) => o.value) as [ + string, + ...string[], +]; + +export const StudyDoneFormSchema = z.object({ + progressStatus: z.enum(STUDY_PROGRESS_VALUES, { + message: '진행 현황을 선택해 주세요.', + }), + feedback: z + .string() + .trim() + .min(1, '피드백을 입력해 주세요.') + .max(100, '최대 100자까지 입력 가능합니다.'), +}); + +export type StudyDoneFormValues = z.infer; + +export function buildStudyDoneDefaults( + d: DailyStudyDetail, +): StudyDoneFormValues { + return { + progressStatus: (d.progressStatus ?? 'PENDING') as StudyProgressStatus, + feedback: d.feedback ?? '', + }; +} diff --git a/src/features/study/model/use-review-query.ts b/src/features/study/model/use-review-query.ts new file mode 100644 index 00000000..40e17990 --- /dev/null +++ b/src/features/study/model/use-review-query.ts @@ -0,0 +1,86 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; +import { + addStudyReview, + getUserPositiveKeywords, + getPartnerStudyReview, + getMyNegativeKeywords, + getMyReviews, +} from '../api/get-review'; +import { + MyNegativeKeywordsRequest, + UserPositiveKeywordsRequest, +} from '../api/types'; + +export const usePartnerStudyReviewQuery = () => { + return useSuspenseQuery({ + queryKey: ['partnerStudyReview'], + queryFn: getPartnerStudyReview, + }); +}; + +export const useAddStudyReviewMutation = () => { + return useMutation({ + mutationFn: addStudyReview, + onSuccess: () => { + // todo: 모달로 변경 + alert('후기 작성이 완료되었습니다.'); + }, + }); +}; + +export const useUserPositiveKeywordsQuery = ( + params: UserPositiveKeywordsRequest, +) => { + return useQuery({ + queryKey: ['userPositiveKeywords', params], + queryFn: ({ queryKey }) => { + const [, requestParams] = queryKey as [ + string, + UserPositiveKeywordsRequest, + ]; + + return getUserPositiveKeywords(requestParams); + }, + }); +}; + +export const useMyNegativeKeywordsQuery = ( + params: MyNegativeKeywordsRequest, +) => { + return useQuery({ + queryKey: ['myNegativeKeywords', params], + queryFn: () => getMyNegativeKeywords(params), + }); +}; + +export const useMyReviewsInfinityQuery = () => { + return useInfiniteQuery({ + queryKey: ['myReviews'], + queryFn: ({ pageParam = null }) => getMyReviews({ cursor: pageParam }), + initialPageParam: null, + getNextPageParam: (lastPage) => { + if (lastPage.reviews.hasNext) { + return lastPage.reviews.nextCursor; + } + + return undefined; + }, + select: (data) => { + const allReviews = data.pages.flatMap((page) => page.reviews.items); + const lastPage = data.pages[data.pages.length - 1]; + const totalCount = data.pages[0].totalCount; + const hasNext = lastPage.reviews.hasNext; + + return { + reviews: allReviews, + totalCount, + hasNext, + }; + }, + }); +}; diff --git a/src/features/study/participation/api/get-participation-data.ts b/src/features/study/participation/api/get-participation-data.ts new file mode 100644 index 00000000..82791ff4 --- /dev/null +++ b/src/features/study/participation/api/get-participation-data.ts @@ -0,0 +1,44 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { + WeeklyReservationRequest, + WeeklyReservationResponse, + ReservationUserItem, + Participant, +} from './participation-types'; + +export function mapReservation(user: ReservationUserItem): Participant { + const original = user.profileImage?.resizedImages.find( + (img) => img.imageSizeType.imageTypeName === 'ORIGINAL', + )?.resizedImageUrl; + + return { + id: user.memberId, + name: user.memberName, + avatarUrl: original ?? null, + simpleIntroduction: user.simpleIntroduction, + }; +} + +export const getReservationMembers = async ( + params: WeeklyReservationRequest, +): Promise => { + const { cursor, pageSize = 50, firstMemberId } = params; + + const res = await axiosInstance.get('/members/study-reservation', { + params: { + ...(cursor !== null ? { cursor } : {}), + 'page-size': pageSize, + ...(firstMemberId !== null ? { 'first-member-id': firstMemberId } : {}), + }, + }); + + return res.data.content; +}; + +export type StudyStatus = 'RECRUITING' | 'STUDYING'; + +export const getStudyStatus = async (): Promise => { + const res = await axiosInstance.get('/matching/system-status'); + + return res.data.content.status as StudyStatus; +}; diff --git a/src/features/study/participation/api/participation-types.ts b/src/features/study/participation/api/participation-types.ts new file mode 100644 index 00000000..0a5eb343 --- /dev/null +++ b/src/features/study/participation/api/participation-types.ts @@ -0,0 +1,39 @@ +export interface ReservationUserItem { + memberId: number; + memberName: string; + profileImage?: { + imageId: number; + resizedImages: { + resizedImageId: number; + resizedImageUrl: string; + imageSizeType: { + imageTypeName: string; + width?: number; + height?: number; + }; + }[]; + }; + simpleIntroduction?: string; +} + +export interface WeeklyReservationResponse { + totalMemberCount: number; + members: { + nextCursor?: number; + hasNext: boolean; + items: ReservationUserItem[]; + }; +} + +export interface Participant { + id: number; + name: string; + avatarUrl?: string; + simpleIntroduction?: string; +} + +export interface WeeklyReservationRequest { + cursor?: number; + pageSize?: number; + firstMemberId?: number; +} diff --git a/src/features/study/participation/model/start-study-form.schema.ts b/src/features/study/participation/model/start-study-form.schema.ts new file mode 100644 index 00000000..5f30714c --- /dev/null +++ b/src/features/study/participation/model/start-study-form.schema.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { UrlSchema } from '@/shared/util/zod-schema'; +import { JoinStudyRequest } from '../../api/types'; + +export const StartStudyFormSchema = z.object({ + selfIntroduction: z + .string() + .trim() + .min(1, '자기소개를 입력해 주세요.') + .max(500, '최대 500자까지 입력 가능합니다.'), + studyPlan: z + .string() + .trim() + .min(1, '공부 계획을 입력해 주세요.') + .max(500, '최대 500자까지 입력 가능합니다.'), + tel: z + .string() + .trim() + .regex( + /^\d{2,3}-\d{3,4}-\d{4}$/, + '연락처 형식이 올바르지 않습니다. (예: 010-1234-5678)', + ), + githubLink: UrlSchema.optional().transform((v) => (v === '' ? undefined : v)), + blogOrSnsLink: UrlSchema.optional().transform((v) => + v === '' ? undefined : v, + ), + + preferredStudySubjectId: z + .string() + .min(1, '선호하는 스터디 주제를 선택해 주세요.'), + + availableStudyTimeIds: z + .array(z.string()) + .min(1, '가능 시간대를 1개 이상 선택해 주세요.'), + techStackIds: z + .array(z.string()) + .min(1, '기술 스택을 1개 이상 선택해 주세요.'), +}); + +export type StartStudyFormValues = z.infer; + +export function buildStartStudyDefaultValues(): StartStudyFormValues { + return { + selfIntroduction: '', + studyPlan: '', + tel: '', + githubLink: '', + blogOrSnsLink: '', + preferredStudySubjectId: '', + availableStudyTimeIds: [], + techStackIds: [], + }; +} + +export function toJoinStudyRequest( + memberId: number, + v: StartStudyFormValues, +): JoinStudyRequest { + const github = v.githubLink?.trim(); + const blog = v.blogOrSnsLink?.trim(); + + return { + memberId, + selfIntroduction: v.selfIntroduction.trim(), + studyPlan: v.studyPlan.trim(), + tel: v.tel.trim(), + githubLink: github ? github : undefined, + blogOrSnsLink: blog ? blog : undefined, + preferredStudySubjectId: v.preferredStudySubjectId, + availableStudyTimeIds: v.availableStudyTimeIds.map(Number), + techStackIds: v.techStackIds.map(Number), + }; +} diff --git a/src/features/study/participation/model/use-participation-query.ts b/src/features/study/participation/model/use-participation-query.ts new file mode 100644 index 00000000..c4d46719 --- /dev/null +++ b/src/features/study/participation/model/use-participation-query.ts @@ -0,0 +1,54 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { + getReservationMembers, + getStudyStatus, + mapReservation, + StudyStatus, +} from '../api/get-participation-data'; +import { WeeklyReservationResponse } from '../api/participation-types'; + +export function useInfiniteReservation(firstMemberId?: number, pageSize = 50) { + return useInfiniteQuery({ + queryKey: ['weeklyReservationMembers', { firstMemberId, pageSize }], + initialPageParam: { cursor: null as number | null }, + queryFn: async ({ pageParam }) => { + return getReservationMembers({ + cursor: pageParam?.cursor ?? null, + pageSize, + firstMemberId, + }); + }, + + getNextPageParam: (lastPage?: WeeklyReservationResponse) => { + if (lastPage?.members?.hasNext) { + return { cursor: lastPage.members.nextCursor }; + } + + return undefined; + }, + select: (data) => { + const pages = data?.pages ?? []; + const items = pages.flatMap((p) => + (p?.members?.items ?? []).map(mapReservation), + ); + const total = pages[0]?.totalMemberCount ?? items.length; + const last = pages[pages.length - 1]; + const hasNextPage = last?.members?.hasNext ?? false; + + return { + items, + total, + hasNextPage, + }; + }, + 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 new file mode 100644 index 00000000..284e9206 --- /dev/null +++ b/src/features/study/participation/ui/reservation-list.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { ChevronRight } from 'lucide-react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + usePatchAutoMatchingMutation, + useUserProfileQuery, +} from '@/entities/user/model/use-user-profile-query'; +import ProfileDefault from '@/entities/user/ui/icon/profile-default.svg'; +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 { + isParticipation?: boolean; + pageSize?: number; + month: number; + week: number; +} + +export default function ReservationList({ + isParticipation = false, + pageSize = 50, + month, + week, +}: ReservationListProps) { + const sentinelRef = useRef(null); + const calledRef = useRef(false); + const [memberId, setMemberId] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + const id = getCookie('memberId'); + setMemberId(id ? Number(id) : null); + }, []); + + const firstMemberId = useMemo( + () => (isParticipation && memberId !== null ? memberId : null), + [isParticipation, memberId], + ); + + const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteReservation(firstMemberId, pageSize); + + const { data: userProfile } = useUserProfileQuery(memberId ?? 0); + + const { mutate: patchAutoMatching, isPending } = + usePatchAutoMatchingMutation(); + + useEffect(() => { + if (!memberId || isParticipation || !userProfile) return; + + const { studyApplied, autoMatching } = userProfile; + + if (studyApplied && !autoMatching && !calledRef.current) { + calledRef.current = true; + patchAutoMatching( + { memberId, autoMatching: true }, + { + onError: () => { + calledRef.current = false; + }, + }, + ); + } + }, [memberId, isParticipation, userProfile, patchAutoMatching]); + + useEffect(() => { + if (!hasNextPage) return; + + const targetElement = sentinelRef.current; + if (!targetElement) return; + + const observer = new IntersectionObserver( + async (entries) => { + const isVisible = entries.some((entry) => entry.isIntersecting); + if (isVisible && hasNextPage && !isFetchingNextPage) { + await fetchNextPage(); + } + }, + { rootMargin: '200px 0px' }, + ); + + observer.observe(targetElement); + + return () => { + observer.disconnect(); + }; + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const items = data?.items ?? []; + const studyApplied = userProfile?.studyApplied ?? false; + + const handleApplyClick = () => { + if (!memberId) return; + + if (studyApplied) { + if (isPending) return; + patchAutoMatching({ memberId, autoMatching: true }); + } else { + setIsModalOpen(true); + } + }; + + if (isLoading) { + return ( +
    + 불러오는 중… +
    + ); + } + + return ( +
    +
    +
    + {`${month}월 ${week}주차 스터디 신청 목록`} +
    +
    + 총 {data?.total}명 +
    +
    + +
    + {items.map((p) => ( + + ))} + + {!isParticipation && ( +
    + +
    + + {`${month}월 ${week}주차 스터디 신청하기`} + + + 지금 신청하면 함께할 수 있어요 + +
    + +
    + )} +
    + +
    + {isFetchingNextPage && ( +
    더 불러오는 중…
    + )} +
    + + {memberId && ( + + )} +
    + ); +} diff --git a/src/features/study/participation/ui/reservation-user-card.tsx b/src/features/study/participation/ui/reservation-user-card.tsx new file mode 100644 index 00000000..4e23e6b3 --- /dev/null +++ b/src/features/study/participation/ui/reservation-user-card.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import UserAvatar from '@/shared/ui/avatar'; +import Badge from '@/shared/ui/badge'; +import { Participant } from '../api/participation-types'; + +interface ReservationCardProps { + participant: Participant; + isCurrentUser?: boolean; +} + +export default function ReservationCard({ + participant, + isCurrentUser = false, +}: ReservationCardProps) { + return ( +
    + +
    +
    +
    {participant.name}
    + {isCurrentUser && 본인} +
    +
    + {participant.simpleIntroduction} +
    +
    + + 프로필 +
    + } + /> + + ); +} diff --git a/src/features/study/ui/start-study-modal.tsx b/src/features/study/ui/start-study-modal.tsx index 4c3a190e..1330e84d 100644 --- a/src/features/study/ui/start-study-modal.tsx +++ b/src/features/study/ui/start-study-modal.tsx @@ -1,34 +1,39 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import { XIcon } from 'lucide-react'; -import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useMemo } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + import { useAvailableStudyTimesQuery, useStudySubjectsQuery, useTechStacksQuery, } from '@/features/my-page/model/use-update-user-profile-mutation'; -import { cn } from '@/shared/shadcn/lib/utils'; + import Button from '@/shared/ui/button'; import { SingleDropdown, MultiDropdown } from '@/shared/ui/dropdown'; -import { BaseInput } from '@/shared/ui/input'; +import FormField from '@/shared/ui/form/form-field'; +import { BaseInput, TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { ToggleButton } from '@/shared/ui/toggle'; -import { JoinStudyRequest } from '../api/types'; + +import { ToggleGroup } from '@/shared/ui/toggle'; import { studySteps } from '../consts/study-const'; + import { useJoinStudyMutation } from '../model/use-study-query'; +import { + StartStudyFormSchema, + type StartStudyFormValues, + buildStartStudyDefaultValues, + toJoinStudyRequest, +} from '../participation/model/start-study-form.schema'; interface StartStudyModalProps { memberId: number; -} - -interface LabeledFieldProps { - label: string; - required?: boolean; - description?: string; - children: React.ReactNode; - className?: string; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } interface NumberedBulletSectionProps { @@ -42,7 +47,7 @@ function NumberedBulletSection({ title, items }: NumberedBulletSectionProps) {
    {title}
      - {items.map((item: string, idx: number) => ( + {items.map((item, idx) => (
    • {item}
    • @@ -53,51 +58,15 @@ function NumberedBulletSection({ title, items }: NumberedBulletSectionProps) { ); } -export function LabeledField({ - label, - required, - description, - children, - className, -}: LabeledFieldProps) { - return ( -
      - - {description && ( - - {description} - - )} - {children} -
      - ); -} - -type JoinStudyFormError = { - [K in keyof Omit< - JoinStudyRequest, - 'memberId' | 'githubLink' | 'blogOrSnsLink' - >]: boolean; -}; - -export default function StartStudyModal({ memberId }: StartStudyModalProps) { +export default function StartStudyModal({ + memberId, + trigger, + open, + onOpenChange, +}: StartStudyModalProps) { return ( - - - 스터디 시작 버튼 - + + {trigger ? {trigger} : null} @@ -110,100 +79,77 @@ export default function StartStudyModal({ memberId }: StartStudyModalProps) { - + onOpenChange?.(false)} + /> ); } -function StartStudyForm({ memberId }: StartStudyModalProps) { - const [form, setForm] = useState>({ - selfIntroduction: '', - studyPlan: '', - tel: '', - githubLink: '', - blogOrSnsLink: '', - preferredStudySubjectId: undefined, - availableStudyTimeIds: [], - techStackIds: [], - }); +function StartStudyForm({ + memberId, + onClose, +}: { + memberId: number; + onClose: () => void; +}) { + const router = useRouter(); + const { data: availableStudyTimes = [] } = useAvailableStudyTimesQuery(); + const { data: studySubjects = [] } = useStudySubjectsQuery(); + const { data: techStacks = [] } = useTechStacksQuery(); + const { mutate: joinStudy } = useJoinStudyMutation(); - const [error, setError] = useState({ - selfIntroduction: false, - studyPlan: false, - tel: false, - preferredStudySubjectId: false, - availableStudyTimeIds: false, - techStackIds: false, + const methods = useForm({ + resolver: zodResolver(StartStudyFormSchema), + mode: 'onChange', + defaultValues: buildStartStudyDefaultValues(), }); - const { - selfIntroduction, - studyPlan, - tel, - githubLink, - blogOrSnsLink, - preferredStudySubjectId, - availableStudyTimeIds, - } = form; + const { handleSubmit } = methods; - const { data: availableStudyTimes } = useAvailableStudyTimesQuery(); - const { data: studySubjects } = useStudySubjectsQuery(); - const { data: techStacks } = useTechStacksQuery(); - const router = useRouter(); - - const { mutate: joinStudy } = useJoinStudyMutation(); - - const toggleStudyTime = (id: number) => { - setForm((prev) => - prev.availableStudyTimeIds.includes(id) - ? { - ...prev, - availableStudyTimeIds: prev.availableStudyTimeIds.filter( - (item) => item !== id, - ), - } - : { - ...prev, - availableStudyTimeIds: [...prev.availableStudyTimeIds, id], - }, - ); - }; + const subjectOptions = useMemo( + () => + studySubjects.map(({ studySubjectId, name }) => ({ + value: String(studySubjectId), + label: name, + })), + [studySubjects], + ); - const handleSubmit = () => { - const newError: JoinStudyFormError = { - selfIntroduction: selfIntroduction.trim() === '', - studyPlan: studyPlan.trim() === '', - tel: !/^\d{2,3}-\d{3,4}-\d{4}$/.test(tel), - preferredStudySubjectId: preferredStudySubjectId === undefined, - availableStudyTimeIds: availableStudyTimeIds.length === 0, - techStackIds: form.techStackIds.length === 0, - }; + const timeOptions = useMemo( + () => + availableStudyTimes.map(({ availableTimeId, display }) => ({ + value: String(availableTimeId), + label: display, + })), + [availableStudyTimes], + ); - if (Object.values(newError).some(Boolean)) { - setError(newError); + const techOptions = useMemo( + () => + techStacks.map(({ techStackId, techStackName }) => ({ + value: String(techStackId), + label: techStackName, + })), + [techStacks], + ); - return; - } + const onValidSubmit = (values: StartStudyFormValues) => { + const body = toJoinStudyRequest(memberId, values); - joinStudy( - { - ...form, - memberId, - githubLink: githubLink.trim() || undefined, - blogOrSnsLink: blogOrSnsLink.trim() || undefined, + joinStudy(body, { + onSuccess: () => { + alert('스터디 신청이 완료되었습니다!'); + onClose(); + router.refresh(); }, - { - onSuccess: () => { - alert('스터디 신청이 완료되었습니다!'); - router.refresh(); - }, - onError: () => { - alert('스터디 신청 중 오류가 발생했습니다. 다시 시도해 주세요.'); - }, + onError: () => { + alert('스터디 신청 중 오류가 발생했습니다. 다시 시도해 주세요.'); }, - ); + }); }; return ( @@ -220,177 +166,105 @@ function StartStudyForm({ memberId }: StartStudyModalProps) {
      -
      - +
      - { - setForm((prev) => ({ - ...prev, - selfIntroduction: e.target.value, - })); - setError((prev) => ({ - ...prev, - selfIntroduction: e.target.value.trim() === '', - })); - }} - /> - + + name="selfIntroduction" + label="자기 소개" + helper="간단한 자기소개를 입력해 주세요." + direction="vertical" + required + > + + - - { - setForm((prev) => ({ - ...prev, - studyPlan: e.target.value, - })); - setError((prev) => ({ - ...prev, - studyPlan: e.target.value.trim() === '', - })); - }} - /> - + + name="studyPlan" + label="공부 주제 및 계획" + helper="스터디에서 다루고 싶은 주제와 학습 목표를 알려주세요." + direction="vertical" + required + > + + - - ({ - value: studySubjectId, - label: name, - }), - )} - placeholder="선택하세요" - onChange={(value) => { - setForm((prev) => ({ - ...prev, - preferredStudySubjectId: value.toString(), - })); - setError((prev) => ({ - ...prev, - preferredStudySubjectId: value === undefined, - })); - }} - /> - + + name="tel" + label="연락처" + helper="스터디 진행을 위해 연락 가능한 정보를 입력해 주세요. 입력하신 정보는 매칭된 스터디원에게만 제공되며, 외부에는 노출되지 않습니다." + direction="vertical" + required + > + + - -
      - {(availableStudyTimes ?? []).map( - ({ availableTimeId, display }) => ( - toggleStudyTime(availableTimeId)} - > - {display} - - ), - )} -
      -
      + + name="preferredStudySubjectId" + label="선호하는 스터디 주제" + helper="관심 있는 스터디 유형을 선택해 주세요." + direction="vertical" + required + > + + - - ({ - value: techStackId, - label: techStackName, - }), - )} - onChange={(newSelected) => { - setForm((prev) => ({ - ...prev, - techStackIds: newSelected as number[], - })); - setError((prev) => ({ - ...prev, - techStackIds: newSelected.length === 0, - })); - }} - placeholder="기술을 선택해주세요" - /> - + + name="availableStudyTimeIds" + label="가능 시간대" + helper="스터디 참여가 가능한 시간대를 모두 선택해 주세요." + direction="vertical" + required + > + + - - { - setForm((prev) => ({ - ...prev, - tel: e.target.value, - })); - setError((prev) => ({ - ...prev, - tel: !/^\d{2,3}-\d{3,4}-\d{4}$/.test(e.target.value), - })); - }} - /> - + + name="techStackIds" + label="사용 가능한 기술 스택" + helper="현재 본인이 사용할 수 있는 기술 스택을 모두 선택해 주세요." + direction="vertical" + required + > + + - - - setForm((prev) => ({ ...prev, githubLink: e.target.value })) - } - /> - + + name="githubLink" + label="GitHub" + helper="본인의 활동을 확인할 수 있는 GitHub 링크를 입력해 주세요." + direction="vertical" + > + + - - - setForm((prev) => ({ - ...prev, - blogOrSnsLink: e.target.value, - })) - } - /> - -
      + + name="blogOrSnsLink" + label="블로그/SNS 등 링크" + helper="본인의 활동을 확인할 수 있는 외부 링크가 있다면 입력해 주세요." + direction="vertical" + > + + + + @@ -399,8 +273,16 @@ function StartStudyForm({ memberId }: StartStudyModalProps) { 취소 - diff --git a/src/features/study/ui/study-card.tsx b/src/features/study/ui/study-card.tsx index 2c8d5352..0edb1e8d 100644 --- a/src/features/study/ui/study-card.tsx +++ b/src/features/study/ui/study-card.tsx @@ -6,6 +6,8 @@ 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'; // 스터디 주차 구하는 함수 function getWeekly(date: Date): { month: number; week: number } { @@ -59,6 +61,8 @@ export default function StudyCard() { const studyDate = dateOffset.toISOString().split('T')[0]; + const { data: status } = useStudyStatusQuery(); + const { data: participationData } = useWeeklyParticipation(studyDate); const isParticipate = participationData?.isParticipate ?? false; @@ -66,14 +70,26 @@ export default function StudyCard() { return ( <> -
      -
      {`${month}월 ${week}주차 스터디`}
      - -
      -
      - {isParticipate && } - -
      + {status === 'RECRUITING' && ( + + )} + {status === 'STUDYING' && ( + <> +
      +
      {`${month}월 ${week}주차 스터디`}
      + +
      +
      + {isParticipate && } + +
      + + )} ); } diff --git a/src/features/study/ui/study-done-modal.tsx b/src/features/study/ui/study-done-modal.tsx index 6224f2d3..b3825ca6 100644 --- a/src/features/study/ui/study-done-modal.tsx +++ b/src/features/study/ui/study-done-modal.tsx @@ -1,17 +1,27 @@ '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 { 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 { + +import type { CompleteStudyRequest, DailyStudyDetail, StudyProgressStatus, } from '../api/types'; import { STUDY_PROGRESS_OPTIONS } from '../consts/study-const'; +import { + StudyDoneFormSchema, + type StudyDoneFormValues, + buildStudyDoneDefaults, +} from '../model/interview.schema'; import { useUpdateDailyStudyMutation } from '../model/use-study-query'; interface StudyDoneModalProps { @@ -56,27 +66,33 @@ export default function StudyDoneModal({ ); } -interface StudyDoneFormProps { +function StudyDoneForm({ + data, + studyDate, + onClose, +}: { data: DailyStudyDetail; studyDate: string; onClose: () => void; -} +}) { + const { mutate, isPending } = useUpdateDailyStudyMutation(); -function StudyDoneForm({ data, studyDate, onClose }: StudyDoneFormProps) { - const [form, setForm] = useState({ - feedback: data.feedback ?? '', - progressStatus: data.progressStatus ?? 'PENDING', + const methods = useForm({ + resolver: zodResolver(StudyDoneFormSchema), + mode: 'onChange', + defaultValues: buildStudyDoneDefaults(data), }); - const { mutate, isPending } = useUpdateDailyStudyMutation(); - const { feedback, progressStatus } = form; + const { + handleSubmit, + formState: { isValid, isSubmitting }, + } = methods; - const handleChange = (key: keyof CompleteStudyRequest) => (value: string) => { - setForm((prev) => ({ ...prev, [key]: value })); - }; - - const handleSubmit = async () => { - if (!feedback.trim() || !progressStatus) return; + const onSubmit = (values: StudyDoneFormValues) => { + const form: CompleteStudyRequest = { + progressStatus: values.progressStatus as StudyProgressStatus, + feedback: values.feedback, + }; mutate( { @@ -98,49 +114,42 @@ function StudyDoneForm({ data, studyDate, onClose }: StudyDoneFormProps) { return ( <> -
      -
      - - - 면접 완료 후 해당 지원자의 상태를 업데이트해 주세요. - -
      - - - handleChange('progressStatus')(value as StudyProgressStatus) - } - /> -
      - -
      -
      - - - 면접 결과에 대한 간단한 피드백을 입력해 주세요. - -
      - - handleChange('feedback')(value)} - /> -
      + +
      + + name="progressStatus" + label="진행 현황" + helper="면접 완료 후 해당 지원자의 상태를 업데이트해 주세요." + required + direction="vertical" + > + + + + + name="feedback" + label="피드백" + helper="면접 결과에 대한 간단한 피드백을 입력해 주세요." + required + direction="vertical" + showCounterRight + counterMax={100} + > + + + +
      @@ -150,9 +159,10 @@ function StudyDoneForm({ data, studyDate, onClose }: StudyDoneFormProps) { diff --git a/src/features/study/ui/study-ready-modal.tsx b/src/features/study/ui/study-ready-modal.tsx index 2926eb22..56631828 100644 --- a/src/features/study/ui/study-ready-modal.tsx +++ b/src/features/study/ui/study-ready-modal.tsx @@ -1,11 +1,21 @@ '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 FormField from '@/shared/ui/form/form-field'; import { BaseInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { DailyStudyDetail, PrepareStudyRequest } from '../api/types'; + +import type { DailyStudyDetail, PrepareStudyRequest } from '../api/types'; +import { + StudyReadyFormSchema, + type StudyReadyFormValues, + buildStudyReadyDefaults, +} from '../model/interview.schema'; import { useUpdateDailyStudyMutation } from '../model/use-study-query'; interface StudyReadyModalProps { @@ -46,27 +56,33 @@ export default function StudyReadyModal({ ); } -interface StudyReadyFormProps { +function StudyReadyForm({ + data, + studyDate, + onClose, +}: { data: DailyStudyDetail; studyDate: string; onClose: () => void; -} +}) { + const { mutate, isPending } = useUpdateDailyStudyMutation(); -function StudyReadyForm({ data, studyDate, onClose }: StudyReadyFormProps) { - const [form, setForm] = useState({ - subject: data.subject ?? '', - link: data.link ?? '', + const methods = useForm({ + resolver: zodResolver(StudyReadyFormSchema), + mode: 'onChange', + defaultValues: buildStudyReadyDefaults(data), }); - const { mutate, isPending } = useUpdateDailyStudyMutation(); - const { subject, link } = form; + const { + handleSubmit, + formState: { isValid, isSubmitting }, + } = methods; - const handleChange = (key: keyof PrepareStudyRequest) => (value: string) => { - setForm((prev) => ({ ...prev, [key]: value })); - }; - - const handleSubmit = async () => { - if (!subject.trim()) return; + const onSubmit = (values: StudyReadyFormValues) => { + const form: PrepareStudyRequest = { + subject: values.subject, + link: values.link ?? undefined, + }; mutate( { @@ -88,42 +104,33 @@ function StudyReadyForm({ data, studyDate, onClose }: StudyReadyFormProps) { return ( <> -
      -
      - - - 이번 스터디에서 다룰 면접 주제를 입력하세요 - -
      - - handleChange('subject')(e.target.value)} - /> -
      - -
      -
      - - - 참고할 링크나 자료가 있다면 입력해 주세요 - -
      - - handleChange('link')(e.target.value)} - /> -
      + +
      + + name="subject" + label="면접 주제" + helper="이번 스터디에서 다룰 면접 주제 또는 질문 유형을 간단히 작성해 주세요." + required + direction="vertical" + > + + + + + name="link" + label="참고 자료" + helper="함께 참고할 문서나 링크가 있다면 입력해 주세요" + required + direction="vertical" + > + + + +
      @@ -133,9 +140,10 @@ function StudyReadyForm({ data, studyDate, onClose }: StudyReadyFormProps) { diff --git a/src/features/study/ui/study-review-modal.tsx b/src/features/study/ui/study-review-modal.tsx new file mode 100644 index 00000000..fe291dda --- /dev/null +++ b/src/features/study/ui/study-review-modal.tsx @@ -0,0 +1,437 @@ +'use client'; + +import { XIcon } from 'lucide-react'; +import Image from 'next/image'; +import { useState } from 'react'; +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'; + +interface FormState { + studySpaceId: number; + targetMemberId: number; + satisfactionId: 10 | 20 | 30 | null; // 10 - "아쉬워요", 20 - "괜찮아요", 30 - "좋았어요" + keywordIds: number[]; + content: string; +} + +export default function StudyReviewModal() { + return ( + + +
      +

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

      +
      +
      + + + + + + + + + +
      + 함께 스터디한 멤버에 대해 알려주세요 + +
      + + 같이 성장할 수 있는 스터디 문화를 만들기 위해 평가를 남겨주세요. + + 평가한 내용은 성실 온도에 반영됩니다. +
      +
      + + +
      +
      +
      + ); +} + +function StudyReviewForm() { + const { data } = usePartnerStudyReviewQuery(); + const { mutate: addStudyReview } = useAddStudyReviewMutation(); + + const [form, setForm] = useState({ + studySpaceId: data?.studySpaceId, + targetMemberId: data?.targetMembers[0].memberId, + satisfactionId: null, + keywordIds: [], + content: '', + }); + + if (!data) return null; + + const handleSubmit = () => { + if ( + form.keywordIds.length === 0 || + form.satisfactionId === null || + form.content === '' + ) + return; + + addStudyReview(form); + }; + + return ( + <> + + + +
      + + 스터디 만족도 + + +
      + { + setForm({ + ...form, + satisfactionId: 10, + keywordIds: [], + content: '', + }); + }} + /> + + { + setForm({ + ...form, + satisfactionId: 20, + keywordIds: [], + content: '', + }); + }} + /> + + { + setForm({ + ...form, + satisfactionId: 30, + keywordIds: [], + content: '', + }); + }} + /> +
      +
      + + {form.satisfactionId === 10 && ( + + )} + + {(form.satisfactionId === 20 || form.satisfactionId === 30) && ( + + )} +
      + + + + + + + + ); +} + +function PartnerInfo(data: StudyEvaluationResponse) { + const partner = data.targetMembers[0]; + + return ( +
      + Study Member + +
      + + {partner.memberName} + + +

      + + {data.studySubject} + + + {data.startDate} ~ {data.endDate} + +

      +
      +
      + ); +} + +function SatisfactionButton({ + label, + isSelected, + imageSrc, + onClick, +}: { + label: string; + isSelected: boolean; + imageSrc: string; + onClick: () => void; +}) { + return ( + + ); +} + +function PositiveReview({ + data, + form, + onChange, +}: { + form: FormState; + data: StudyEvaluationResponse; + onChange: (form: FormState | ((prev: FormState) => FormState)) => void; +}) { + return ( + <> + onChange((prev) => ({ ...prev, keywordIds }))} + /> + onChange((prev) => ({ ...prev, content }))} + /> + + ); +} + +function NegativeReview({ + data, + form, + onChange, +}: { + data: StudyEvaluationResponse; + form: FormState; + onChange: (form: FormState | ((prev: FormState) => FormState)) => void; +}) { + return ( + <> + onChange((prev) => ({ ...prev, keywordIds }))} + /> + onChange((prev) => ({ ...prev, content }))} + /> + + ); +} + +function PositiveCheckboxList({ + positiveKeywords, + keywordIds, + onChange, +}: { + positiveKeywords: EvalKeyword[]; + keywordIds: FormState['keywordIds']; + onChange: (keywordIds: FormState['keywordIds']) => void; +}) { + const handleToggle = (id: number) => { + const isChecked = keywordIds.includes(id); + const newKeywordIds = isChecked + ? keywordIds.filter((k) => k !== id) + : [...keywordIds, id]; + + onChange(newKeywordIds); + }; + + return ( +
      +
      + + 이런 점이 좋았어요 + + 필수 +
      + +
        + {positiveKeywords.map(({ id, keyword }) => ( + + { + handleToggle(id); + }} + /> + + + ))} +
      +
      + ); +} + +function PositiveTextArea({ + value, + onChange, +}: { + value: string; + onChange: (content: string) => void; +}) { + return ( +
      +
      +

      + 어떤 점이 좋았나요? +

      +
      + + 같이 성장할 수 있는 스터디 문화를 만들기 위해 평가를 남겨주세요. + + 평가한 내용은 성실 온도에 반영됩니다. +
      +
      + + onChange(e.target.value)} + /> +
      + ); +} + +function NegativeCheckboxList({ + negativeKeywords, + keywordIds, + onChange, +}: { + negativeKeywords: StudyEvaluationResponse['unsatisfiedEvalKeywords']; + keywordIds: FormState['keywordIds']; + onChange: (keywordIds: FormState['keywordIds']) => void; +}) { + const handleToggle = (id: number) => { + const isChecked = keywordIds.includes(id); + const newKeywordIds = isChecked + ? keywordIds.filter((k) => k !== id) + : [...keywordIds, id]; + + onChange(newKeywordIds); + }; + + return ( +
      +
      + + 이런 점이 아쉬웠어요 + + 필수 +
      + +
        + {negativeKeywords.map(({ id, keyword }) => ( + + { + handleToggle(id); + }} + /> + + + ))} +
      +
      + ); +} + +function NegativeTextArea({ + value, + onChange, +}: { + value: string; + onChange: (content: string) => void; +}) { + return ( +
      +
      +

      + 어떤 점이 아쉬웠나요? +

      +
      + + 스터디 과정에서 아쉬웠던 점이 있다면, 이는 성장을 위한 소중한 + 피드백이 됩니다. + + 작성하신 내용은 오직 상대방만 확인할 수 있어요. +
      +
      + + onChange(e.target.value)} + maxLength={1000} + placeholder="아쉬웠던 점을 자세히 말해주세요" + /> +
      + ); +} diff --git a/src/features/study/ui/today-study-card.tsx b/src/features/study/ui/today-study-card.tsx index ffc87692..a1816bb4 100644 --- a/src/features/study/ui/today-study-card.tsx +++ b/src/features/study/ui/today-study-card.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import UserProfileModal from '@/features/my-page/ui/user-profile-modal'; +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 해야하는데, diff --git a/src/shared/lib/time.ts b/src/shared/lib/time.ts new file mode 100644 index 00000000..662bba84 --- /dev/null +++ b/src/shared/lib/time.ts @@ -0,0 +1,37 @@ +import { + differenceInDays, + differenceInHours, + differenceInMinutes, + parseISO, +} from 'date-fns'; +import { format } from 'path'; + +export const getKoreaDate = (targetDate?: Date) => { + const date = targetDate || new Date(); + const utc = date.getTime() + date.getTimezoneOffset() * 60 * 1000; // 1970년 1월 1일로부터 현재까지 지난 시간 (밀리초) + + const koreaTimeDiff = 9 * 60 * 60 * 1000; // 한국은 UTC보다 9시간 빠름 + + const koreaNow = new Date(utc + koreaTimeDiff); + + return koreaNow; +}; + +export const formatKoreaRelativeTime = (targetDateStr: string): string => { + const targetDate = parseISO(targetDateStr); + const koreaTarget = getKoreaDate(targetDate); // 한국 시간 변환 + const koreaNow = getKoreaDate(); + + const minutes = differenceInMinutes(koreaNow, koreaTarget); + + if (minutes < 1) return '방금 전'; // 1분 미만이면 "방금 전" + if (minutes < 60) return `${minutes}분 전`; // 60분 미만이면 "n분 전" + + const hours = differenceInHours(koreaNow, koreaTarget); + if (hours < 24) return `${hours}시간 전`; // 24시간 미만이면 "n시간 전" + + const days = differenceInDays(koreaNow, koreaTarget); + if (days < 30) return `${days}일 전`; // 30일 미만이면 "n일 전" + + return targetDateStr; +}; diff --git a/src/shared/shadcn/ui/button.tsx b/src/shared/shadcn/ui/button.tsx index b81e3ffb..d61405e4 100644 --- a/src/shared/shadcn/ui/button.tsx +++ b/src/shared/shadcn/ui/button.tsx @@ -50,7 +50,7 @@ function Button({ return ( ); diff --git a/src/shared/ui/avatar/index.tsx b/src/shared/ui/avatar/index.tsx index 3f8817bc..ded4874a 100644 --- a/src/shared/ui/avatar/index.tsx +++ b/src/shared/ui/avatar/index.tsx @@ -1,27 +1,66 @@ 'use client'; -import { Avatar, AvatarImage } from '@/shared/shadcn/ui/avatar'; +import { useMemo, useState } from 'react'; +import ProfileDefault from '@/entities/user/ui/icon/profile-default.svg'; +import { Avatar, AvatarImage, AvatarFallback } from '@/shared/shadcn/ui/avatar'; + +type ProfileImageSrc = string | undefined; + +function getValidImageUrl(src: ProfileImageSrc) { + const trimedSrc = (src ?? '').trim(); + if (!trimedSrc || trimedSrc.toLowerCase() === 'default') return undefined; + + return trimedSrc; +} interface UserAvatarProps { - image?: string; + image?: ProfileImageSrc; alt?: string; size?: number; + accentColor?: string; + className?: string; } export default function UserAvatar({ image, alt = 'user profile', size = 32, - ref, + accentColor = '#FAB0D5', + className, ...props -}: React.RefAttributes & UserAvatarProps) { +}: UserAvatarProps) { + const [isImageError, setImageError] = useState(false); + + const resolvedImageUrl = useMemo(() => { + setImageError(false); + + return getValidImageUrl(image); + }, [image]); + + const showImage = !!resolvedImageUrl && !isImageError; + return ( - - {image ? ( - - ) : ( - + + {showImage && ( + setImageError(true)} + /> )} + + + + ); } diff --git a/src/shared/ui/button/index.tsx b/src/shared/ui/button/index.tsx index e7673056..793b7ce3 100644 --- a/src/shared/ui/button/index.tsx +++ b/src/shared/ui/button/index.tsx @@ -9,29 +9,26 @@ interface ButtonProps extends React.ComponentProps<'button'> { iconPosition?: 'left' | 'right'; } -const buttonVariants = cva( - 'rounded-100 flex items-center justify-center cursor-pointer', - { - variants: { - color: { - primary: - 'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover active:bg-fill-brand-default-pressed disabled:bg-background-disabled disabled:text-text-disabled', - secondary: - 'bg-fill-neutral-default-default text-text-default hover:bg-fill-neutral-default-hover active:bg-fill-neutral-default-pressed disabled:bg-background-disabled disabled:text-text-disabled', - }, - size: { - xsmall: 'px-75 py-25 font-designer-13b', - small: 'px-75 py-50 font-designer-14b', - medium: 'px-100 py-75 font-designer-16b', - large: 'px-150 py-100 font-designer-16b', - }, +const buttonVariants = cva('flex items-center justify-center cursor-pointer', { + variants: { + color: { + primary: + 'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover active:bg-fill-brand-default-pressed disabled:bg-background-disabled disabled:text-text-disabled', + secondary: + 'bg-fill-neutral-default-default text-text-default hover:bg-fill-neutral-default-hover active:bg-fill-neutral-default-pressed disabled:bg-background-disabled disabled:text-text-disabled', }, - defaultVariants: { - color: 'primary', - size: 'medium', + size: { + xsmall: 'px-75 py-25 font-designer-13b rounded-75', + small: 'px-75 py-50 font-designer-14b rounded-75', + medium: 'px-100 py-75 font-designer-16b rounded-100', + large: 'px-150 py-100 font-designer-16b rounded-100', }, }, -); + defaultVariants: { + color: 'primary', + size: 'medium', + }, +}); function Button({ color = 'primary', diff --git a/src/shared/ui/checkbox/index.tsx b/src/shared/ui/checkbox/index.tsx new file mode 100644 index 00000000..6652cf8c --- /dev/null +++ b/src/shared/ui/checkbox/index.tsx @@ -0,0 +1,39 @@ +import Image from 'next/image'; + +const Checkbox = ({ + id, + defaultChecked = false, + checked = false, + onToggle, +}: { + id: string; + defaultChecked?: boolean; + checked?: boolean; + onToggle: () => void; +}) => { + return ( + + ); +}; + +export default Checkbox; diff --git a/src/shared/ui/dropdown/multi.tsx b/src/shared/ui/dropdown/multi.tsx index 7b228088..fc1fc0ca 100644 --- a/src/shared/ui/dropdown/multi.tsx +++ b/src/shared/ui/dropdown/multi.tsx @@ -1,7 +1,7 @@ 'use client'; import { XIcon, ChevronDown, ChevronUp } from 'lucide-react'; -import { useState } from 'react'; +import { useMemo, useState, useCallback } from 'react'; import { DropdownMenu, DropdownMenuContent, @@ -11,107 +11,130 @@ import { interface Option { label: string; - value: string | number; + value: string; } interface MultiDropdownProps { - options: Option[]; - defaultValue?: (string | number)[]; - error?: boolean; - onChange?: (selected: (string | number)[]) => void; + options: ReadonlyArray