diff --git a/app/assets/mypage-default.svg b/app/assets/mypage-default.svg new file mode 100644 index 00000000..ced5dcb1 --- /dev/null +++ b/app/assets/mypage-default.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/components/common/FilterBottomSheet.tsx b/app/components/common/FilterBottomSheet.tsx index 4c68a22a..72463c7c 100644 --- a/app/components/common/FilterBottomSheet.tsx +++ b/app/components/common/FilterBottomSheet.tsx @@ -44,7 +44,7 @@ export default function FilterBottomSheet({ isOpen, onClose, children, className
void; onApply: (filter: string) => void; currentFilter: string; + filters?: string[]; + title?: string; } -const FILTERS = ["전체", "검토 중", "매칭", "거절"]; +const DEFAULT_FILTERS = ["전체", "검토 중", "매칭", "거절"]; export default function FilterBottomSheet({ isOpen, onClose, onApply, currentFilter, + filters, + title = "정렬 필터", }: FilterBottomSheetProps) { const [selected, setSelected] = useState(currentFilter); + const filterOptions = filters ?? DEFAULT_FILTERS; if (!isOpen) return null; @@ -36,14 +41,14 @@ export default function FilterBottomSheet({ {/* 1. 헤더 영역 */}
- 정렬 필터 + {title}
{/* 2. 필터 옵션 영역 */}
- {FILTERS.map((filter) => ( + {filterOptions.map((filter) => (
); -} \ No newline at end of file +} diff --git a/app/routes/chat/chat-content.tsx b/app/routes/chat/chat-content.tsx index fa66a06d..5fbac04e 100644 --- a/app/routes/chat/chat-content.tsx +++ b/app/routes/chat/chat-content.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { SORT_LABEL, type SortOption } from "./components/SortingSheetConstant"; import { useEffect } from "react"; import { ChatListHeader } from "./components/ChatListHeader"; @@ -19,7 +19,7 @@ function ChatPage() { // 바텀탭 숨기기 useHideBottomTab(isSortOpen); - const fetchRooms = async () => { + const fetchRooms = useCallback(async () => { setLoading(true); try { const data = await getChatRooms({ @@ -34,11 +34,11 @@ function ChatPage() { } finally { setLoading(false); } - }; + }, [sort]); useEffect(() => { fetchRooms(); - }, [sort]); + }, [fetchRooms]); const openSortSheet = () => { setPendingSort(sort); @@ -77,4 +77,4 @@ function ChatPage() { ); } -export default ChatPage; \ No newline at end of file +export default ChatPage; diff --git a/app/routes/chat/components/SortingSheet.tsx b/app/routes/chat/components/SortingSheet.tsx index cf1e7852..0b80f72d 100644 --- a/app/routes/chat/components/SortingSheet.tsx +++ b/app/routes/chat/components/SortingSheet.tsx @@ -1,22 +1,40 @@ -import { type SortOption } from "./SortingSheetConstant"; +import { SORT_LABEL, type SortOption } from "./SortingSheetConstant"; // 정렬, 필터 옵션 (최신순/협업 중) -export function SortFilterSheet({ +type SortOptionItem = { + label: string; + value: T; +}; + +const DEFAULT_OPTIONS: SortOptionItem[] = [ + { label: SORT_LABEL.latest, value: "latest" }, + { label: SORT_LABEL.collaborating, value: "collaborating" }, +]; + +export function SortFilterSheet({ open, value, onChange, onClose, onApply, + options, + title = "정렬 필터", + applyLabel = "적용하기", }: { open: boolean; - value: SortOption; - onChange: (v: SortOption) => void; + value: T; + onChange: (v: T) => void; onClose: () => void; onApply: () => void; + options?: SortOptionItem[]; + title?: string; + applyLabel?: string; }) { if (!open) return null; + const sheetOptions = (options ?? DEFAULT_OPTIONS) as SortOptionItem[]; + return (
{/* 딤(배경) */} @@ -29,21 +47,19 @@ export function SortFilterSheet({ {/* 시트 */}
-
-
정렬 필터
+
+
{title}
- onChange("latest")} - /> - onChange("collaborating")} - /> + {sheetOptions.map((option) => ( + onChange(option.value)} + /> + ))}
@@ -54,7 +70,7 @@ export function SortFilterSheet({ onClick={onApply} className="w-full max-w-[327px] h-11 rounded-[12px] bg-[#6666E5] text-white text-SemiBold" > - 적용하기 + {applyLabel}
@@ -81,4 +97,4 @@ function SortOptionButton({ ); } -export default SortFilterSheet; \ No newline at end of file +export default SortFilterSheet; diff --git a/app/routes/mypage/components/MyPageHome.tsx b/app/routes/mypage/components/MyPageHome.tsx index abc36618..f7d81bbe 100644 --- a/app/routes/mypage/components/MyPageHome.tsx +++ b/app/routes/mypage/components/MyPageHome.tsx @@ -4,7 +4,7 @@ import GateModal from "./mypage/GateModal"; import MenuButton from "./mypage/MenuButton"; type Props = { - hasMatchingTest: boolean; + hasMatchingTest: boolean | null; user: { name: string; roleText?: string; @@ -16,7 +16,6 @@ type Props = { onOpenLikes: () => void; onOpenEditProfile: () => void; onOpenNotifications: () => void; - onOpenInquiry: () => void; onOpenTerms: () => void; onOpenPrivacy: () => void; onLogout: () => void; @@ -31,17 +30,16 @@ export default function MyPageHome({ onOpenLikes, onOpenEditProfile, onOpenNotifications, - onOpenInquiry, onOpenTerms, onOpenPrivacy, onLogout, onWithdraw, }: Props) { - const [openGate, setOpenGate] = useState(!hasMatchingTest); + const [gateDismissed, setGateDismissed] = useState(false); const [openLogout, setOpenLogout] = useState(false); const [openWithdraw, setOpenWithdraw] = useState(false); - //const actionsDisabled = useMemo(() => !hasMatchingTest, [hasMatchingTest]); 매칭검사 안했을 시 + const openGate = hasMatchingTest === false && !gateDismissed; return (
@@ -155,7 +153,13 @@ export default function MyPageHome({ + window.open( + "https://open.kakao.com/o/sPvvelfi", + "_blank", + "noopener,noreferrer", + ) + } py={11} /> @@ -202,7 +206,7 @@ export default function MyPageHome({ {/* gate modal */} {openGate ? ( setOpenGate(false)} + onClose={() => setGateDismissed(true)} onGoTest={onGoMatchingTest} /> ) : null} diff --git a/app/routes/mypage/components/profileCard/CampaignsSection.tsx b/app/routes/mypage/components/profileCard/CampaignsSection.tsx new file mode 100644 index 00000000..f0ecdaf5 --- /dev/null +++ b/app/routes/mypage/components/profileCard/CampaignsSection.tsx @@ -0,0 +1,214 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import { axiosInstance } from "../../../../api/axios"; + +type Collaboration = { + campaignId?: number | null; + proposalId?: number | null; + brandName?: string | null; + thumbnailUrl?: string | null; + title?: string | null; + status?: "NONE" | "REVIEWING" | "MATCHED" | "REJECTED" | null; + startDate?: string | null; + endDate?: string | null; + type?: "APPLIED" | "SENT" | "RECEIVED" | null; +}; + +type CollaborationResponse = { + isSuccess: boolean; + code: string; + message: string; + result: Collaboration[]; +}; + +const PAGE_SIZE = 3; + +const typeLabelMap: Record = { + APPLIED: "지원", + SENT: "보낸 제안", + RECEIVED: "받은 제안", +}; + +const statusLabelMap: Record = { + NONE: "", + REVIEWING: "검토중", + MATCHED: "완료", + REJECTED: "거절", +}; + +const formatDate = (date?: string | null) => { + if (!date) return ""; + const [y, m, d] = date.split("-"); + if (!y || !m || !d) return date; + return `${m}/${d}/${y.slice(2)}`; +}; + +export default function CampaignsSection() { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const [isOpen, setIsOpen] = useState(true); + + useEffect(() => { + let isMounted = true; + const fetchData = async () => { + try { + const res = + await axiosInstance.get("/api/v1/campaigns/collaborations/me"); + if (!isMounted) return; + setItems(res.data?.isSuccess ? res.data.result ?? [] : []); + } catch (error) { + console.error("캠페인 조회 실패:", error); + if (!isMounted) return; + setItems([]); + } + }; + + fetchData(); + return () => { + isMounted = false; + }; + }, []); + + const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages); + + const pageItems = useMemo(() => { + const start = (safePage - 1) * PAGE_SIZE; + return items.slice(start, start + PAGE_SIZE); + }, [items, safePage]); + + const pageNumbers = useMemo(() => { + if (totalPages <= 4) return Array.from({ length: totalPages }, (_, i) => i + 1); + const start = Math.max(1, Math.min(safePage - 1, totalPages - 3)); + return [start, start + 1, start + 2, start + 3]; + }, [safePage, totalPages]); + + return ( +
+
+
진행한 캠페인
+ +
+ + {isOpen ? ( +
+
+ {pageItems.map((item, idx) => { + const typeLabel = item.type ? typeLabelMap[item.type] : ""; + const statusLabel = item.status ? statusLabelMap[item.status] : ""; + const dateLabel = formatDate(item.endDate ?? item.startDate ?? undefined); + const rightLabel = [dateLabel, statusLabel].filter(Boolean).join(" "); + const title = item.title ?? ""; + const brand = item.brandName ? `${item.brandName} - ` : ""; + + const campaignId = item.campaignId ?? null; + return ( +
navigate(`/business/campaign/${campaignId}`) + : undefined + } + role={campaignId ? "button" : undefined} + tabIndex={campaignId ? 0 : -1} + > +
+ {typeLabel} +
+
+ {brand} + {title} +
+
+ {rightLabel} +
+
+ ); + })} +
+ +
+ + + + {pageNumbers.map((n) => ( + + ))} + + + +
+
+ ) : null} +
+ ); +} diff --git a/app/routes/mypage/components/profileCard/MatchingSection.tsx b/app/routes/mypage/components/profileCard/MatchingSection.tsx index 24570fe1..7579640e 100644 --- a/app/routes/mypage/components/profileCard/MatchingSection.tsx +++ b/app/routes/mypage/components/profileCard/MatchingSection.tsx @@ -1,39 +1,49 @@ -import Section from "./CommonSection" +import Section from "./CommonSection"; +import MainIcon from "../../../../assets/MainIcon.svg"; interface MatchingSectionProps { onOpenReMatch: () => void; + nickname?: string | null; + creatorType?: string | null; } -export default function MatchingSection({ onOpenReMatch }: MatchingSectionProps ) { +export default function MatchingSection({ + onOpenReMatch, + nickname, + creatorType, +}: MatchingSectionProps) { + const displayName = nickname || "크리에이터"; + const displayCreatorType = creatorType || "OO한 크리에이터"; return ( -
- -
+
+ {/* 우측 아이콘 */} + Main icon +
+ + ); -} \ No newline at end of file +} diff --git a/app/routes/mypage/components/profileCard/ProfileSection.tsx b/app/routes/mypage/components/profileCard/ProfileSection.tsx index 4e8764de..3b5fb15e 100644 --- a/app/routes/mypage/components/profileCard/ProfileSection.tsx +++ b/app/routes/mypage/components/profileCard/ProfileSection.tsx @@ -1,6 +1,36 @@ -export default function ProfileSection() { +import { useMemo } from "react"; +import mypageDefault from "../../../../assets/mypage-default.svg"; - const profileImage = ""; +type ProfileSectionProps = { + profileImageUrl?: string | null; + nickname?: string | null; + gender?: string | null; + age?: number | null; + contentCategories?: string[] | null; +}; + +export default function ProfileSection({ + profileImageUrl, + nickname, + gender, + age, + contentCategories, +}: ProfileSectionProps) { + const profileImage = profileImageUrl ?? mypageDefault; + const displayName = nickname || "비비"; + const genderLabel = useMemo(() => { + if (gender === "MALE") return "남성"; + if (gender === "FEMALE") return "여성"; + if (!gender) return "성별"; + return gender; + }, [gender]); + const ageLabel = + age != null && Number.isFinite(age) + ? `${age}세` + : "나이"; + const interestLabel = contentCategories?.length + ? contentCategories.join(", ") + : ""; return (
@@ -40,16 +70,23 @@ export default function ProfileSection() { /> -
+
-
비비
-
여성 22세
-
- 관심분야: 뷰티, 패션 +
+ {displayName} +
+
+ {genderLabel} {ageLabel}
+ {interestLabel ? ( +
+ 관심분야: + {interestLabel} +
+ ) : null}
); -} \ No newline at end of file +} diff --git a/app/routes/mypage/components/profileCard/SnsSection.tsx b/app/routes/mypage/components/profileCard/SnsSection.tsx index 22fa7ab4..1aaf8dcf 100644 --- a/app/routes/mypage/components/profileCard/SnsSection.tsx +++ b/app/routes/mypage/components/profileCard/SnsSection.tsx @@ -1,6 +1,11 @@ import Section from "./CommonSection"; -export default function SnsSection() { +type SnsSectionProps = { + snsAccount?: string | null; +}; + +export default function SnsSection({ snsAccount }: SnsSectionProps) { + const displayAccount = snsAccount || "연동된 계정이 없어요"; return (
@@ -9,7 +14,9 @@ export default function SnsSection() {
-
www.instagram.com/vivi
+
+ {displayAccount} +
) -} \ No newline at end of file +} diff --git a/app/routes/mypage/components/profileCard/TraitCard.tsx b/app/routes/mypage/components/profileCard/TraitCard.tsx index 4915fa84..4bb80742 100644 --- a/app/routes/mypage/components/profileCard/TraitCard.tsx +++ b/app/routes/mypage/components/profileCard/TraitCard.tsx @@ -5,12 +5,18 @@ type Trait = { previewLines: { label: string; value: string }[]; }; -export default function TraitCard({ trait, onClick }: { trait: Trait; onClick: () => void }) { +export default function TraitCard({ + trait, + onClick, +}: { + trait: Trait; + onClick: () => void; +}) { return ( - ); -} \ No newline at end of file +} diff --git a/app/routes/mypage/components/profileCard/TraitsSection.tsx b/app/routes/mypage/components/profileCard/TraitsSection.tsx index c1e4981d..e1a1b91e 100644 --- a/app/routes/mypage/components/profileCard/TraitsSection.tsx +++ b/app/routes/mypage/components/profileCard/TraitsSection.tsx @@ -1,28 +1,199 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router"; import Section from "./CommonSection"; import TraitCard from "./TraitCard"; import TraitModal from "./TraitModal"; -import { TRAITS } from "./traitData"; -export default function TraitsSection() { - const [selectedTrait, setSelectedTrait] = useState(null); +import { TRAITS } from "./traitData"; +type FeatureData = { + beautyType?: { + skinType?: string[] | null; + skinBrightness?: string | null; + makeupStyle?: string[] | null; + interestCategories?: string[] | null; + interestFunctions?: string[] | null; + } | null; + fashionType?: { + height?: string | null; + bodyShape?: string | null; + topSize?: string | null; + bottomSize?: string | null; + interestFields?: string[] | null; + interestStyles?: string[] | null; + interestBrands?: string[] | null; + } | null; + contentsType?: { + viewerGender?: string[] | null; + viewerAge?: string[] | null; + avgVideoLength?: string | null; + avgViews?: string | null; + contentFormats?: string[] | null; + contentTones?: string[] | null; + desiredInvolvement?: string[] | null; + desiredUsageScope?: string[] | null; + } | null; +}; + +type TraitsSectionProps = { + feature?: FeatureData | null; +}; + +export default function TraitsSection({ feature }: TraitsSectionProps) { + const navigate = useNavigate(); + const [selectedTrait, setSelectedTrait] = useState<(typeof TRAITS)[0] | null>( + null, + ); + const traits = useMemo(() => { + if (!feature) return TRAITS; + + const beauty = feature.beautyType; + const fashion = feature.fashionType; + const content = feature.contentsType; + + return TRAITS.map((trait) => { + if (trait.id === "beauty") { + return { + ...trait, + previewLines: [ + { label: "피부 타입", value: (beauty?.skinType ?? []).join(", ") }, + { label: "피부 밝기", value: beauty?.skinBrightness ?? "" }, + { + label: "메이크업 \n스타일", + value: (beauty?.makeupStyle ?? []).join(", "), + }, + ], + topSummary: [ + { label: "피부타입", value: (beauty?.skinType ?? []).join(", ") }, + { label: "피부 밝기", value: beauty?.skinBrightness ?? "" }, + { + label: "메이크업 스타일", + value: (beauty?.makeupStyle ?? []).join(", "), + }, + ], + sections: [ + { + title: "관심 카테고리", + items: beauty?.interestCategories ?? [], + }, + { + title: "관심 기능", + items: beauty?.interestFunctions ?? [], + }, + ], + }; + } + + if (trait.id === "fashion") { + return { + ...trait, + previewLines: [ + { label: "키", value: fashion?.height ?? "" }, + { label: "체형", value: fashion?.bodyShape ?? "" }, + { label: "상의", value: fashion?.topSize ?? "" }, + { label: "하의", value: fashion?.bottomSize ?? "" }, + ], + topSummary: [ + { label: "키/몸무게", value: fashion?.height ?? "" }, + { label: "체형", value: fashion?.bodyShape ?? "" }, + { label: "상의 사이즈", value: fashion?.topSize ?? "" }, + { label: "하의 사이즈", value: fashion?.bottomSize ?? "" }, + ], + sections: [ + { + title: "관심 분야", + items: fashion?.interestFields ?? [], + }, + { + title: "관심 스타일", + items: fashion?.interestStyles ?? [], + }, + { + title: "관심 브랜드", + items: fashion?.interestBrands ?? [], + }, + ], + }; + } + + if (trait.id === "content") { + return { + ...trait, + previewLines: [ + { label: "성별", value: (content?.viewerGender ?? []).join(", ") }, + { label: "나이대", value: (content?.viewerAge ?? []).join(", ") }, + { label: "평균 길이", value: content?.avgVideoLength ?? "" }, + { label: "평균 조회수", value: content?.avgViews ?? "" }, + ], + topSummary: [ + { + label: "주 시청자 성별", + value: (content?.viewerGender ?? []).join(", "), + }, + { + label: "주 시청자 나이대", + value: (content?.viewerAge ?? []).join(", "), + }, + { label: "평균 영상 길이", value: content?.avgVideoLength ?? "" }, + { label: "평균 조회수", value: content?.avgViews ?? "" }, + ], + sections: [ + { + title: "콘텐츠 형식", + items: content?.contentFormats ?? [], + }, + { + title: "브랜드 톤", + items: content?.contentTones ?? [], + }, + { + title: "희망 관여도", + items: content?.desiredInvolvement ?? [], + }, + { + title: "희망 활용 범위", + items: content?.desiredUsageScope ?? [], + }, + ], + }; + } + + return trait; + }); + }, [feature]); return ( <>
+ } > -
-
- {TRAITS.map((trait) => ( -
- setSelectedTrait(trait)} /> +
+
+ {traits.map((trait) => ( +
+ setSelectedTrait(trait)} + />
))}
@@ -30,8 +201,11 @@ export default function TraitsSection() {
{selectedTrait && ( - setSelectedTrait(null)} /> + setSelectedTrait(null)} + /> )} ); -} \ No newline at end of file +} diff --git a/app/routes/mypage/edit/edit-content.tsx b/app/routes/mypage/edit/edit-content.tsx index 4190e12a..1967b0a6 100644 --- a/app/routes/mypage/edit/edit-content.tsx +++ b/app/routes/mypage/edit/edit-content.tsx @@ -1,3 +1,193 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import NavigationHeader from "../../../components/common/NavigateHeader"; +import { useHideHeader } from "../../../hooks/useHideHeader"; +import FilterBottomSheet from "../../../components/common/FilterBottomSheet"; + export default function MyPageEdit() { - return
Hello "/mypage/edit"!
+ useHideHeader(true); + const navigate = useNavigate(); + const [name, setName] = useState(""); + const [nickname, setNickname] = useState(""); + const [address, setAddress] = useState(""); + const [addressDetail, setAddressDetail] = useState(""); + + const [isNickSheetOpen, setIsNickSheetOpen] = useState(false); + const [nickDraft, setNickDraft] = useState(""); + const [checkStatus, setCheckStatus] = useState<"idle" | "invalid" | "valid">("idle"); + + const nickHelper = useMemo(() => { + if (checkStatus === "invalid") return "이미 존재하는 닉네임입니다."; + if (checkStatus === "valid") return "사용 가능한 닉네임입니다."; + return "영문, 숫자, 특수 문자 중 2종류 이상을 포함하여 8-20자리로 설정"; + }, [checkStatus]); + + const nickHelperClass = + checkStatus === "invalid" + ? "text-[#FF4D4F]" + : checkStatus === "valid" + ? "text-[#4A4DFF]" + : "text-[#9B9BA1]"; + + const isNickValid = (value: string) => { + if (value.length < 8 || value.length > 20) return false; + const hasLetter = /[a-zA-Z]/.test(value); + const hasNumber = /[0-9]/.test(value); + const hasSpecial = /[^a-zA-Z0-9]/.test(value); + const typeCount = [hasLetter, hasNumber, hasSpecial].filter(Boolean).length; + return typeCount >= 2; + }; + + return ( +
+
+
+ navigate(-1)} /> +
+ +
+
+ {/* 본명 */} +
+
+ 본명 +
+ setName(e.target.value)} + className="w-full h-[52px] rounded-[14px] border border-[#E8E8FB] px-4 text-[15px] text-[#171718] placeholder:text-[#9B9BA1] bg-white" + /> +
+ + {/* 닉네임 */} +
+
+ 닉네임 +
+
+ setNickname(e.target.value)} + className="w-full h-[52px] rounded-[14px] border border-[#E8E8FB] px-4 pr-[88px] text-[15px] text-[#171718] placeholder:text-[#9B9BA1] bg-white" + /> + +
+
+ + {/* 주소 */} +
+
+ 주소 +
+
+ setAddress(e.target.value)} + className="flex-1 h-[52px] rounded-[14px] border border-[#E8E8FB] px-4 text-[15px] text-[#171718] placeholder:text-[#9B9BA1] bg-white" + /> + +
+ setAddressDetail(e.target.value)} + className="w-full h-[52px] rounded-[14px] border border-[#E8E8FB] px-4 text-[15px] text-[#171718] placeholder:text-[#9B9BA1] bg-white" + /> +
+ *협찬품 받을 주소를 입력해주세요. 주소는 매칭된 + 브랜드에게만 공개됩니다. +
+
+
+
+
+ + setIsNickSheetOpen(false)} + className="h-[55%]" + > +
+
+ 닉네임 변경 +
+
+ *{nickHelper} +
+ +
+ { + setNickDraft(e.target.value); + setCheckStatus("idle"); + }} + placeholder="새 닉네임을 입력해주세요" + className="flex-1 h-[48px] rounded-[12px] border border-[#E8E8FB] px-4 text-[14px] text-[#171718] placeholder:text-[#9B9BA1] bg-white" + /> + +
+ + {checkStatus !== "idle" ? ( +
+ *{nickHelper} +
+ ) : null} + +
+ + +
+ +
+ ); } diff --git a/app/routes/mypage/edit/route.tsx b/app/routes/mypage/edit/route.tsx index e69de29b..fdee1d94 100644 --- a/app/routes/mypage/edit/route.tsx +++ b/app/routes/mypage/edit/route.tsx @@ -0,0 +1,5 @@ +import MyPageEdit from "./edit-content"; + +export default function MyPageEditLayout() { + return ; +} diff --git a/app/routes/mypage/likes/likes-content.tsx b/app/routes/mypage/likes/likes-content.tsx index afc61825..be492cb5 100644 --- a/app/routes/mypage/likes/likes-content.tsx +++ b/app/routes/mypage/likes/likes-content.tsx @@ -1,3 +1,401 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import NavigationHeader from "../../../components/common/NavigateHeader"; +import { useHideHeader } from "../../../hooks/useHideHeader"; +import FilterBottomSheet from "../../business/components/FilterBottomSheet"; +import FilterButton from "../../../components/common/FilterButton"; +import { axiosInstance } from "../../../api/axios"; + +type BrandLike = { + id: number; + name: string; + tags: string[]; + matchRate: number; + isLiked: boolean; + logoUrl?: string | null; +}; + +type BrandScrap = { + brandId: number; + brandName: string; + brandLogo?: string | null; + matchingRate: number; + hashtags: string[]; + isScraped: boolean; +}; + +type CampaignScrap = { + campaignId: number; + brandName: string; + campaignTitle: string; + brandLogo?: string | null; + matchingRate: number; + reward: number; + dDay: number; + currentApplicants: number; + totalRecruits: number; + isScraped: boolean; +}; + +type MyScrapResponseDto = { + type: string; + totalCount: number; + brandList?: BrandScrap[]; + campaignList?: CampaignScrap[]; +}; + +type CustomResponseMyScrapResponseDto = { + isSuccess: boolean; + code: string; + message: string; + result: MyScrapResponseDto; +}; + +type CampaignLike = { + id: number; + brand: string; + title: string; + matchRate: number; + reward: number; + dday: string; + applicants: string; + isLiked: boolean; + logoUrl?: string | null; +}; + export default function MyPageLikes() { - return
Hello "/mypage/likes"!
+ useHideHeader(true); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState<"brand" | "campaign">("brand"); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [sortOption, setSortOption] = useState("정렬 필터"); + const [loading, setLoading] = useState(false); + const [brandLikesApi, setBrandLikesApi] = useState([]); + const [campaignLikesApi, setCampaignLikesApi] = useState([]); + + const getSortButtonLabel = () => sortOption; + + const brandLikes = useMemo(() => { + const base = brandLikesApi; + const list = [...base]; + if (sortOption === "매칭률 순") { + return list.sort((a, b) => b.matchRate - a.matchRate); + } + if (sortOption === "인기 순") { + return list.sort((a, b) => b.matchRate - a.matchRate); + } + if (sortOption === "신규 순") { + return list.sort((a, b) => b.id - a.id); + } + return list; + }, [sortOption, brandLikesApi]); + + const campaignLikes = useMemo(() => { + const base = campaignLikesApi; + const list = [...base]; + if (sortOption === "매칭률 순") { + return list.sort((a, b) => b.matchRate - a.matchRate); + } + if (sortOption === "인기 순") { + return list.sort((a, b) => b.matchRate - a.matchRate); + } + if (sortOption === "금액 순") { + return list.sort((a, b) => b.reward - a.reward); + } + if (sortOption === "마감 순") { + return list.sort((a, b) => a.id - b.id); + } + return list; + }, [sortOption, campaignLikesApi]); + + const sortParamMap: Record = { + "정렬 필터": "matchingRate", + "매칭률 순": "matchingRate", + "인기 순": "popularity", + "신규 순": "latest", + "금액 순": "reward", + "마감 순": "dDay", + }; + + useEffect(() => { + let isMounted = true; + const fetchScrap = async () => { + try { + setLoading(true); + const res = await axiosInstance.get( + "/api/v1/users/me/scrap", + { + params: { + type: activeTab === "brand" ? "brand" : "campaign", + sort: sortParamMap[sortOption] ?? "matchingRate", + }, + }, + ); + if (!isMounted) return; + if (!res.data?.isSuccess) { + throw new Error(res.data?.message || "찜 목록 조회 실패"); + } + const result = res.data.result; + if (activeTab === "brand") { + const mapped = + result.brandList?.map((b) => ({ + id: b.brandId, + name: b.brandName, + tags: b.hashtags ?? [], + matchRate: b.matchingRate ?? 0, + isLiked: Boolean(b.isScraped), + logoUrl: b.brandLogo ?? null, + })) ?? []; + setBrandLikesApi(mapped); + } else { + const mapped = + result.campaignList?.map((c) => ({ + id: c.campaignId, + brand: c.brandName, + title: c.campaignTitle, + matchRate: c.matchingRate ?? 0, + reward: c.reward ?? 0, + dday: c.dDay === 0 ? "D-Day" : `D-${c.dDay}`, + applicants: `${c.currentApplicants}/${c.totalRecruits}명`, + isLiked: Boolean(c.isScraped), + logoUrl: c.brandLogo ?? null, + })) ?? []; + setCampaignLikesApi(mapped); + } + } catch (error) { + console.error("찜 목록 조회 실패:", error); + if (!isMounted) return; + if (activeTab === "brand") setBrandLikesApi([]); + if (activeTab === "campaign") setCampaignLikesApi([]); + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + fetchScrap(); + return () => { + isMounted = false; + }; + }, [activeTab, sortOption]); + + const toggleBrandLike = async (brandId: number) => { + setBrandLikesApi((prev) => + prev.map((b) => (b.id === brandId ? { ...b, isLiked: !b.isLiked } : b)), + ); + try { + await axiosInstance.post(`/api/v1/brands/${brandId}/like`); + } catch (error) { + console.error("브랜드 좋아요 토글 실패:", error); + setBrandLikesApi((prev) => + prev.map((b) => (b.id === brandId ? { ...b, isLiked: !b.isLiked } : b)), + ); + } + }; + + const toggleCampaignLike = async (campaignId: number) => { + setCampaignLikesApi((prev) => + prev.map((c) => (c.id === campaignId ? { ...c, isLiked: !c.isLiked } : c)), + ); + try { + await axiosInstance.post(`/api/v1/campaigns/${campaignId}/like`); + } catch (error) { + console.error("캠페인 좋아요 토글 실패:", error); + setCampaignLikesApi((prev) => + prev.map((c) => (c.id === campaignId ? { ...c, isLiked: !c.isLiked } : c)), + ); + } + }; + + return ( +
+
+
+ navigate(-1)} /> +
+ +
+
+ + +
+
+ +
+
+
+
+ {activeTab === "brand" ? "브랜드 리스트" : "캠페인 리스트"} +
+ { + setIsFilterOpen(true); + }} + /> +
+ + {loading ? ( +
+ 로딩 중... +
+ ) : ( +
+ {activeTab === "brand" + ? brandLikes.length > 0 + ? brandLikes.map((brand) => ( +
+
+ {brand.logoUrl ? ( + {brand.name} + ) : ( + brand.name + )} +
+ +
+
+
+ {brand.name} +
+
+
+ 매칭률 {brand.matchRate}% +
+ +
+
+
+ {brand.tags.join(" ")} +
+
+
+ )) + : ( +
+ 찜한 브랜드가 없습니다. +
+ ) + : campaignLikes.length > 0 + ? campaignLikes.map((campaign) => ( +
+
+
+ {campaign.logoUrl ? ( + {campaign.brand} + ) : ( + campaign.brand + )} +
+
+ + {campaign.dday} + + + {campaign.applicants} + +
+
+ +
+
+
+ {campaign.brand} +
+
+
+ 매칭률 {campaign.matchRate}% +
+ +
+
+
+ {campaign.title} +
+
+ 원고료: {campaign.reward.toLocaleString()}원 +
+
+
+ )) + : ( +
+ 찜한 캠페인이 없습니다. +
+ )} +
+ )} +
+
+ + setIsFilterOpen(false)} + onApply={(filter) => setSortOption(filter)} + currentFilter={sortOption} + filters={ + activeTab === "brand" + ? ["정렬 필터", "매칭률 순", "인기 순", "신규 순"] + : ["정렬 필터", "매칭률 순", "인기 순", "금액 순", "마감 순"] + } + title="정렬 필터" + /> +
+
+ ); } diff --git a/app/routes/mypage/likes/route.tsx b/app/routes/mypage/likes/route.tsx index e69de29b..e169fe0d 100644 --- a/app/routes/mypage/likes/route.tsx +++ b/app/routes/mypage/likes/route.tsx @@ -0,0 +1,5 @@ +import MyPageLikes from "./likes-content"; + +export default function MyPageLikesLayout() { + return ; +} diff --git a/app/routes/mypage/mypage/mypage-content.tsx b/app/routes/mypage/mypage/mypage-content.tsx index e5422d87..03a62b01 100644 --- a/app/routes/mypage/mypage/mypage-content.tsx +++ b/app/routes/mypage/mypage/mypage-content.tsx @@ -1,13 +1,15 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router"; import MyPageHome from "../components/MyPageHome"; import { useAuthStore } from "../../../stores/auth-store"; import { getMyPage } from "../api/mypage"; +import mypageDefault from "../../../assets/mypage-default.svg"; export default function MyPageContent() { const navigate = useNavigate(); const me = useAuthStore((s) => s.me); const setMe = useAuthStore((s) => s.setMe); + const [loaded, setLoaded] = useState(false); useEffect(() => { let isMounted = true; @@ -28,8 +30,10 @@ export default function MyPageContent() { avatarUrl: result.profileImageUrl ?? undefined, matchingTestDone: Boolean(result.hasMatchingTest), }); + setLoaded(true); } catch (error) { console.error("마이페이지 정보 조회 실패:", error); + setLoaded(true); } }; @@ -40,7 +44,7 @@ export default function MyPageContent() { }; }, [setMe]); - const hasMatchingTest = Boolean(me?.matchingTestDone); + const hasMatchingTest = loaded ? Boolean(me?.matchingTestDone) : null; return ( navigate("/matching/test/step1")} onOpenProfileCard={() => navigate( "/mypage/profileCard")} onOpenLikes={() => navigate( "/mypage/likes")} onOpenEditProfile={() => navigate("/mypage/edit")} - onOpenNotifications={() => navigate("/mypage/notifications")} - onOpenInquiry={() => navigate("/mypage/inquiry")} + onOpenNotifications={() => navigate("/mypage/notification")} onOpenTerms={() => navigate("/mypage/terms")} // policy/terms onOpenPrivacy={() => navigate("/mypage/privacy")} // policy/privacy onLogout={() => { diff --git a/app/routes/mypage/notification/index.tsx b/app/routes/mypage/notification/index.tsx new file mode 100644 index 00000000..5c8e461b --- /dev/null +++ b/app/routes/mypage/notification/index.tsx @@ -0,0 +1,5 @@ +import MyPageNotifications from "./notifications-content"; + +export default function NotificationIndex() { + return ; +} diff --git a/app/routes/mypage/notification/marketing/marketing-content.tsx b/app/routes/mypage/notification/marketing/marketing-content.tsx new file mode 100644 index 00000000..ac32668b --- /dev/null +++ b/app/routes/mypage/notification/marketing/marketing-content.tsx @@ -0,0 +1,67 @@ +import { useNavigate } from "react-router"; +import NavigationHeader from "../../../../components/common/NavigateHeader"; +import { useHideHeader } from "../../../../hooks/useHideHeader"; + +const ROWS = [ + { + title: "수집 및 이용 목적", + desc: "각종 이벤트, 유저 혜택, 행사 등의 안내 및 이를 기반한 마케팅 활용", + }, + { + title: "수집항목", + desc: + "이름, 성별, 주소, 나이, 쇼셜로그인 계정(카카오톡, 네이버), 소셜미디어 링크(인스타), " + + "서비스 이용 내역, 광고성 정보 수신 채널 (APP PUSH, 이메일)", + }, + { + title: "보유 및 이용 기간", + desc: + "회원탈퇴후 30일까지 또는 해당 서비스 동의 철회 시까지 " + + "회원은 본 서비스 이용 동의에 대한 거부를 할 수 있으며, " + + "미동의 시 본 서비스에 대한 혜택을 받으실 수 없습니다.", + }, +]; + +export default function MarketingDetail() { + useHideHeader(true); + const navigate = useNavigate(); + + return ( +
+
+
+ navigate(-1)} + /> +
+ +
+
+
+ {ROWS.map((row, idx) => ( +
+
+ {row.title} +
+
+ {row.desc} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/app/routes/mypage/notification/marketing/route.tsx b/app/routes/mypage/notification/marketing/route.tsx new file mode 100644 index 00000000..a3105505 --- /dev/null +++ b/app/routes/mypage/notification/marketing/route.tsx @@ -0,0 +1,5 @@ +import MarketingDetail from "./marketing-content"; + +export default function MarketingDetailLayout() { + return ; +} diff --git a/app/routes/mypage/notification/notifications-content.tsx b/app/routes/mypage/notification/notifications-content.tsx index df821d34..73a9abba 100644 --- a/app/routes/mypage/notification/notifications-content.tsx +++ b/app/routes/mypage/notification/notifications-content.tsx @@ -1,3 +1,126 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import NavigationHeader from "../../../components/common/NavigateHeader"; +import { useHideHeader } from "../../../hooks/useHideHeader"; + export default function MyPageNotifications() { - return
Hello "/mypage/notifications"!
+ useHideHeader(true); + const navigate = useNavigate(); + const [benefitPush, setBenefitPush] = useState(true); + const [appPush, setAppPush] = useState(true); + const [emailPush, setEmailPush] = useState(false); + + return ( +
+
+
+ navigate(-1)} /> +
+ +
+
+ {/* 혜택 푸시 */} +
+
+ 혜택 푸시 +
+
+
+
+ 이벤트 혜택 및 광고성 정보 수신 동의 (선택) +
+ +
+ +
+
+
+ + {/* 알림설정 */} +
+
+ 알림설정 +
+ +
+
앱푸시
+ +
+ +
+
이메일
+ +
+ +
+ +
+ *알림을 통해 이벤트, 켐페인, 브랜드에 등에 관한 정보를 드려요 + 알림 허용을 통해 다양한 정보들을 받아 보세요 +
+
+ +
+
+ +
+ +
+
+
+
+ ); } diff --git a/app/routes/mypage/notification/route.tsx b/app/routes/mypage/notification/route.tsx index e69de29b..32f2c282 100644 --- a/app/routes/mypage/notification/route.tsx +++ b/app/routes/mypage/notification/route.tsx @@ -0,0 +1,5 @@ +import { Outlet } from "react-router"; + +export default function NotificationsLayout() { + return ; +} diff --git a/app/routes/mypage/privacy/privacy-content.tsx b/app/routes/mypage/privacy/privacy-content.tsx index 8923a904..cade4631 100644 --- a/app/routes/mypage/privacy/privacy-content.tsx +++ b/app/routes/mypage/privacy/privacy-content.tsx @@ -1,3 +1,67 @@ +import { useNavigate } from "react-router"; +import NavigationHeader from "../../../components/common/NavigateHeader"; +import { useHideHeader } from "../../../hooks/useHideHeader"; + +const PRIVACY = [ + { + title: "제 1 조 (목적)", + body: + "본 개인정보처리방침은 리얼매치(이하 \"회사\")가 운영하는 인터넷 사이트 및 모바일 " + + "애플리케이션에서 제공하는 매칭 서비스(이하 \"서비스\")와 관련하여, 회사가 개인정보를 " + + "어떻게 수집·이용·보관·파기하는지에 관한 사항을 안내함을 목적으로 합니다.", + }, + { + title: "제 2 조 (수집 항목)", + body: + "회사는 서비스 제공을 위해 다음과 같은 개인정보를 수집할 수 있습니다.\n" + + "1. 이름, 닉네임, 성별, 나이\n" + + "2. 주소, 연락처, 이메일\n" + + "3. 소셜 로그인 계정 정보(카카오톡, 네이버)\n" + + "4. 서비스 이용 내역, 접속 로그, 쿠키\n" + + "5. 광고성 정보 수신 채널(APP PUSH, 이메일)", + }, + { + title: "제 3 조 (보유 및 이용 기간)", + body: + "회사는 회원 탈퇴 시 지체 없이 개인정보를 파기합니다. 단, 관계 법령에 따라 일정 기간 보관이 " + + "필요한 경우 해당 기간 동안 보관할 수 있습니다.\n" + + "회원은 언제든지 동의를 철회할 수 있으며, 동의 철회 시 일부 서비스 이용이 제한될 수 있습니다.", + }, +]; + export default function MyPagePrivacy() { - return
Hello "/mypage/privacy"!
+ useHideHeader(true); + const navigate = useNavigate(); + + return ( +
+
+
+ navigate(-1)} /> +
+ +
+
+
+ 개인정보 처리방침 +
+ + {PRIVACY.map((section) => ( +
+
+ {section.title} +
+
+ {section.body} +
+
+ ))} +
+
+
+
+ ); } diff --git a/app/routes/mypage/privacy/route.tsx b/app/routes/mypage/privacy/route.tsx index e69de29b..4a5e2eff 100644 --- a/app/routes/mypage/privacy/route.tsx +++ b/app/routes/mypage/privacy/route.tsx @@ -0,0 +1,5 @@ +import MyPagePrivacy from "./privacy-content"; + +export default function PrivacyLayout() { + return ; +} diff --git a/app/routes/mypage/profileCard/profileCard-content.tsx b/app/routes/mypage/profileCard/profileCard-content.tsx index 83ec4c31..77815e43 100644 --- a/app/routes/mypage/profileCard/profileCard-content.tsx +++ b/app/routes/mypage/profileCard/profileCard-content.tsx @@ -1,12 +1,14 @@ import NavigationHeader from "../../../components/common/NavigateHeader"; import ConfirmModal from "../components/mypage/ConfirmModal"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router"; import { useHideHeader } from "../../../hooks/useHideHeader"; import ProfileSection from "../components/profileCard/ProfileSection"; import SnsSection from "../components/profileCard/SnsSection"; import MatchingSection from "../components/profileCard/MatchingSection"; import TraitsSection from "../components/profileCard/TraitsSection"; +import CampaignsSection from "../components/profileCard/CampaignsSection"; +import { axiosInstance } from "../../../api/axios"; export default function ProfileCard() { @@ -14,6 +16,10 @@ export default function ProfileCard() { const [openReMatchModal, setOpenReMatchModal] = useState(false); const navigate = useNavigate(); + const [profileCard, setProfileCard] = useState( + null, + ); + const [feature, setFeature] = useState(null); const onOpenReMatch = () => { setOpenReMatchModal(true); @@ -28,6 +34,38 @@ export default function ProfileCard() { navigate("/matching/test/step1") } + useEffect(() => { + let isMounted = true; + + const fetchData = async () => { + try { + const [profileRes, featureRes] = await Promise.all([ + axiosInstance.get("/api/v1/users/me/profile-card"), + axiosInstance.get("/api/v1/users/me/feature"), + ]); + + if (!isMounted) return; + + setProfileCard(profileRes.data?.isSuccess ? profileRes.data.result : null); + setFeature(featureRes.data?.isSuccess ? featureRes.data.result : null); + } catch (error) { + console.error("프로필 카드 조회 실패:", error); + if (!isMounted) return; + setProfileCard(null); + setFeature(null); + } + }; + + fetchData(); + + return () => { + isMounted = false; + }; + }, []); + + const nickname = profileCard?.nickname ?? null; + const creatorType = profileCard?.matchingResult?.creatorType ?? null; + return (
@@ -41,15 +79,26 @@ export default function ProfileCard() {
- +
-
+
- +
- - + + +
@@ -85,4 +134,59 @@ export default function ProfileCard() {
); -} \ No newline at end of file +} + +type ProfileCardResult = { + nickname?: string; + profileImageUrl?: string | null; + gender?: string; + age?: number | null; + snsAccount?: string | null; + contentCategories?: string[] | null; + matchingResult?: { + creatorType?: string | null; + } | null; +}; + +type ProfileCardResponse = { + isSuccess: boolean; + code: string; + message: string; + result: ProfileCardResult; +}; + +type FeatureResult = { + beautyType?: { + skinType?: string[] | null; + skinBrightness?: string | null; + makeupStyle?: string[] | null; + interestCategories?: string[] | null; + interestFunctions?: string[] | null; + } | null; + fashionType?: { + height?: string | null; + bodyShape?: string | null; + topSize?: string | null; + bottomSize?: string | null; + interestFields?: string[] | null; + interestStyles?: string[] | null; + interestBrands?: string[] | null; + } | null; + contentsType?: { + viewerGender?: string[] | null; + viewerAge?: string[] | null; + avgVideoLength?: string | null; + avgViews?: string | null; + contentFormats?: string[] | null; + contentTones?: string[] | null; + desiredInvolvement?: string[] | null; + desiredUsageScope?: string[] | null; + } | null; +}; + +type FeatureResponse = { + isSuccess: boolean; + code: string; + message: string; + result: FeatureResult; +}; diff --git a/app/routes/mypage/terms/route.tsx b/app/routes/mypage/terms/route.tsx index e69de29b..e7146b33 100644 --- a/app/routes/mypage/terms/route.tsx +++ b/app/routes/mypage/terms/route.tsx @@ -0,0 +1,5 @@ +import MyPageTerms from "./terms-content"; + +export default function TermsLayout() { + return ; +} diff --git a/app/routes/mypage/terms/terms-content.tsx b/app/routes/mypage/terms/terms-content.tsx index 18a00db7..73d6a66e 100644 --- a/app/routes/mypage/terms/terms-content.tsx +++ b/app/routes/mypage/terms/terms-content.tsx @@ -1,3 +1,60 @@ +import { useNavigate } from "react-router"; +import NavigationHeader from "../../../components/common/NavigateHeader"; +import { useHideHeader } from "../../../hooks/useHideHeader"; + +const TERMS = [ + { + title: "제 1 조 (목적)", + body: + "본 이용약관은 리얼매치(이하 \"회사\")가 운영하는 인터넷 사이트 및 모바일 " + + "애플리케이션에서 제공하는 매칭 서비스(이하 \"서비스\")와 관련하여, 회사와 이용 고객(또는 \"회원\")간의 " + + "권리, 의무 및 책임사항, 회사와 회원 간의 서비스 이용조건 및 절차를 규정함을 목적으로 합니다.", + }, + { + title: "제 2 조 (용어의 정의)", + body: + "본 약관에서 사용하는 용어의 정의는 다음과 같습니다.\n" + + "1. \"서비스\"란 \"회원\"이 컴퓨터, 휴대용 단말기 등 각종 유·무선 또는 프로그램을 통해 이용할 수 있도록 " + + "\"회사\"가 제공하는 모든 \"서비스\"를 의미합니다.\n" + + "2. \"회원\"이란 \"회사\"에 개인정보를 제공하여 회원등록을 한 자로서, 테이블링이 제공하는 \"서비스\"를 이용하는 사용자를 말합니다.\n" + + "3. \"비회원\"이란 회원가입 없이 \"회사\"가 제공하는 \"서비스\"를 이용하는 자를 말합니다.\n" + + "4. \"이용자\"란 \"서비스\"를 이용하는 자를 말하며, 회원과 비회원을 모두 포함합니다.\n" + + "5. \"게시물\"이란 \"회원\"이 \"서비스\"를 이용함에 있어 \"서비스\"상에 게시한 부호·문자·음성·음향 형태의 글, 사진, 동영상 및 각종 파일과 링크 등을 의미합니다.\n" + + "6. 리얼매치가 발행/관리하는...", + }, +]; + export default function MyPageTerms() { - return
Hello "/mypage/terms"!
+ useHideHeader(true); + const navigate = useNavigate(); + + return ( +
+
+
+ navigate(-1)} /> +
+ +
+
+
약관
+ + {TERMS.map((section) => ( +
+
+ {section.title} +
+
+ {section.body} +
+
+ ))} +
+
+
+
+ ); } diff --git a/app/routes/mypage/traits/route.tsx b/app/routes/mypage/traits/route.tsx new file mode 100644 index 00000000..12d34d01 --- /dev/null +++ b/app/routes/mypage/traits/route.tsx @@ -0,0 +1,5 @@ +import TraitsPage from "./traits-content"; + +export default function TraitsPageLayout() { + return ; +} diff --git a/app/routes/mypage/traits/traits-content.tsx b/app/routes/mypage/traits/traits-content.tsx new file mode 100644 index 00000000..246e1eeb --- /dev/null +++ b/app/routes/mypage/traits/traits-content.tsx @@ -0,0 +1,291 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import NavigationHeader from "../../../components/common/NavigateHeader"; +import { useHideHeader } from "../../../hooks/useHideHeader"; +import { axiosInstance } from "../../../api/axios"; +import { TRAITS } from "../components/profileCard/traitData"; + +type FeatureResult = { + beautyType?: { + skinType?: string[] | null; + skinBrightness?: string | null; + makeupStyle?: string[] | null; + interestCategories?: string[] | null; + interestFunctions?: string[] | null; + } | null; + fashionType?: { + height?: string | null; + bodyShape?: string | null; + topSize?: string | null; + bottomSize?: string | null; + interestFields?: string[] | null; + interestStyles?: string[] | null; + interestBrands?: string[] | null; + } | null; + contentsType?: { + viewerGender?: string[] | null; + viewerAge?: string[] | null; + avgVideoLength?: string | null; + avgViews?: string | null; + contentFormats?: string[] | null; + contentTones?: string[] | null; + desiredInvolvement?: string[] | null; + desiredUsageScope?: string[] | null; + } | null; +}; + +type FeatureResponse = { + isSuccess: boolean; + code: string; + message: string; + result: FeatureResult; +}; + +export default function TraitsPage() { + useHideHeader(true); + const navigate = useNavigate(); + const [feature, setFeature] = useState(null); + + useEffect(() => { + let isMounted = true; + + const fetchData = async () => { + try { + const featureRes = await axiosInstance.get( + "/api/v1/users/me/feature", + ); + if (!isMounted) return; + setFeature(featureRes.data?.isSuccess ? featureRes.data.result : null); + } catch (error) { + console.error("특성 조회 실패:", error); + if (!isMounted) return; + setFeature(null); + } + }; + + fetchData(); + + return () => { + isMounted = false; + }; + }, []); + + const traits = useMemo(() => { + if (!feature) return TRAITS; + + const beauty = feature.beautyType; + const fashion = feature.fashionType; + const content = feature.contentsType; + + return TRAITS.map((trait) => { + if (trait.id === "beauty") { + return { + ...trait, + previewLines: [ + { label: "피부 타입", value: (beauty?.skinType ?? []).join(", ") }, + { label: "피부 밝기", value: beauty?.skinBrightness ?? "" }, + { + label: "메이크업 \n스타일", + value: (beauty?.makeupStyle ?? []).join(", "), + }, + ], + topSummary: [ + { label: "피부타입", value: (beauty?.skinType ?? []).join(", ") }, + { label: "피부 밝기", value: beauty?.skinBrightness ?? "" }, + { + label: "메이크업 스타일", + value: (beauty?.makeupStyle ?? []).join(", "), + }, + ], + sections: [ + { + title: "관심 카테고리", + items: beauty?.interestCategories ?? [], + }, + { + title: "관심 기능", + items: beauty?.interestFunctions ?? [], + }, + ], + }; + } + + if (trait.id === "fashion") { + return { + ...trait, + previewLines: [ + { label: "키", value: fashion?.height ?? "" }, + { label: "체형", value: fashion?.bodyShape ?? "" }, + { label: "상의", value: fashion?.topSize ?? "" }, + { label: "하의", value: fashion?.bottomSize ?? "" }, + ], + topSummary: [ + { label: "키/몸무게", value: fashion?.height ?? "" }, + { label: "체형", value: fashion?.bodyShape ?? "" }, + { label: "상의 사이즈", value: fashion?.topSize ?? "" }, + { label: "하의 사이즈", value: fashion?.bottomSize ?? "" }, + ], + sections: [ + { + title: "관심 분야", + items: fashion?.interestFields ?? [], + }, + { + title: "관심 스타일", + items: fashion?.interestStyles ?? [], + }, + { + title: "관심 브랜드", + items: fashion?.interestBrands ?? [], + }, + ], + }; + } + + if (trait.id === "content") { + return { + ...trait, + previewLines: [ + { label: "성별", value: (content?.viewerGender ?? []).join(", ") }, + { label: "나이대", value: (content?.viewerAge ?? []).join(", ") }, + { label: "평균 길이", value: content?.avgVideoLength ?? "" }, + { label: "평균 조회수", value: content?.avgViews ?? "" }, + ], + topSummary: [ + { + label: "주 시청자 성별", + value: (content?.viewerGender ?? []).join(", "), + }, + { + label: "주 시청자 나이대", + value: (content?.viewerAge ?? []).join(", "), + }, + { label: "평균 영상 길이", value: content?.avgVideoLength ?? "" }, + { label: "평균 조회수", value: content?.avgViews ?? "" }, + ], + sections: [ + { + title: "콘텐츠 형식", + items: content?.contentFormats ?? [], + }, + { + title: "브랜드 톤", + items: content?.contentTones ?? [], + }, + { + title: "희망 관여도", + items: content?.desiredInvolvement ?? [], + }, + { + title: "희망 활용 범위", + items: content?.desiredUsageScope ?? [], + }, + ], + }; + } + + return trait; + }); + }, [feature]); + + return ( +
+
+
+ navigate(-1)} /> +
+ +
+
+ {traits.map((trait) => { + const cols = trait.topSummary.length; + return ( +
+
+
+ {trait.icon("w-[46px] h-[47px]")} +
+
+ {trait.badge} +
+ +
+ +
+
+ {trait.topSummary.map((item, i) => ( +
+
+ {item.label} +
+
+ {item.value} +
+
+ ))} +
+
+ +
+ {trait.sections.map((section, i) => ( +
+
+ {section.title} +
+
+ {section.items.join(", ")} +
+
+ ))} +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/app/routes/rooms/hooks/useAuthScroll.ts b/app/routes/rooms/hooks/useAuthScroll.ts index 6bb15971..72a9d238 100644 --- a/app/routes/rooms/hooks/useAuthScroll.ts +++ b/app/routes/rooms/hooks/useAuthScroll.ts @@ -8,5 +8,5 @@ export function useAutoScroll( const el = listRef.current; if (!el) return; el.scrollTop = el.scrollHeight; - }, deps); + }, [listRef, deps]); } diff --git a/app/stores/auth-store.ts b/app/stores/auth-store.ts index c604e157..8da509a2 100644 --- a/app/stores/auth-store.ts +++ b/app/stores/auth-store.ts @@ -1,5 +1,6 @@ // stores/auth-store.ts import { create } from "zustand"; +import { tokenStorage } from "../lib/token"; export type AuthUser = { // optional로 둠 @@ -33,6 +34,6 @@ export const useAuthStore = create((set) => ({ logout: () => { set({ me: null }); - // localStorage.removeItem("accessToken"); + tokenStorage.clearTokens(); }, }));