From bade118630ef527aea4fe99b341a4e15344478a2 Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 24 Apr 2026 15:39:21 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/MatchingListCard.tsx | 242 +++++++++++++++ .../_components/NoMatchingList.tsx | 47 +++ .../_components/ScreenMatchingList.tsx | 41 +++ .../_components/YesMatchingList.tsx | 132 ++++++++ app/matching-list/_components/dummyData.ts | 282 ++++++++++++++++++ app/matching-list/page.tsx | 28 ++ hooks/useMatchingHistory.ts | 11 +- 7 files changed, 777 insertions(+), 6 deletions(-) create mode 100644 app/matching-list/_components/MatchingListCard.tsx create mode 100644 app/matching-list/_components/NoMatchingList.tsx create mode 100644 app/matching-list/_components/ScreenMatchingList.tsx create mode 100644 app/matching-list/_components/YesMatchingList.tsx create mode 100644 app/matching-list/_components/dummyData.ts create mode 100644 app/matching-list/page.tsx diff --git a/app/matching-list/_components/MatchingListCard.tsx b/app/matching-list/_components/MatchingListCard.tsx new file mode 100644 index 0000000..ca04ee5 --- /dev/null +++ b/app/matching-list/_components/MatchingListCard.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { + MatchingHistoryItem, + MatchingPartner, +} from "@/hooks/useMatchingHistory"; +import Image from "next/image"; +import { Send, Star, ChevronDown } from "lucide-react"; +import React, { useRef, useState } from "react"; + +/* ── 유틸 함수 ── */ +const getAge = (birthDate?: string | null) => { + if (!birthDate) return "??"; + return new Date().getFullYear() - new Date(birthDate).getFullYear() + 1; +}; + +/* ── 태그 컴포넌트 ── */ +const Tag = ({ text }: { text: string }) => ( +
+ + {text} + +
+); + +/* ── 프로필 헤더 (이미지 + 닉네임 + 액션 아이콘) ── */ +const CardHeader = ({ + partner, + isFavorite, + onFavoriteToggle, +}: { + partner: MatchingPartner; + isFavorite: boolean; + onFavoriteToggle?: () => void; +}) => ( +
+ {/* 프로필 이미지 (48x48 container, 44x44 image) */} +
+
+ {`${partner.nickname +
+
+ + {/* 닉네임 */} +
+ 내가 뽑은 사람 + + {partner.nickname || "익명"} + +
+ + {/* 액션 아이콘들 */} +
+ + + +
+
+); + +/* ── 나이 + MBTI + 연락빈도 ── */ +const CardStats = ({ partner }: { partner: MatchingPartner }) => ( +
+
+ 나이 + + {getAge(partner.birthDate)} + +
+
+ MBTI + {partner.mbti} +
+
+ + 연락빈도 + + + {partner.contactFrequency} + +
+
+); + +/* ── 관심사 태그 ── */ +const CardHobbies = ({ partner }: { partner: MatchingPartner }) => ( +
+ 관심사 +
+ {partner.hobbies && partner.hobbies.length > 0 ? ( + partner.hobbies.map((hobby) => ( + + )) + ) : ( + + )} +
+
+); + +/* ── 소셜 ID 표시 ── */ +const SocialIdDisplay = ({ partner }: { partner: MatchingPartner }) => { + if (!partner.socialType || !partner.socialAccountId) return null; + + if (partner.socialType === "KAKAO") { + return ( +
+ kakao + + {partner.socialAccountId} + +
+ ); + } + return ( + + @{partner.socialAccountId} + + ); +}; + +/* ── 메인 매칭 리스트 카드 컴포넌트 ── */ +interface MatchingListCardProps { + item: MatchingHistoryItem; + onFavoriteToggle?: (historyId: number) => void; +} + +const MatchingListCard = ({ + item, + onFavoriteToggle, +}: MatchingListCardProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const touchStartTime = useRef(0); + + const handleCardClick = () => { + const touchDuration = Date.now() - touchStartTime.current; + if (touchDuration < 200) { + setIsExpanded((prev) => !prev); + } + }; + + return ( +
+ {/* 카드 본체 */} +
(touchStartTime.current = Date.now())} + onMouseDown={() => (touchStartTime.current = Date.now())} + onClick={handleCardClick} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsExpanded((prev) => !prev); + } + }} + className="flex w-full cursor-pointer flex-col items-center justify-start rounded-t-[24px] border border-b-0 border-white/30 bg-white/50 px-4 pt-6 pb-4" + > + onFavoriteToggle?.(item.historyId)} + /> +
+ +
+ +
+ + {/* 그라디언트 푸터 */} +
+ + + {/* 펼치기 버튼 */} + +
+
+ ); +}; + +export default MatchingListCard; diff --git a/app/matching-list/_components/NoMatchingList.tsx b/app/matching-list/_components/NoMatchingList.tsx new file mode 100644 index 0000000..9585770 --- /dev/null +++ b/app/matching-list/_components/NoMatchingList.tsx @@ -0,0 +1,47 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { ArrowLeft } from "lucide-react"; + +interface NoMatchingListProps { + nickname?: string; +} + +const NoMatchingList = ({ nickname = "회원" }: NoMatchingListProps) => { + return ( +
+ {/* her image */} +
+ her +
+ + {/* Message */} +

+ 아직 매칭된 상대가 없어요. +
+ 아직 + 732 + 명이 {nickname}님을 기다리고 있어요. +
+ 나와 딱 맞는 이성친구를 만들어봐요! +

+ + {/* button */} + +
+ ); +}; + +export default NoMatchingList; diff --git a/app/matching-list/_components/ScreenMatchingList.tsx b/app/matching-list/_components/ScreenMatchingList.tsx new file mode 100644 index 0000000..f89bcdb --- /dev/null +++ b/app/matching-list/_components/ScreenMatchingList.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { BackButton } from "@/components/ui/BackButton"; +import React from "react"; +import { useMatchingHistory } from "@/hooks/useMatchingHistory"; +import NoMatchingList from "./NoMatchingList"; +import YesMatchingList from "./YesMatchingList"; +import { DUMMY_MATCHING_HISTORY } from "./dummyData"; + +const ScreenMatchingList = () => { + const { data: historyData } = useMatchingHistory(); + + // API 데이터가 있으면 실데이터를, 없거나 빈 배열이면 더미 데이터를 사용 (UI 테스트용) + const history = + historyData?.data.content && historyData.data.content.length > 0 + ? historyData.data.content + : DUMMY_MATCHING_HISTORY; + + return ( +
+ + + 내가 뽑은 상대들을 여기서 확인할 수 있어요. +
+ 용기내서 연락해보세요! +
+ + {history.length === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ); +}; + +export default ScreenMatchingList; diff --git a/app/matching-list/_components/YesMatchingList.tsx b/app/matching-list/_components/YesMatchingList.tsx new file mode 100644 index 0000000..26370b4 --- /dev/null +++ b/app/matching-list/_components/YesMatchingList.tsx @@ -0,0 +1,132 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { MatchingHistoryItem } from "@/hooks/useMatchingHistory"; +import { Search, ArrowUpNarrowWide } from "lucide-react"; +import MatchingListCard from "./MatchingListCard"; + +interface YesMatchingListProps { + history: MatchingHistoryItem[]; +} + +const YesMatchingList = ({ history }: YesMatchingListProps) => { + const [searchQuery, setSearchQuery] = useState(""); + const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); + const [sortOrder, setSortOrder] = useState<"oldest" | "newest">("oldest"); + + const filteredHistory = useMemo(() => { + let result = [...history]; + + // 검색 필터 + if (searchQuery.trim()) { + const query = searchQuery.trim().toLowerCase(); + result = result.filter((item) => { + const p = item.partner; + return ( + p.nickname.toLowerCase().includes(query) || + p.mbti.toLowerCase().includes(query) || + p.major.toLowerCase().includes(query) || + (p.birthDate && String(getAge(p.birthDate)).includes(query)) + ); + }); + } + + // 즐겨찾기 필터 + if (showFavoritesOnly) { + result = result.filter((item) => item.favorite); + } + + // 정렬 + result.sort((a, b) => { + const dateA = new Date(a.matchedAt).getTime(); + const dateB = new Date(b.matchedAt).getTime(); + return sortOrder === "oldest" ? dateA - dateB : dateB - dateA; + }); + + return result; + }, [history, searchQuery, showFavoritesOnly, sortOrder]); + + const toggleSortOrder = () => { + setSortOrder((prev) => (prev === "oldest" ? "newest" : "oldest")); + }; + + return ( +
+ {/* 검색 바 */} +
+ setSearchQuery(e.target.value)} + className="typo-12-500 text-color-text-black placeholder:text-color-gray-400 flex-1 bg-transparent outline-none" + /> + +
+ + {/* 필터 바 */} +
+ {/* 즐겨찾기 필터 */} + + + {/* 정렬 */} + +
+ + {/* 카드 리스트 */} +
+ {filteredHistory.length > 0 ? ( + filteredHistory.map((item) => ( + + )) + ) : ( +
+ + 검색 결과가 없습니다. + +
+ )} +
+
+ ); +}; + +/* ── 유틸 함수 ── */ +const getAge = (birthDate?: string | null) => { + if (!birthDate) return "??"; + return new Date().getFullYear() - new Date(birthDate).getFullYear() + 1; +}; + +export default YesMatchingList; diff --git a/app/matching-list/_components/dummyData.ts b/app/matching-list/_components/dummyData.ts new file mode 100644 index 0000000..735a6ce --- /dev/null +++ b/app/matching-list/_components/dummyData.ts @@ -0,0 +1,282 @@ +import { MatchingHistoryItem } from "@/hooks/useMatchingHistory"; + +export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ + { + historyId: 1, + partner: { + memberId: 1, + email: "user1@test.com", + nickname: "봄날의햇살", + gender: "FEMALE", + birthDate: "2002-03-15", + mbti: "ENFP", + intro: "안녕하세요! 맛집 탐방을 좋아하는 대학생입니다 😊", + profileImageUrl: null, + socialType: "INSTAGRAM", + socialAccountId: "spring_sunshine_", + university: "한국대학교", + major: "경영학과", + contactFrequency: "자주", + hobbies: [ + { category: "CULTURE", name: "카페투어" }, + { category: "TRAVEL", name: "여행" }, + { category: "SPORTS", name: "필라테스" }, + ], + intros: [ + { question: "제 키는", answer: "163cm" }, + { question: "제 음주 습관은", answer: "가끔" }, + ], + }, + favorite: true, + matchedAt: "2026-04-20T10:30:00.000000", + }, + { + historyId: 2, + partner: { + memberId: 2, + email: "user2@test.com", + nickname: "겨울바다", + gender: "FEMALE", + birthDate: "2001-11-22", + mbti: "INFJ", + intro: "조용하지만 깊은 대화를 좋아해요.", + profileImageUrl: null, + socialType: "INSTAGRAM", + socialAccountId: "winter_sea22", + university: "서울대학교", + major: "심리학과", + contactFrequency: "보통", + hobbies: [ + { category: "CULTURE", name: "독서" }, + { category: "MUSIC", name: "피아노" }, + ], + intros: [ + { question: "제 직업은", answer: "대학원생" }, + { question: "제 키는", answer: "158cm" }, + ], + }, + favorite: false, + matchedAt: "2026-04-19T14:20:00.000000", + }, + { + historyId: 3, + partner: { + memberId: 3, + email: "user3@test.com", + nickname: "달빛소녀", + gender: "FEMALE", + birthDate: "2003-07-01", + mbti: "ENTP", + intro: "토론하는 거 좋아합니다! 같이 이야기해요~", + profileImageUrl: null, + socialType: "KAKAO", + socialAccountId: "moonlight_girl", + university: "연세대학교", + major: "컴퓨터공학과", + contactFrequency: "자주", + hobbies: [ + { category: "GAME", name: "보드게임" }, + { category: "DAILY", name: "코딩" }, + { category: "SPORTS", name: "배드민턴" }, + { category: "CULTURE", name: "영화감상" }, + ], + intros: [ + { question: "제 직업은", answer: "개발자" }, + { question: "저는 흡연을", answer: "비흡연" }, + ], + }, + favorite: true, + matchedAt: "2026-04-18T09:15:00.000000", + }, + { + historyId: 4, + partner: { + memberId: 4, + email: "user4@test.com", + nickname: "여름향기", + gender: "FEMALE", + birthDate: "2000-08-10", + mbti: "ISFP", + intro: "그림 그리는 걸 좋아해요 🎨", + profileImageUrl: null, + socialType: "INSTAGRAM", + socialAccountId: "summer_scent__", + university: "고려대학교", + major: "미술학과", + contactFrequency: "적음", + hobbies: [ + { category: "MUSIC", name: "그림" }, + { category: "TRAVEL", name: "사진촬영" }, + ], + intros: [ + { question: "제 키는", answer: "165cm" }, + { question: "제가 좋아하는 음식은", answer: "파스타" }, + ], + }, + favorite: false, + matchedAt: "2026-04-17T16:45:00.000000", + }, + { + historyId: 5, + partner: { + memberId: 5, + email: "user5@test.com", + nickname: "별빛나래", + gender: "FEMALE", + birthDate: "2002-01-28", + mbti: "ESTJ", + intro: "운동 좋아하고 활발한 성격이에요!", + profileImageUrl: null, + socialType: "INSTAGRAM", + socialAccountId: "star_narae", + university: "한양대학교", + major: "체육학과", + contactFrequency: "자주", + hobbies: [ + { category: "SPORTS", name: "헬스" }, + { category: "SPORTS", name: "러닝" }, + { category: "DAILY", name: "요리" }, + ], + intros: [ + { question: "제 음주 습관은", answer: "즐기는 편" }, + { question: "제 키는", answer: "170cm" }, + ], + }, + favorite: false, + matchedAt: "2026-04-16T11:00:00.000000", + }, + { + historyId: 6, + partner: { + memberId: 6, + email: "user6@test.com", + nickname: "꽃잎사이로", + gender: "FEMALE", + birthDate: "2001-04-05", + mbti: "INFP", + intro: "감성적인 대화를 좋아해요 💐", + profileImageUrl: null, + socialType: "INSTAGRAM", + socialAccountId: "flower_between", + university: "이화여자대학교", + major: "국문학과", + contactFrequency: "보통", + hobbies: [ + { category: "CULTURE", name: "시 쓰기" }, + { category: "CULTURE", name: "전시회" }, + ], + intros: [ + { question: "제가 좋아하는 음식은", answer: "한식" }, + { question: "저는 흡연을", answer: "비흡연" }, + ], + }, + favorite: true, + matchedAt: "2026-04-15T08:30:00.000000", + }, + { + historyId: 7, + partner: { + memberId: 7, + email: "user7@test.com", + nickname: "하늘구름", + gender: "FEMALE", + birthDate: "2003-09-12", + mbti: "ESFJ", + intro: null, + profileImageUrl: null, + socialType: null, + socialAccountId: null, + university: "성균관대학교", + major: "의상학과", + contactFrequency: "자주", + hobbies: [ + { category: "DAILY", name: "쇼핑" }, + { category: "CULTURE", name: "뮤지컬" }, + ], + intros: [{ question: "제 키는", answer: "162cm" }], + }, + favorite: false, + matchedAt: "2026-04-14T13:20:00.000000", + }, + { + historyId: 8, + partner: { + memberId: 8, + email: "user8@test.com", + nickname: "파도소리", + gender: "FEMALE", + birthDate: "2000-12-25", + mbti: "INTJ", + intro: "효율적인 만남을 선호합니다.", + profileImageUrl: null, + socialType: "INSTAGRAM", + socialAccountId: "wave_sound25", + university: "KAIST", + major: "전산학부", + contactFrequency: "적음", + hobbies: [ + { category: "GAME", name: "체스" }, + { category: "DAILY", name: "독서" }, + { category: "DAILY", name: "자기계발" }, + ], + intros: [ + { question: "제 직업은", answer: "연구원" }, + { question: "제 음주 습관은", answer: "전혀 안 함" }, + ], + }, + favorite: false, + matchedAt: "2026-04-13T17:50:00.000000", + }, + { + historyId: 9, + partner: { + memberId: 9, + email: "withdrawn_9@deleted.com", + nickname: "탈퇴한 사용자", + gender: "FEMALE", + birthDate: null, + mbti: "ENFP", + intro: null, + profileImageUrl: null, + socialType: null, + socialAccountId: null, + university: "한국대학교", + major: "(알 수 없음)", + contactFrequency: "보통", + hobbies: [{ category: "SPORTS", name: "헬스" }], + intros: [], + }, + favorite: false, + matchedAt: "2026-04-12T10:10:00.000000", + }, + { + historyId: 10, + partner: { + memberId: 10, + email: "user10@test.com", + nickname: "새벽이슬", + gender: "FEMALE", + birthDate: "2002-06-18", + mbti: "ISTP", + intro: "자전거 타고 한강 달리는 거 좋아해요 🚴", + profileImageUrl: null, + socialType: "INSTAGRAM", + socialAccountId: "dawn_dew_18", + university: "중앙대학교", + major: "기계공학과", + contactFrequency: "보통", + hobbies: [ + { category: "SPORTS", name: "자전거" }, + { category: "TRAVEL", name: "캠핑" }, + { category: "DAILY", name: "요리" }, + ], + intros: [ + { question: "제 키는", answer: "167cm" }, + { question: "제가 좋아하는 음식은", answer: "일식" }, + { question: "저는 흡연을", answer: "비흡연" }, + ], + }, + favorite: true, + matchedAt: "2026-04-11T19:00:00.000000", + }, +]; diff --git a/app/matching-list/page.tsx b/app/matching-list/page.tsx new file mode 100644 index 0000000..6b286f4 --- /dev/null +++ b/app/matching-list/page.tsx @@ -0,0 +1,28 @@ +import { serverApi } from "@/lib/server-api"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; +import ScreenMatchingList from "./_components/ScreenMatchingList"; +import { MatchingHistoryResponse } from "@/hooks/useMatchingHistory"; + +export default async function MatchingListPage() { + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: ["matchingHistory"], + queryFn: async () => { + const res = await serverApi.get({ + path: "/api/matching/history", + }); + return res.data; + }, + }); + + return ( + + + + ); +} diff --git a/hooks/useMatchingHistory.ts b/hooks/useMatchingHistory.ts index 479f7e1..eb21829 100644 --- a/hooks/useMatchingHistory.ts +++ b/hooks/useMatchingHistory.ts @@ -13,18 +13,17 @@ export interface MatchingPartner { email: string; nickname: string; gender: Gender; - birthDate: string; + birthDate: string | null; mbti: MBTI; intro: string | null; - profileImageKey: string; + profileImageUrl: string | null; socialType: SocialType | null; socialAccountId: string | null; university: string; major: string; - contactFrequency: ContactFrequency; - hobbies: Hobby[]; - tags: { tag: string }[] | null; - song: string | null; + contactFrequency: string; // User JSON shows "적음", "자주", "보통" in Korean, but the type was ContactFrequency. I'll check ContactFrequency type. + hobbies: { category: string; name: string }[]; + intros: { question: string; answer: string }[]; } export interface MatchingHistoryItem { From b9ac5e340fa5d6c56854cf130e3625ae4f57c4ab Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 24 Apr 2026 16:53:26 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/constants/charge.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/constants/charge.ts b/lib/constants/charge.ts index 5c32990..4fd9020 100644 --- a/lib/constants/charge.ts +++ b/lib/constants/charge.ts @@ -104,12 +104,13 @@ export const SHOP_ITEMS_API: ShopItemAPI[] = [ ]; /* ── 이용안내 및 사업자 정보 ── */ -export const USAGE_INFO = `충전된 포인트의 소멸시효 기한은 충전 후 5년입니다. -1 포인트는 1원입니다. -충전한 포인트로 서비스를 이용할 수 있습니다. -포인트는 이벤트 포인트 먼저 사용되고, 유상 포인트가 사용됩니다. -이벤트 포인트는 유효기한이 임박한 순으로 먼저 사용됩니다. -유효기간은 포인트 충전내역을 통해 확인하실 수 있습니다.`; +export const USAGE_INFO = `* 충전된 포인트의 소멸시효 기한은 서비스 종료일까지 입니다. +* 각 상품은 아이템 개별 구매 또는 번들 상품 형태로 제공됩니다. +* 매칭 1회 진행 시 뽑기권 1장이 차감됩니다. +* 추가 옵션 선택 시 옵션권이 각각 차감되며, 풀옵션 적용 시 최대 옵션권 3장이 사용될 수 있습니다. +* 사용하지 못한 아이템이 남아있더라도 매칭 횟수 제한으로 인해 이용이 제한될 수 있습니다. +* 매칭 이용제한(30회)으로 인해 사용하지 못한 아이템은 환불이 가능합니다. +* 일부 사용된 상품(번들 포함)은 사용된 비율에 따라 환불금액이 산정됩니다.`; export const NOTICE_INFO = "결제 혹은 환불에 필요한 유의사항을 적습니다."; From 41c26e7cd52e0ef998f5241c927873654b095fd1 Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 24 Apr 2026 17:02:05 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=EC=9D=BC=EB=8B=A8=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main/_components/ScreenMainPage.tsx | 14 +++++++--- app/matching-list/_components/dummyData.ts | 30 ++++++++++++++++++++++ hooks/useMatchingHistory.ts | 5 +++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/app/main/_components/ScreenMainPage.tsx b/app/main/_components/ScreenMainPage.tsx index b2c34e4..052871b 100644 --- a/app/main/_components/ScreenMainPage.tsx +++ b/app/main/_components/ScreenMainPage.tsx @@ -48,12 +48,20 @@ const ScreenMainPage = () => { // 매칭 히스토리 데이터에서 파트너 정보를 추출하여 프로필 목록 생성 const profileList: ProfileData[] = historyData?.data.content.map(({ partner }) => ({ - ...partner, - // API에서 필드명이 변경되었거나 null로 올 수 있는 필드들만 안전하게 변환 - profileImageUrl: partner.profileImageKey, + memberId: partner.memberId, + nickname: partner.nickname, + gender: partner.gender, + birthDate: partner.birthDate ?? undefined, + mbti: partner.mbti, intro: partner.intro ?? undefined, + profileImageUrl: + partner.profileImageUrl ?? partner.profileImageKey ?? undefined, socialType: partner.socialType ?? undefined, socialAccountId: partner.socialAccountId ?? "", + university: partner.university, + major: partner.major, + contactFrequency: partner.contactFrequency as ContactFrequency, + hobbies: partner.hobbies.map((h) => h.name), tags: partner.tags ?? undefined, song: partner.song ?? undefined, })) || []; diff --git a/app/matching-list/_components/dummyData.ts b/app/matching-list/_components/dummyData.ts index 735a6ce..b0ca7a9 100644 --- a/app/matching-list/_components/dummyData.ts +++ b/app/matching-list/_components/dummyData.ts @@ -12,6 +12,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "ENFP", intro: "안녕하세요! 맛집 탐방을 좋아하는 대학생입니다 😊", profileImageUrl: null, + profileImageKey: null, socialType: "INSTAGRAM", socialAccountId: "spring_sunshine_", university: "한국대학교", @@ -22,6 +23,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "TRAVEL", name: "여행" }, { category: "SPORTS", name: "필라테스" }, ], + tags: [{ tag: "친절함" }, { tag: "활발함" }], + song: "IU - Love Wins All", intros: [ { question: "제 키는", answer: "163cm" }, { question: "제 음주 습관은", answer: "가끔" }, @@ -41,6 +44,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "INFJ", intro: "조용하지만 깊은 대화를 좋아해요.", profileImageUrl: null, + profileImageKey: null, socialType: "INSTAGRAM", socialAccountId: "winter_sea22", university: "서울대학교", @@ -50,6 +54,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "CULTURE", name: "독서" }, { category: "MUSIC", name: "피아노" }, ], + tags: [{ tag: "조용함" }, { tag: "진중함" }], + song: "백예린 - 산책", intros: [ { question: "제 직업은", answer: "대학원생" }, { question: "제 키는", answer: "158cm" }, @@ -69,6 +75,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "ENTP", intro: "토론하는 거 좋아합니다! 같이 이야기해요~", profileImageUrl: null, + profileImageKey: null, socialType: "KAKAO", socialAccountId: "moonlight_girl", university: "연세대학교", @@ -80,6 +87,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "SPORTS", name: "배드민턴" }, { category: "CULTURE", name: "영화감상" }, ], + tags: [{ tag: "재치있음" }, { tag: "논리적" }], + song: "NewJeans - Ditto", intros: [ { question: "제 직업은", answer: "개발자" }, { question: "저는 흡연을", answer: "비흡연" }, @@ -99,6 +108,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "ISFP", intro: "그림 그리는 걸 좋아해요 🎨", profileImageUrl: null, + profileImageKey: null, socialType: "INSTAGRAM", socialAccountId: "summer_scent__", university: "고려대학교", @@ -108,6 +118,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "MUSIC", name: "그림" }, { category: "TRAVEL", name: "사진촬영" }, ], + tags: [{ tag: "예술적" }, { tag: "차분함" }], + song: "10CM - 그라데이션", intros: [ { question: "제 키는", answer: "165cm" }, { question: "제가 좋아하는 음식은", answer: "파스타" }, @@ -127,6 +139,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "ESTJ", intro: "운동 좋아하고 활발한 성격이에요!", profileImageUrl: null, + profileImageKey: null, socialType: "INSTAGRAM", socialAccountId: "star_narae", university: "한양대학교", @@ -137,6 +150,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "SPORTS", name: "러닝" }, { category: "DAILY", name: "요리" }, ], + tags: [{ tag: "에너지넘침" }, { tag: "건강함" }], + song: "LE SSERAFIM - UNFORGIVEN", intros: [ { question: "제 음주 습관은", answer: "즐기는 편" }, { question: "제 키는", answer: "170cm" }, @@ -156,6 +171,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "INFP", intro: "감성적인 대화를 좋아해요 💐", profileImageUrl: null, + profileImageKey: null, socialType: "INSTAGRAM", socialAccountId: "flower_between", university: "이화여자대학교", @@ -165,6 +181,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "CULTURE", name: "시 쓰기" }, { category: "CULTURE", name: "전시회" }, ], + tags: [{ tag: "섬세함" }, { tag: "감성적" }], + song: "성시경 - 거리에서", intros: [ { question: "제가 좋아하는 음식은", answer: "한식" }, { question: "저는 흡연을", answer: "비흡연" }, @@ -184,6 +202,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "ESFJ", intro: null, profileImageUrl: null, + profileImageKey: null, socialType: null, socialAccountId: null, university: "성균관대학교", @@ -193,6 +212,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "DAILY", name: "쇼핑" }, { category: "CULTURE", name: "뮤지컬" }, ], + tags: null, + song: null, intros: [{ question: "제 키는", answer: "162cm" }], }, favorite: false, @@ -209,6 +230,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "INTJ", intro: "효율적인 만남을 선호합니다.", profileImageUrl: null, + profileImageKey: null, socialType: "INSTAGRAM", socialAccountId: "wave_sound25", university: "KAIST", @@ -219,6 +241,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "DAILY", name: "독서" }, { category: "DAILY", name: "자기계발" }, ], + tags: [{ tag: "공부하는" }, { tag: "계획적" }], + song: "Day6 - 한 페이지가 될 수 있게", intros: [ { question: "제 직업은", answer: "연구원" }, { question: "제 음주 습관은", answer: "전혀 안 함" }, @@ -238,12 +262,15 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "ENFP", intro: null, profileImageUrl: null, + profileImageKey: null, socialType: null, socialAccountId: null, university: "한국대학교", major: "(알 수 없음)", contactFrequency: "보통", hobbies: [{ category: "SPORTS", name: "헬스" }], + tags: null, + song: null, intros: [], }, favorite: false, @@ -260,6 +287,7 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ mbti: "ISTP", intro: "자전거 타고 한강 달리는 거 좋아해요 🚴", profileImageUrl: null, + profileImageKey: null, socialType: "INSTAGRAM", socialAccountId: "dawn_dew_18", university: "중앙대학교", @@ -270,6 +298,8 @@ export const DUMMY_MATCHING_HISTORY: MatchingHistoryItem[] = [ { category: "TRAVEL", name: "캠핑" }, { category: "DAILY", name: "요리" }, ], + tags: [{ tag: "쿨함" }, { tag: "독립적" }], + song: "ZICO - Any Song", intros: [ { question: "제 키는", answer: "167cm" }, { question: "제가 좋아하는 음식은", answer: "일식" }, diff --git a/hooks/useMatchingHistory.ts b/hooks/useMatchingHistory.ts index eb21829..55317ed 100644 --- a/hooks/useMatchingHistory.ts +++ b/hooks/useMatchingHistory.ts @@ -17,12 +17,15 @@ export interface MatchingPartner { mbti: MBTI; intro: string | null; profileImageUrl: string | null; + profileImageKey: string | null; socialType: SocialType | null; socialAccountId: string | null; university: string; major: string; - contactFrequency: string; // User JSON shows "적음", "자주", "보통" in Korean, but the type was ContactFrequency. I'll check ContactFrequency type. + contactFrequency: string; hobbies: { category: string; name: string }[]; + tags: { tag: string }[] | null; + song: string | null; intros: { question: string; answer: string }[]; } From 07d2124220d39ab52f385f45b825e1d6eb58ba7d Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 27 Apr 2026 16:24:32 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EB=A7=A4=EC=B9=AD?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20api=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main/_components/ScreenMainPage.tsx | 19 ++- .../_components/MatchingListCard.tsx | 85 ++++++++++---- .../_components/NoMatchingList.tsx | 5 +- .../_components/ScreenMatchingList.tsx | 25 ++-- app/matching-list/_components/SortDrawer.tsx | 88 ++++++++++++++ .../_components/YesMatchingList.tsx | 109 ++++++++++++++---- app/matching-list/page.tsx | 12 +- hooks/useMatchingHistory.ts | 58 ++++++---- lib/utils/date.ts | 4 + 9 files changed, 322 insertions(+), 83 deletions(-) create mode 100644 app/matching-list/_components/SortDrawer.tsx create mode 100644 lib/utils/date.ts diff --git a/app/main/_components/ScreenMainPage.tsx b/app/main/_components/ScreenMainPage.tsx index 052871b..2674500 100644 --- a/app/main/_components/ScreenMainPage.tsx +++ b/app/main/_components/ScreenMainPage.tsx @@ -15,7 +15,10 @@ import { ProfileData, ContactFrequency } from "@/lib/types/profile"; import ChargeRequestWaiting from "./ChargeRequestWaiting"; import NoContactSection from "./NoContactSection"; -import { useMatchingHistory } from "@/hooks/useMatchingHistory"; +import { + useMatchingHistory, + MatchingPartner, +} from "@/hooks/useMatchingHistory"; const ScreenMainPage = () => { // 실제 서비스 시에는 서버에서 받아온 데이터(notice)가 있는지 여부에 따라 렌더링을 결정할 수 있습니다. @@ -46,8 +49,11 @@ const ScreenMainPage = () => { }; // 매칭 히스토리 데이터에서 파트너 정보를 추출하여 프로필 목록 생성 - const profileList: ProfileData[] = - historyData?.data.content.map(({ partner }) => ({ + const allContent = + historyData?.pages.flatMap((page) => page.data.content) ?? []; + + const profileList: ProfileData[] = allContent.map( + ({ partner }: { partner: MatchingPartner }) => ({ memberId: partner.memberId, nickname: partner.nickname, gender: partner.gender, @@ -61,10 +67,13 @@ const ScreenMainPage = () => { university: partner.university, major: partner.major, contactFrequency: partner.contactFrequency as ContactFrequency, - hobbies: partner.hobbies.map((h) => h.name), + hobbies: partner.hobbies.map( + (h: { category: string; name: string }) => h.name, + ), tags: partner.tags ?? undefined, song: partner.song ?? undefined, - })) || []; + }), + ); return (
diff --git a/app/matching-list/_components/MatchingListCard.tsx b/app/matching-list/_components/MatchingListCard.tsx index ca04ee5..390b1d0 100644 --- a/app/matching-list/_components/MatchingListCard.tsx +++ b/app/matching-list/_components/MatchingListCard.tsx @@ -7,12 +7,7 @@ import { import Image from "next/image"; import { Send, Star, ChevronDown } from "lucide-react"; import React, { useRef, useState } from "react"; - -/* ── 유틸 함수 ── */ -const getAge = (birthDate?: string | null) => { - if (!birthDate) return "??"; - return new Date().getFullYear() - new Date(birthDate).getFullYear() + 1; -}; +import { getAge } from "@/lib/utils/date"; /* ── 태그 컴포넌트 ── */ const Tag = ({ text }: { text: string }) => ( @@ -120,18 +115,66 @@ const CardStats = ({ partner }: { partner: MatchingPartner }) => ( ); -/* ── 관심사 태그 ── */ -const CardHobbies = ({ partner }: { partner: MatchingPartner }) => ( -
- 관심사 -
- {partner.hobbies && partner.hobbies.length > 0 ? ( - partner.hobbies.map((hobby) => ( - - )) - ) : ( - - )} +/* ── 확장 가능한 상세 정보 ── */ +const CardDetails = ({ + partner, + isExpanded, +}: { + partner: MatchingPartner; + isExpanded: boolean; +}) => ( +
+ {/* 관심사 */} +
+ 관심사 +
+ {partner.hobbies && partner.hobbies.length > 0 ? ( + partner.hobbies.map((hobby) => ( + + )) + ) : ( + + )} +
+
+ + {/* 장점 */} +
+ 장점 +
+ {partner.tags && partner.tags.length > 0 ? ( + partner.tags.map((t) => ) + ) : ( + + )} +
+
+ + {/* 좋아하는 노래 */} +
+ 좋아하는 노래 + + {partner.song || "아직 없어요!"} + +
+ + {/* 나를 소개하는 한마디 */} +
+ 나를 소개하는 한마디 + + {partner.intro || "잘 부탁드립니다!! 😆"} +
); @@ -194,7 +237,7 @@ const MatchingListCard = ({ setIsExpanded((prev) => !prev); } }} - className="flex w-full cursor-pointer flex-col items-center justify-start rounded-t-[24px] border border-b-0 border-white/30 bg-white/50 px-4 pt-6 pb-4" + className="flex w-full cursor-pointer flex-col items-start justify-start rounded-t-[24px] border border-b-0 border-white/30 bg-white/50 px-4 pt-6 pb-4" >
- +
{/* 그라디언트 푸터 */} @@ -224,7 +267,7 @@ const MatchingListCard = ({ e.stopPropagation(); setIsExpanded((prev) => !prev); }} - className="flex items-center gap-1 rounded-full border border-white/20 bg-[linear-gradient(111.41deg,rgba(255,255,255,0.3)_5.28%,rgba(255,255,255,0.5)_101.41%)] px-[6px] py-[2px] backdrop-blur-[50px]" + className="ml-auto flex items-center gap-1 rounded-full border border-white/20 bg-[linear-gradient(111.41deg,rgba(255,255,255,0.3)_5.28%,rgba(255,255,255,0.5)_101.41%)] px-[6px] py-[2px] backdrop-blur-[50px]" > {isExpanded ? "접기" : "펼치기"} diff --git a/app/matching-list/_components/NoMatchingList.tsx b/app/matching-list/_components/NoMatchingList.tsx index 9585770..8789bec 100644 --- a/app/matching-list/_components/NoMatchingList.tsx +++ b/app/matching-list/_components/NoMatchingList.tsx @@ -3,12 +3,15 @@ import React from "react"; import Image from "next/image"; import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; interface NoMatchingListProps { nickname?: string; } const NoMatchingList = ({ nickname = "회원" }: NoMatchingListProps) => { + const router = useRouter(); + return (
{/* her image */} @@ -34,7 +37,7 @@ const NoMatchingList = ({ nickname = "회원" }: NoMatchingListProps) => { {/* button */} + ); + })} +
+ + {/* 확인 버튼 */} + + + + + + + ); +} diff --git a/app/matching-list/_components/YesMatchingList.tsx b/app/matching-list/_components/YesMatchingList.tsx index 26370b4..e1f4a69 100644 --- a/app/matching-list/_components/YesMatchingList.tsx +++ b/app/matching-list/_components/YesMatchingList.tsx @@ -1,18 +1,40 @@ "use client"; -import React, { useState, useMemo } from "react"; +import React, { + useState, + useMemo, + useRef, + useEffect, + useCallback, +} from "react"; import { MatchingHistoryItem } from "@/hooks/useMatchingHistory"; -import { Search, ArrowUpNarrowWide } from "lucide-react"; +import { Search, ArrowUpNarrowWide, Loader2 } from "lucide-react"; import MatchingListCard from "./MatchingListCard"; +import { getAge } from "@/lib/utils/date"; +import SortDrawer, { SortOrder } from "./SortDrawer"; + +const SORT_LABELS: Record = { + oldest: "오래된 순", + newest: "최신 순", + age: "나이 순", +}; interface YesMatchingListProps { history: MatchingHistoryItem[]; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; } -const YesMatchingList = ({ history }: YesMatchingListProps) => { +const YesMatchingList = ({ + history, + fetchNextPage, + hasNextPage, + isFetchingNextPage, +}: YesMatchingListProps) => { const [searchQuery, setSearchQuery] = useState(""); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); - const [sortOrder, setSortOrder] = useState<"oldest" | "newest">("oldest"); + const [sortOrder, setSortOrder] = useState("newest"); const filteredHistory = useMemo(() => { let result = [...history]; @@ -38,6 +60,16 @@ const YesMatchingList = ({ history }: YesMatchingListProps) => { // 정렬 result.sort((a, b) => { + if (sortOrder === "age") { + const ageA = a.partner.birthDate + ? new Date(a.partner.birthDate).getTime() + : 0; + const ageB = b.partner.birthDate + ? new Date(b.partner.birthDate).getTime() + : 0; + // birthDate가 작을수록(오래될수록) 나이가 많음 → 오름차순 + return ageA - ageB; + } const dateA = new Date(a.matchedAt).getTime(); const dateB = new Date(b.matchedAt).getTime(); return sortOrder === "oldest" ? dateA - dateB : dateB - dateA; @@ -46,9 +78,30 @@ const YesMatchingList = ({ history }: YesMatchingListProps) => { return result; }, [history, searchQuery, showFavoritesOnly, sortOrder]); - const toggleSortOrder = () => { - setSortOrder((prev) => (prev === "oldest" ? "newest" : "oldest")); - }; + /* ── 무한 스크롤: IntersectionObserver ── */ + const sentinelRef = useRef(null); + + const handleIntersect = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [fetchNextPage, hasNextPage, isFetchingNextPage], + ); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver(handleIntersect, { + rootMargin: "200px", + }); + observer.observe(sentinel); + + return () => observer.disconnect(); + }, [handleIntersect]); return (
@@ -92,17 +145,19 @@ const YesMatchingList = ({ history }: YesMatchingListProps) => { 즐겨찾기 - {/* 정렬 */} - + {/* 정렬 - SortDrawer 트리거 */} + + + + {SORT_LABELS[sortOrder]} + + + } + />
{/* 카드 리스트 */} @@ -119,14 +174,20 @@ const YesMatchingList = ({ history }: YesMatchingListProps) => { )} + + {/* 무한 스크롤 sentinel + 로딩 인디케이터 */} +
+ {isFetchingNextPage && ( + + )} + {!hasNextPage && filteredHistory.length > 0 && ( + + 모든 매칭 결과를 불러왔어요. + + )} +
); }; -/* ── 유틸 함수 ── */ -const getAge = (birthDate?: string | null) => { - if (!birthDate) return "??"; - return new Date().getFullYear() - new Date(birthDate).getFullYear() + 1; -}; - export default YesMatchingList; diff --git a/app/matching-list/page.tsx b/app/matching-list/page.tsx index 6b286f4..ac92782 100644 --- a/app/matching-list/page.tsx +++ b/app/matching-list/page.tsx @@ -10,14 +10,22 @@ import { MatchingHistoryResponse } from "@/hooks/useMatchingHistory"; export default async function MatchingListPage() { const queryClient = new QueryClient(); - await queryClient.prefetchQuery({ + await queryClient.prefetchInfiniteQuery({ queryKey: ["matchingHistory"], - queryFn: async () => { + queryFn: async ({ pageParam }) => { const res = await serverApi.get({ path: "/api/matching/history", + params: { + page: pageParam, + size: 30, + sort: "matchedAt,desc", + }, }); return res.data; }, + initialPageParam: 0, + getNextPageParam: (lastPage: MatchingHistoryResponse) => + lastPage.data.hasNext ? lastPage.data.currentPage + 1 : undefined, }); return ( diff --git a/hooks/useMatchingHistory.ts b/hooks/useMatchingHistory.ts index 55317ed..4494ec5 100644 --- a/hooks/useMatchingHistory.ts +++ b/hooks/useMatchingHistory.ts @@ -6,7 +6,7 @@ import { Hobby, ContactFrequency, } from "@/lib/types/profile"; -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; export interface MatchingPartner { memberId: number; @@ -36,34 +36,50 @@ export interface MatchingHistoryItem { matchedAt: string; } +export interface MatchingHistoryPageData { + content: MatchingHistoryItem[]; + currentPage: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; +} + export interface MatchingHistoryResponse { code: string; status: number; message: string; - data: { - content: MatchingHistoryItem[]; - currentPage: number; - size: number; - totalElements: number; - totalPages: number; - hasNext: boolean; - hasPrevious: boolean; - }; + data: MatchingHistoryPageData; } -export const fetchMatchingHistory = - async (): Promise => { - const { data } = await api.get( - "/api/matching/history", - ); - return data; - }; +/** 매칭 히스토리 단일 페이지 조회 */ +export const fetchMatchingHistoryPage = async ( + page: number = 0, + size: number = 30, +): Promise => { + const { data } = await api.get( + "/api/matching/history", + { + params: { + page, + size, + sort: "matchedAt,desc", + }, + }, + ); + return data; +}; +/** React Query useInfiniteQuery 훅 */ export const useMatchingHistory = () => { - return useQuery({ + return useInfiniteQuery({ queryKey: ["matchingHistory"], - queryFn: fetchMatchingHistory, - staleTime: Infinity, // 새로운 매칭이나 즐겨찾기 변경 전까지는 캐시 유지 - gcTime: 1000 * 60 * 60, // 메모리에서 1시간 동안 유지 + queryFn: ({ pageParam }) => fetchMatchingHistoryPage(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => + lastPage.data.hasNext ? lastPage.data.currentPage + 1 : undefined, + staleTime: Infinity, + gcTime: 1000 * 60 * 60, }); }; diff --git a/lib/utils/date.ts b/lib/utils/date.ts new file mode 100644 index 0000000..15d6826 --- /dev/null +++ b/lib/utils/date.ts @@ -0,0 +1,4 @@ +export const getAge = (birthDate?: string | null) => { + if (!birthDate) return "??"; + return new Date().getFullYear() - new Date(birthDate).getFullYear() + 1; +}; From f9b33977f04ee1e83fe9d160bfc6596291d53c53 Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 27 Apr 2026 17:53:24 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/matching-list/_components/ScreenMatchingList.tsx | 5 +++-- hooks/useMatchingHistory.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/matching-list/_components/ScreenMatchingList.tsx b/app/matching-list/_components/ScreenMatchingList.tsx index 5e557a0..379bcfb 100644 --- a/app/matching-list/_components/ScreenMatchingList.tsx +++ b/app/matching-list/_components/ScreenMatchingList.tsx @@ -14,8 +14,9 @@ const ScreenMatchingList = () => { // 모든 페이지의 content를 하나의 배열로 평탄화 const allHistory = useMemo(() => { const apiData = data?.pages.flatMap((page) => page.data.content) ?? []; - // API 데이터가 없으면 더미 데이터 반환 (UI 테스트용) - return apiData.length > 0 ? apiData : DUMMY_MATCHING_HISTORY; + const result = apiData.length > 0 ? apiData : DUMMY_MATCHING_HISTORY; + console.log("Processed allHistory data:", result); + return result; }, [data]); return ( diff --git a/hooks/useMatchingHistory.ts b/hooks/useMatchingHistory.ts index 4494ec5..bda64ea 100644 --- a/hooks/useMatchingHistory.ts +++ b/hooks/useMatchingHistory.ts @@ -68,6 +68,7 @@ export const fetchMatchingHistoryPage = async ( }, }, ); + console.log("Matching History Data:", data); return data; }; From 6343abcf0156d3780b9a3b07e034d86f8f1d6ec3 Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 28 Apr 2026 14:36:28 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/matching-list/_components/YesMatchingList.tsx | 5 ++++- lib/utils/date.ts | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/matching-list/_components/YesMatchingList.tsx b/app/matching-list/_components/YesMatchingList.tsx index e1f4a69..f67c971 100644 --- a/app/matching-list/_components/YesMatchingList.tsx +++ b/app/matching-list/_components/YesMatchingList.tsx @@ -38,6 +38,7 @@ const YesMatchingList = ({ const filteredHistory = useMemo(() => { let result = [...history]; + const currentYear = new Date().getFullYear(); // 검색 필터 if (searchQuery.trim()) { @@ -48,7 +49,8 @@ const YesMatchingList = ({ p.nickname.toLowerCase().includes(query) || p.mbti.toLowerCase().includes(query) || p.major.toLowerCase().includes(query) || - (p.birthDate && String(getAge(p.birthDate)).includes(query)) + (p.birthDate && + String(getAge(p.birthDate, currentYear)).includes(query)) ); }); } @@ -110,6 +112,7 @@ const YesMatchingList = ({ setSearchQuery(e.target.value)} className="typo-12-500 text-color-text-black placeholder:text-color-gray-400 flex-1 bg-transparent outline-none" diff --git a/lib/utils/date.ts b/lib/utils/date.ts index 15d6826..4677a56 100644 --- a/lib/utils/date.ts +++ b/lib/utils/date.ts @@ -1,4 +1,7 @@ -export const getAge = (birthDate?: string | null) => { +export const getAge = ( + birthDate?: string | null, + currentYear: number = new Date().getFullYear(), +) => { if (!birthDate) return "??"; - return new Date().getFullYear() - new Date(birthDate).getFullYear() + 1; + return currentYear - new Date(birthDate).getFullYear() + 1; }; From e30761a1997d1928962a3fb86f5af6eecf37d35f Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 28 Apr 2026 15:07:07 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20SSR=20=EB=A9=94=EC=9D=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main/_components/ScreenMainPage.tsx | 4 ++-- app/main/page.tsx | 10 ++++++++-- hooks/useMatchingHistory.ts | 9 ++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/main/_components/ScreenMainPage.tsx b/app/main/_components/ScreenMainPage.tsx index 2674500..21f30f5 100644 --- a/app/main/_components/ScreenMainPage.tsx +++ b/app/main/_components/ScreenMainPage.tsx @@ -50,7 +50,7 @@ const ScreenMainPage = () => { // 매칭 히스토리 데이터에서 파트너 정보를 추출하여 프로필 목록 생성 const allContent = - historyData?.pages.flatMap((page) => page.data.content) ?? []; + historyData?.pages.flatMap((page) => page?.data?.content || []) ?? []; const profileList: ProfileData[] = allContent.map( ({ partner }: { partner: MatchingPartner }) => ({ @@ -67,7 +67,7 @@ const ScreenMainPage = () => { university: partner.university, major: partner.major, contactFrequency: partner.contactFrequency as ContactFrequency, - hobbies: partner.hobbies.map( + hobbies: (partner.hobbies ?? []).map( (h: { category: string; name: string }) => h.name, ), tags: partner.tags ?? undefined, diff --git a/app/main/page.tsx b/app/main/page.tsx index fdf9b3d..d72343e 100644 --- a/app/main/page.tsx +++ b/app/main/page.tsx @@ -22,14 +22,20 @@ export default async function MainPage() { return res.data; }, }), - queryClient.prefetchQuery({ + queryClient.prefetchInfiniteQuery({ queryKey: ["matchingHistory"], - queryFn: async () => { + queryFn: async ({ pageParam }) => { const res = await serverApi.get({ path: "/api/matching/history", + params: { + page: pageParam as number, + size: 30, + sort: "matchedAt,desc", + }, }); return res.data; }, + initialPageParam: 0, }), ]); diff --git a/hooks/useMatchingHistory.ts b/hooks/useMatchingHistory.ts index bda64ea..4747c4d 100644 --- a/hooks/useMatchingHistory.ts +++ b/hooks/useMatchingHistory.ts @@ -23,7 +23,7 @@ export interface MatchingPartner { university: string; major: string; contactFrequency: string; - hobbies: { category: string; name: string }[]; + hobbies: { category: string; name: string }[] | null; tags: { tag: string }[] | null; song: string | null; intros: { question: string; answer: string }[]; @@ -78,8 +78,11 @@ export const useMatchingHistory = () => { queryKey: ["matchingHistory"], queryFn: ({ pageParam }) => fetchMatchingHistoryPage(pageParam), initialPageParam: 0, - getNextPageParam: (lastPage) => - lastPage.data.hasNext ? lastPage.data.currentPage + 1 : undefined, + getNextPageParam: (lastPage) => { + return lastPage?.data?.hasNext + ? lastPage.data.currentPage + 1 + : undefined; + }, staleTime: Infinity, gcTime: 1000 * 60 * 60, }); From 1386ebd07760cf8cfb39f3f5645500c0e554e439 Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 28 Apr 2026 15:30:23 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=EC=B6=A9=EC=A0=84=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=AA=BD=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/common/charge/QuickBundleCard.tsx | 50 ++++++----- components/common/charge/ShopContent.tsx | 95 ++++++++++++-------- components/common/charge/ShopRows.tsx | 52 +++++------ hooks/useShopProducts.ts | 43 +++++++++ lib/constants/charge.ts | 77 ---------------- 5 files changed, 155 insertions(+), 162 deletions(-) create mode 100644 hooks/useShopProducts.ts diff --git a/components/common/charge/QuickBundleCard.tsx b/components/common/charge/QuickBundleCard.tsx index 5dc8d3e..41f2f57 100644 --- a/components/common/charge/QuickBundleCard.tsx +++ b/components/common/charge/QuickBundleCard.tsx @@ -4,42 +4,48 @@ import React from "react"; import Image from "next/image"; import { ICON_SIZE } from "@/lib/constants/charge"; import ConfirmChargeDrawer from "@/components/common/charge-confirm/ConfirmChargeDrawer"; +import { ShopProduct } from "@/hooks/useShopProducts"; interface QuickBundleCardProps { - title: string; - icon: string; - description: string; - bonus: string; - price: string; - priceValue: number; + product: ShopProduct; } -export default function QuickBundleCard({ - title, - icon, - description, - bonus, - price, - priceValue, -}: QuickBundleCardProps) { +export default function QuickBundleCard({ product }: QuickBundleCardProps) { + // 메인 리워드 설명 + const description = product.rewards + .map((r) => `${r.itemName} ${r.quantity}개`) + .join("+"); + + // 보너스 리워드 텍스트 + const bonusText = + product.bonusRewards.length > 0 + ? product.bonusRewards + .map((r) => `${r.itemName} ${r.quantity}개`) + .join(", ") + " 무료 증정!" + : null; + return (
{title}
- {title} + + {product.name} +
{description} - - {bonus} - + {bonusText && ( + + {bonusText} + + )}
@@ -49,10 +55,10 @@ export default function QuickBundleCard({ type="button" className="typo-16-600 bg-color-flame-700 mt-4 flex h-10 w-full items-center justify-center rounded-[8px] text-white" > - {price} + {product.price.toLocaleString()}원 } - amount={priceValue} + amount={product.price} />
); diff --git a/components/common/charge/ShopContent.tsx b/components/common/charge/ShopContent.tsx index cba821b..ecbebb8 100644 --- a/components/common/charge/ShopContent.tsx +++ b/components/common/charge/ShopContent.tsx @@ -2,67 +2,86 @@ import React from "react"; import { useItems } from "@/hooks/useItems"; +import { useShopProducts, ShopProduct } from "@/hooks/useShopProducts"; import ChargeInventoryCard from "./ChargeInventoryCard"; import QuickBundleCard from "./QuickBundleCard"; import { ShopItemRow } from "./ShopRows"; import { ShopBundleRow } from "./ShopRows"; -import { - QUICK_BUNDLES, - SHOP_ITEMS_API, - USAGE_INFO, - BUSINESS_INFO, -} from "@/lib/constants/charge"; +import { USAGE_INFO, BUSINESS_INFO } from "@/lib/constants/charge"; +import { Loader2 } from "lucide-react"; + +/** rewards의 itemType 종류가 2개 이상이면 번들(빠른 구매) */ +function isBundle(product: ShopProduct): boolean { + const types = new Set(product.rewards.map((r) => r.itemType)); + return types.size >= 2; +} export default function ShopContent() { const { data } = useItems(); + const { data: productsData, isLoading: isProductsLoading } = + useShopProducts(); const ticketCounts = { matching: data?.data.matchingTicketCount ?? 0, option: data?.data.optionTicketCount ?? 0, }; - // 개별 아이템: 리워드가 1개인 경우 - const individualItems = SHOP_ITEMS_API.filter( - (item) => item.rewards.length === 1, - ); + const products = productsData?.data ?? []; - // 번들 아이템: 리워드가 2개 이상인 경우 - const bundleItems = SHOP_ITEMS_API.filter((item) => item.rewards.length >= 2); + // 개별 아이템: rewards의 itemType이 1종류 + const individualItems = products.filter((item) => !isBundle(item)); + + // 번들 아이템: rewards의 itemType이 2종류 이상 → 빠른 구매 + const bundleItems = products.filter((item) => isBundle(item)); return (
{/* ── 보유현황 카드 ── */} - {/* ── 빠른 구매 ── */} -
- 빠른 구매 -
- {QUICK_BUNDLES.map((bundle) => ( - - ))} + {isProductsLoading ? ( +
+
-
+ ) : ( + <> + {/* ── 빠른 구매 ── */} + {bundleItems.length > 0 && ( +
+ 빠른 구매 +
+ {bundleItems.map((item) => ( + + ))} +
+
+ )} - {/* ── 전체 ── */} -
- 전체 -
- {individualItems.map((item) => ( - - ))} -
-
+ {/* ── 전체 ── */} + {individualItems.length > 0 && ( +
+ 전체 +
+ {individualItems.map((item) => ( + + ))} +
+
+ )} - {/* ── 번들 ── */} -
- 번들 -
- {bundleItems.map((item) => ( - - ))} -
-
+ {/* ── 번들 상세 ── */} + {bundleItems.length > 0 && ( +
+ 번들 +
+ {bundleItems.map((item) => ( + + ))} +
+
+ )} + + )} {/* ── 이용안내 ── */}
diff --git a/components/common/charge/ShopRows.tsx b/components/common/charge/ShopRows.tsx index fc09ad7..896da28 100644 --- a/components/common/charge/ShopRows.tsx +++ b/components/common/charge/ShopRows.tsx @@ -1,63 +1,65 @@ import React from "react"; import ConfirmChargeDrawer from "@/components/common/charge-confirm/ConfirmChargeDrawer"; -import { ShopItemAPI } from "@/lib/constants/charge"; +import { ShopProduct } from "@/hooks/useShopProducts"; -/* ── ShopItemRow ── */ +/* ── ShopItemRow (개별 아이템) ── */ export interface ShopItemRowProps { - item: ShopItemAPI; + product: ShopProduct; } -export function ShopItemRow({ item }: ShopItemRowProps) { +export function ShopItemRow({ product }: ShopItemRowProps) { return (
- {item.name} + {product.name} - {item.price.toLocaleString()}원 + {product.price.toLocaleString()}원 } - amount={item.price} + amount={product.price} />
); } -/* ── ShopBundleRow ── */ +/* ── ShopBundleRow (번들 아이템) ── */ export interface ShopBundleRowProps { - item: ShopItemAPI; + product: ShopProduct; } -export function ShopBundleRow({ item }: ShopBundleRowProps) { - // 리워드 목록에서 보너스 항목 제외한 메인 리워드 텍스트 생성 - const mainRewards = item.rewards.filter( - (r) => !r.itemName.includes("보너스"), - ); - const description = mainRewards +export function ShopBundleRow({ product }: ShopBundleRowProps) { + // 메인 리워드 텍스트 + const description = product.rewards .map((r) => `${r.itemName} ${r.quantity}개`) .join("+"); - // 보너스 항목 찾기 - const bonusReward = item.rewards.find((r) => r.itemName.includes("보너스")); + // 보너스 리워드 + const bonusText = + product.bonusRewards.length > 0 + ? product.bonusRewards + .map((r) => `${r.itemName} ${r.quantity}개`) + .join(", ") + " 무료 증정!" + : null; return (
- {item.name} - + + {product.name} + + {description}
- {bonusReward && ( - - {bonusReward.itemName} {bonusReward.quantity}개 무료 증정! - + {bonusText && ( + {bonusText} )}
- {item.price.toLocaleString()}원 + {product.price.toLocaleString()}원 } - amount={item.price} + amount={product.price} />
); diff --git a/hooks/useShopProducts.ts b/hooks/useShopProducts.ts new file mode 100644 index 0000000..f3cd40a --- /dev/null +++ b/hooks/useShopProducts.ts @@ -0,0 +1,43 @@ +import { api } from "@/lib/axios"; +import { useQuery } from "@tanstack/react-query"; + +/* ── API 응답 타입 ── */ +export interface ShopReward { + itemType: "MATCHING_TICKET" | "OPTION_TICKET"; + itemName: string; + quantity: number; +} + +export interface ShopProduct { + id: number; + name: string; + description: string; + price: number; + displayOrder: number; + isActive: boolean; + rewards: ShopReward[]; + bonusRewards: ShopReward[]; +} + +export interface ShopProductsResponse { + code: string; + status: number; + message: string; + data: ShopProduct[]; +} + +/* ── fetcher ── */ +export const fetchShopProducts = async (): Promise => { + const { data } = await api.get("/api/v1/shop/products"); + return data; +}; + +/* ── hook ── */ +export const useShopProducts = () => { + return useQuery({ + queryKey: ["shopProducts"], + queryFn: fetchShopProducts, + staleTime: Infinity, + gcTime: 1000 * 60 * 60, + }); +}; diff --git a/lib/constants/charge.ts b/lib/constants/charge.ts index 4fd9020..8bfdd13 100644 --- a/lib/constants/charge.ts +++ b/lib/constants/charge.ts @@ -26,83 +26,6 @@ export const PURCHASE_LIMITS = { option: { current: 42, max: 60, label: "아이템" }, } as const; -/* ── 빠른 구매 번들 카드 ── */ -export const QUICK_BUNDLES = [ - { - title: "실속 번들", - icon: "/main/coin.png", - description: "뽑기권 5개+옵션권 2개", - bonus: "옵션권 1개 무료 증정!", - price: "5,000원", - priceValue: 5000, - }, - { - title: "슈퍼 번들", - icon: "/main/coin.png", - description: "뽑기권 10개+옵션권 10개", - bonus: "옵션권 5개 무료 증정!", - price: "10,000원", - priceValue: 10000, - }, -] as const; - -/* ── API 응답 데이터 타입 ── */ -export interface Reward { - itemType: "MATCHING_TICKET" | "OPTION_TICKET"; - itemName: string; - quantity: number; -} - -export interface ShopItemAPI { - id: number; - name: string; - price: number; - rewards: Reward[]; -} - -/* ── 전체 아이템 (API 더미 데이터) ── */ -export const SHOP_ITEMS_API: ShopItemAPI[] = [ - { - id: 1, - name: "뽑기권 1개", - price: 1000, - rewards: [{ itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 1 }], - }, - { - id: 2, - name: "옵션권 1개", - price: 200, - rewards: [{ itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 1 }], - }, - { - id: 10, - name: "미니 번들", - price: 2500, - rewards: [ - { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 3 }, - { itemType: "OPTION_TICKET", itemName: "보너스 옵션권", quantity: 1 }, - ], - }, - { - id: 11, - name: "실속 번들", - price: 5000, - rewards: [ - { itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 5 }, - { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 2 }, - ], - }, - { - id: 12, - name: "슈퍼 번들", - price: 10000, - rewards: [ - { itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 10 }, - { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 10 }, - ], - }, -]; - /* ── 이용안내 및 사업자 정보 ── */ export const USAGE_INFO = `* 충전된 포인트의 소멸시효 기한은 서비스 종료일까지 입니다. * 각 상품은 아이템 개별 구매 또는 번들 상품 형태로 제공됩니다. From eba40318fa0888363f3b1ec0753f2d16e7d73e1d Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 28 Apr 2026 15:36:39 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20=EC=B6=A9=EC=A0=84=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/common/charge/QuickBundleCard.tsx | 50 +++++------ components/common/charge/ShopContent.tsx | 95 ++++++++------------ components/common/charge/ShopRows.tsx | 52 ++++++----- hooks/useShopProducts.ts | 4 +- lib/constants/charge.ts | 77 ++++++++++++++++ 5 files changed, 164 insertions(+), 114 deletions(-) diff --git a/components/common/charge/QuickBundleCard.tsx b/components/common/charge/QuickBundleCard.tsx index 41f2f57..5dc8d3e 100644 --- a/components/common/charge/QuickBundleCard.tsx +++ b/components/common/charge/QuickBundleCard.tsx @@ -4,48 +4,42 @@ import React from "react"; import Image from "next/image"; import { ICON_SIZE } from "@/lib/constants/charge"; import ConfirmChargeDrawer from "@/components/common/charge-confirm/ConfirmChargeDrawer"; -import { ShopProduct } from "@/hooks/useShopProducts"; interface QuickBundleCardProps { - product: ShopProduct; + title: string; + icon: string; + description: string; + bonus: string; + price: string; + priceValue: number; } -export default function QuickBundleCard({ product }: QuickBundleCardProps) { - // 메인 리워드 설명 - const description = product.rewards - .map((r) => `${r.itemName} ${r.quantity}개`) - .join("+"); - - // 보너스 리워드 텍스트 - const bonusText = - product.bonusRewards.length > 0 - ? product.bonusRewards - .map((r) => `${r.itemName} ${r.quantity}개`) - .join(", ") + " 무료 증정!" - : null; - +export default function QuickBundleCard({ + title, + icon, + description, + bonus, + price, + priceValue, +}: QuickBundleCardProps) { return (
{product.name}
- - {product.name} - + {title}
{description} - {bonusText && ( - - {bonusText} - - )} + + {bonus} +
@@ -55,10 +49,10 @@ export default function QuickBundleCard({ product }: QuickBundleCardProps) { type="button" className="typo-16-600 bg-color-flame-700 mt-4 flex h-10 w-full items-center justify-center rounded-[8px] text-white" > - {product.price.toLocaleString()}원 + {price} } - amount={product.price} + amount={priceValue} />
); diff --git a/components/common/charge/ShopContent.tsx b/components/common/charge/ShopContent.tsx index ecbebb8..cba821b 100644 --- a/components/common/charge/ShopContent.tsx +++ b/components/common/charge/ShopContent.tsx @@ -2,86 +2,67 @@ import React from "react"; import { useItems } from "@/hooks/useItems"; -import { useShopProducts, ShopProduct } from "@/hooks/useShopProducts"; import ChargeInventoryCard from "./ChargeInventoryCard"; import QuickBundleCard from "./QuickBundleCard"; import { ShopItemRow } from "./ShopRows"; import { ShopBundleRow } from "./ShopRows"; -import { USAGE_INFO, BUSINESS_INFO } from "@/lib/constants/charge"; -import { Loader2 } from "lucide-react"; - -/** rewards의 itemType 종류가 2개 이상이면 번들(빠른 구매) */ -function isBundle(product: ShopProduct): boolean { - const types = new Set(product.rewards.map((r) => r.itemType)); - return types.size >= 2; -} +import { + QUICK_BUNDLES, + SHOP_ITEMS_API, + USAGE_INFO, + BUSINESS_INFO, +} from "@/lib/constants/charge"; export default function ShopContent() { const { data } = useItems(); - const { data: productsData, isLoading: isProductsLoading } = - useShopProducts(); const ticketCounts = { matching: data?.data.matchingTicketCount ?? 0, option: data?.data.optionTicketCount ?? 0, }; - const products = productsData?.data ?? []; - - // 개별 아이템: rewards의 itemType이 1종류 - const individualItems = products.filter((item) => !isBundle(item)); + // 개별 아이템: 리워드가 1개인 경우 + const individualItems = SHOP_ITEMS_API.filter( + (item) => item.rewards.length === 1, + ); - // 번들 아이템: rewards의 itemType이 2종류 이상 → 빠른 구매 - const bundleItems = products.filter((item) => isBundle(item)); + // 번들 아이템: 리워드가 2개 이상인 경우 + const bundleItems = SHOP_ITEMS_API.filter((item) => item.rewards.length >= 2); return (
{/* ── 보유현황 카드 ── */} - {isProductsLoading ? ( -
- + {/* ── 빠른 구매 ── */} +
+ 빠른 구매 +
+ {QUICK_BUNDLES.map((bundle) => ( + + ))}
- ) : ( - <> - {/* ── 빠른 구매 ── */} - {bundleItems.length > 0 && ( -
- 빠른 구매 -
- {bundleItems.map((item) => ( - - ))} -
-
- )} +
- {/* ── 전체 ── */} - {individualItems.length > 0 && ( -
- 전체 -
- {individualItems.map((item) => ( - - ))} -
-
- )} + {/* ── 전체 ── */} +
+ 전체 +
+ {individualItems.map((item) => ( + + ))} +
+
- {/* ── 번들 상세 ── */} - {bundleItems.length > 0 && ( -
- 번들 -
- {bundleItems.map((item) => ( - - ))} -
-
- )} - - )} + {/* ── 번들 ── */} +
+ 번들 +
+ {bundleItems.map((item) => ( + + ))} +
+
{/* ── 이용안내 ── */}
diff --git a/components/common/charge/ShopRows.tsx b/components/common/charge/ShopRows.tsx index 896da28..fc09ad7 100644 --- a/components/common/charge/ShopRows.tsx +++ b/components/common/charge/ShopRows.tsx @@ -1,65 +1,63 @@ import React from "react"; import ConfirmChargeDrawer from "@/components/common/charge-confirm/ConfirmChargeDrawer"; -import { ShopProduct } from "@/hooks/useShopProducts"; +import { ShopItemAPI } from "@/lib/constants/charge"; -/* ── ShopItemRow (개별 아이템) ── */ +/* ── ShopItemRow ── */ export interface ShopItemRowProps { - product: ShopProduct; + item: ShopItemAPI; } -export function ShopItemRow({ product }: ShopItemRowProps) { +export function ShopItemRow({ item }: ShopItemRowProps) { return (
- {product.name} + {item.name} - {product.price.toLocaleString()}원 + {item.price.toLocaleString()}원 } - amount={product.price} + amount={item.price} />
); } -/* ── ShopBundleRow (번들 아이템) ── */ +/* ── ShopBundleRow ── */ export interface ShopBundleRowProps { - product: ShopProduct; + item: ShopItemAPI; } -export function ShopBundleRow({ product }: ShopBundleRowProps) { - // 메인 리워드 텍스트 - const description = product.rewards +export function ShopBundleRow({ item }: ShopBundleRowProps) { + // 리워드 목록에서 보너스 항목 제외한 메인 리워드 텍스트 생성 + const mainRewards = item.rewards.filter( + (r) => !r.itemName.includes("보너스"), + ); + const description = mainRewards .map((r) => `${r.itemName} ${r.quantity}개`) .join("+"); - // 보너스 리워드 - const bonusText = - product.bonusRewards.length > 0 - ? product.bonusRewards - .map((r) => `${r.itemName} ${r.quantity}개`) - .join(", ") + " 무료 증정!" - : null; + // 보너스 항목 찾기 + const bonusReward = item.rewards.find((r) => r.itemName.includes("보너스")); return (
- - {product.name} - - + {item.name} + {description}
- {bonusText && ( - {bonusText} + {bonusReward && ( + + {bonusReward.itemName} {bonusReward.quantity}개 무료 증정! + )}
- {product.price.toLocaleString()}원 + {item.price.toLocaleString()}원 } - amount={product.price} + amount={item.price} />
); diff --git a/hooks/useShopProducts.ts b/hooks/useShopProducts.ts index f3cd40a..ccc7aa8 100644 --- a/hooks/useShopProducts.ts +++ b/hooks/useShopProducts.ts @@ -37,7 +37,7 @@ export const useShopProducts = () => { return useQuery({ queryKey: ["shopProducts"], queryFn: fetchShopProducts, - staleTime: Infinity, - gcTime: 1000 * 60 * 60, + staleTime: 1000 * 60 * 30, // 30분: 이후 백그라운드 갱신 + gcTime: Infinity, // 메모리에서 삭제하지 않음 → 로딩바 없음 }); }; diff --git a/lib/constants/charge.ts b/lib/constants/charge.ts index 8bfdd13..4fd9020 100644 --- a/lib/constants/charge.ts +++ b/lib/constants/charge.ts @@ -26,6 +26,83 @@ export const PURCHASE_LIMITS = { option: { current: 42, max: 60, label: "아이템" }, } as const; +/* ── 빠른 구매 번들 카드 ── */ +export const QUICK_BUNDLES = [ + { + title: "실속 번들", + icon: "/main/coin.png", + description: "뽑기권 5개+옵션권 2개", + bonus: "옵션권 1개 무료 증정!", + price: "5,000원", + priceValue: 5000, + }, + { + title: "슈퍼 번들", + icon: "/main/coin.png", + description: "뽑기권 10개+옵션권 10개", + bonus: "옵션권 5개 무료 증정!", + price: "10,000원", + priceValue: 10000, + }, +] as const; + +/* ── API 응답 데이터 타입 ── */ +export interface Reward { + itemType: "MATCHING_TICKET" | "OPTION_TICKET"; + itemName: string; + quantity: number; +} + +export interface ShopItemAPI { + id: number; + name: string; + price: number; + rewards: Reward[]; +} + +/* ── 전체 아이템 (API 더미 데이터) ── */ +export const SHOP_ITEMS_API: ShopItemAPI[] = [ + { + id: 1, + name: "뽑기권 1개", + price: 1000, + rewards: [{ itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 1 }], + }, + { + id: 2, + name: "옵션권 1개", + price: 200, + rewards: [{ itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 1 }], + }, + { + id: 10, + name: "미니 번들", + price: 2500, + rewards: [ + { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 3 }, + { itemType: "OPTION_TICKET", itemName: "보너스 옵션권", quantity: 1 }, + ], + }, + { + id: 11, + name: "실속 번들", + price: 5000, + rewards: [ + { itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 5 }, + { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 2 }, + ], + }, + { + id: 12, + name: "슈퍼 번들", + price: 10000, + rewards: [ + { itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 10 }, + { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 10 }, + ], + }, +]; + /* ── 이용안내 및 사업자 정보 ── */ export const USAGE_INFO = `* 충전된 포인트의 소멸시효 기한은 서비스 종료일까지 입니다. * 각 상품은 아이템 개별 구매 또는 번들 상품 형태로 제공됩니다. From 3392be831136859a0be9276f662add42ad5cd63f Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 28 Apr 2026 15:53:05 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20=EB=A7=A4=EC=B9=AD=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=EC=8B=9C,=20=EC=B6=A9=EC=A0=84=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=8B=9C=20=20=EC=BF=BC=EB=A6=AC=20=EB=AC=B4=ED=9A=A8=ED=99=94?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=E3=84=B9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../charge-confirm/ConfirmChargeDrawer.tsx | 2 + .../common/charge/ChargeHistoryContent.tsx | 9 +- .../common/charge/ChargeInventoryCard.tsx | 16 +-- components/common/charge/QuickBundleCard.tsx | 50 +++++---- components/common/charge/ShopContent.tsx | 102 ++++++++++-------- components/common/charge/ShopRows.tsx | 52 ++++----- hooks/useItems.ts | 2 +- hooks/useMatching.ts | 6 +- lib/constants/charge.ts | 77 ------------- 9 files changed, 129 insertions(+), 187 deletions(-) diff --git a/components/common/charge-confirm/ConfirmChargeDrawer.tsx b/components/common/charge-confirm/ConfirmChargeDrawer.tsx index b01e59e..593f88e 100644 --- a/components/common/charge-confirm/ConfirmChargeDrawer.tsx +++ b/components/common/charge-confirm/ConfirmChargeDrawer.tsx @@ -12,6 +12,7 @@ import { DrawerTrigger, } from "@/components/ui/drawer"; +import { useQueryClient } from "@tanstack/react-query"; import Button from "@/components/ui/Button"; import { BANK_INFO } from "@/lib/constants/charge"; @@ -31,6 +32,7 @@ export default function ConfirmChargeDrawer({ amount, depositorName = "천승환", }: ConfirmChargeDrawerProps) { + const queryClient = useQueryClient(); const [agreed, setAgreed] = React.useState(false); const [name, setName] = React.useState(depositorName); const [isEditingName, setIsEditingName] = React.useState(false); diff --git a/components/common/charge/ChargeHistoryContent.tsx b/components/common/charge/ChargeHistoryContent.tsx index 821fb89..86ad940 100644 --- a/components/common/charge/ChargeHistoryContent.tsx +++ b/components/common/charge/ChargeHistoryContent.tsx @@ -11,17 +11,10 @@ import { } from "@/lib/constants/charge"; export default function ChargeHistoryContent() { - const { data } = useItems(); - - const ticketCounts = { - matching: data?.data.matchingTicketCount ?? 0, - option: data?.data.optionTicketCount ?? 0, - }; - return (
{/* ── 보유현황 카드 ── */} - + {/* ── 충전 내역 리스트 ── */}
diff --git a/components/common/charge/ChargeInventoryCard.tsx b/components/common/charge/ChargeInventoryCard.tsx index 3597472..4a3fa75 100644 --- a/components/common/charge/ChargeInventoryCard.tsx +++ b/components/common/charge/ChargeInventoryCard.tsx @@ -8,16 +8,16 @@ import { PURCHASE_LIMITS, } from "@/lib/constants/charge"; -interface ChargeInventoryCardProps { - ticketCounts: { - matching: number; - option: number; +import { useItems } from "@/hooks/useItems"; + +export default function ChargeInventoryCard() { + const { data, isLoading } = useItems(); + + const ticketCounts = { + matching: data?.data.matchingTicketCount ?? 0, + option: data?.data.optionTicketCount ?? 0, }; -} -export default function ChargeInventoryCard({ - ticketCounts, -}: ChargeInventoryCardProps) { return (
{/* 보유 수량 */} diff --git a/components/common/charge/QuickBundleCard.tsx b/components/common/charge/QuickBundleCard.tsx index 5dc8d3e..41f2f57 100644 --- a/components/common/charge/QuickBundleCard.tsx +++ b/components/common/charge/QuickBundleCard.tsx @@ -4,42 +4,48 @@ import React from "react"; import Image from "next/image"; import { ICON_SIZE } from "@/lib/constants/charge"; import ConfirmChargeDrawer from "@/components/common/charge-confirm/ConfirmChargeDrawer"; +import { ShopProduct } from "@/hooks/useShopProducts"; interface QuickBundleCardProps { - title: string; - icon: string; - description: string; - bonus: string; - price: string; - priceValue: number; + product: ShopProduct; } -export default function QuickBundleCard({ - title, - icon, - description, - bonus, - price, - priceValue, -}: QuickBundleCardProps) { +export default function QuickBundleCard({ product }: QuickBundleCardProps) { + // 메인 리워드 설명 + const description = product.rewards + .map((r) => `${r.itemName} ${r.quantity}개`) + .join("+"); + + // 보너스 리워드 텍스트 + const bonusText = + product.bonusRewards.length > 0 + ? product.bonusRewards + .map((r) => `${r.itemName} ${r.quantity}개`) + .join(", ") + " 무료 증정!" + : null; + return (
{title}
- {title} + + {product.name} +
{description} - - {bonus} - + {bonusText && ( + + {bonusText} + + )}
@@ -49,10 +55,10 @@ export default function QuickBundleCard({ type="button" className="typo-16-600 bg-color-flame-700 mt-4 flex h-10 w-full items-center justify-center rounded-[8px] text-white" > - {price} + {product.price.toLocaleString()}원 } - amount={priceValue} + amount={product.price} />
); diff --git a/components/common/charge/ShopContent.tsx b/components/common/charge/ShopContent.tsx index cba821b..6ca094f 100644 --- a/components/common/charge/ShopContent.tsx +++ b/components/common/charge/ShopContent.tsx @@ -1,68 +1,80 @@ "use client"; import React from "react"; -import { useItems } from "@/hooks/useItems"; +import { useShopProducts, ShopProduct } from "@/hooks/useShopProducts"; import ChargeInventoryCard from "./ChargeInventoryCard"; import QuickBundleCard from "./QuickBundleCard"; import { ShopItemRow } from "./ShopRows"; import { ShopBundleRow } from "./ShopRows"; -import { - QUICK_BUNDLES, - SHOP_ITEMS_API, - USAGE_INFO, - BUSINESS_INFO, -} from "@/lib/constants/charge"; +import { USAGE_INFO, BUSINESS_INFO } from "@/lib/constants/charge"; +import { Loader2 } from "lucide-react"; + +/** rewards의 itemType 종류가 2개 이상이면 번들(빠른 구매) */ +function isBundle(product: ShopProduct): boolean { + const types = new Set(product.rewards.map((r) => r.itemType)); + return types.size >= 2; +} export default function ShopContent() { - const { data } = useItems(); + const { data: productsData, isLoading: isProductsLoading } = + useShopProducts(); - const ticketCounts = { - matching: data?.data.matchingTicketCount ?? 0, - option: data?.data.optionTicketCount ?? 0, - }; + const products = productsData?.data ?? []; - // 개별 아이템: 리워드가 1개인 경우 - const individualItems = SHOP_ITEMS_API.filter( - (item) => item.rewards.length === 1, - ); + // 개별 아이템: rewards의 itemType이 1종류 + const individualItems = products.filter((item) => !isBundle(item)); - // 번들 아이템: 리워드가 2개 이상인 경우 - const bundleItems = SHOP_ITEMS_API.filter((item) => item.rewards.length >= 2); + // 번들 아이템: rewards의 itemType이 2종류 이상 → 빠른 구매/번들 상세 + const bundleItems = products.filter((item) => isBundle(item)); return (
{/* ── 보유현황 카드 ── */} - + - {/* ── 빠른 구매 ── */} -
- 빠른 구매 -
- {QUICK_BUNDLES.map((bundle) => ( - - ))} + {isProductsLoading ? ( +
+
-
+ ) : ( + <> + {/* ── 빠른 구매 ── */} + {bundleItems.length > 0 && ( +
+ 빠른 구매 +
+ {bundleItems.map((item) => ( + + ))} +
+
+ )} - {/* ── 전체 ── */} -
- 전체 -
- {individualItems.map((item) => ( - - ))} -
-
+ {/* ── 전체 ── */} + {individualItems.length > 0 && ( +
+ 전체 +
+ {individualItems.map((item) => ( + + ))} +
+
+ )} - {/* ── 번들 ── */} -
- 번들 -
- {bundleItems.map((item) => ( - - ))} -
-
+ {/* ── 번들 상세 ── */} + {bundleItems.length > 0 && ( +
+ 번들 +
+ {bundleItems.map((item) => ( + + ))} +
+
+ )} + + )} {/* ── 이용안내 ── */}
diff --git a/components/common/charge/ShopRows.tsx b/components/common/charge/ShopRows.tsx index fc09ad7..896da28 100644 --- a/components/common/charge/ShopRows.tsx +++ b/components/common/charge/ShopRows.tsx @@ -1,63 +1,65 @@ import React from "react"; import ConfirmChargeDrawer from "@/components/common/charge-confirm/ConfirmChargeDrawer"; -import { ShopItemAPI } from "@/lib/constants/charge"; +import { ShopProduct } from "@/hooks/useShopProducts"; -/* ── ShopItemRow ── */ +/* ── ShopItemRow (개별 아이템) ── */ export interface ShopItemRowProps { - item: ShopItemAPI; + product: ShopProduct; } -export function ShopItemRow({ item }: ShopItemRowProps) { +export function ShopItemRow({ product }: ShopItemRowProps) { return (
- {item.name} + {product.name} - {item.price.toLocaleString()}원 + {product.price.toLocaleString()}원 } - amount={item.price} + amount={product.price} />
); } -/* ── ShopBundleRow ── */ +/* ── ShopBundleRow (번들 아이템) ── */ export interface ShopBundleRowProps { - item: ShopItemAPI; + product: ShopProduct; } -export function ShopBundleRow({ item }: ShopBundleRowProps) { - // 리워드 목록에서 보너스 항목 제외한 메인 리워드 텍스트 생성 - const mainRewards = item.rewards.filter( - (r) => !r.itemName.includes("보너스"), - ); - const description = mainRewards +export function ShopBundleRow({ product }: ShopBundleRowProps) { + // 메인 리워드 텍스트 + const description = product.rewards .map((r) => `${r.itemName} ${r.quantity}개`) .join("+"); - // 보너스 항목 찾기 - const bonusReward = item.rewards.find((r) => r.itemName.includes("보너스")); + // 보너스 리워드 + const bonusText = + product.bonusRewards.length > 0 + ? product.bonusRewards + .map((r) => `${r.itemName} ${r.quantity}개`) + .join(", ") + " 무료 증정!" + : null; return (
- {item.name} - + + {product.name} + + {description}
- {bonusReward && ( - - {bonusReward.itemName} {bonusReward.quantity}개 무료 증정! - + {bonusText && ( + {bonusText} )}
- {item.price.toLocaleString()}원 + {product.price.toLocaleString()}원 } - amount={item.price} + amount={product.price} />
); diff --git a/hooks/useItems.ts b/hooks/useItems.ts index 760efbb..720a9ab 100644 --- a/hooks/useItems.ts +++ b/hooks/useItems.ts @@ -37,6 +37,6 @@ export const useItems = () => { queryKey: ["items"], queryFn: fetchItems, staleTime: Infinity, // 충전/소모 전까지는 데이터가 변하지 않으므로 무한정 캐싱 - gcTime: 1000 * 60 * 60, // 메모리에서 1시간 동안 유지 + gcTime: Infinity, // 메모리에서 삭제하지 않음 → 로딩바 없음 }); }; diff --git a/hooks/useMatching.ts b/hooks/useMatching.ts index ead5cbc..7f4c917 100644 --- a/hooks/useMatching.ts +++ b/hooks/useMatching.ts @@ -1,4 +1,4 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { postMatchingAction } from "@/lib/actions/matchingAction"; /** @@ -6,10 +6,14 @@ import { postMatchingAction } from "@/lib/actions/matchingAction"; * 성공 시 매칭된 유저 정보를 반환합니다. */ export const useMatching = () => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: postMatchingAction, onSuccess: (data) => { console.log("✅ 매칭 성공:", data); + // 매칭권 소모되므로 아이템 정보 무효화 + queryClient.invalidateQueries({ queryKey: ["items"] }); }, onError: (error) => { console.error("❌ 매칭 실패:", (error as Error).message); diff --git a/lib/constants/charge.ts b/lib/constants/charge.ts index 4fd9020..8bfdd13 100644 --- a/lib/constants/charge.ts +++ b/lib/constants/charge.ts @@ -26,83 +26,6 @@ export const PURCHASE_LIMITS = { option: { current: 42, max: 60, label: "아이템" }, } as const; -/* ── 빠른 구매 번들 카드 ── */ -export const QUICK_BUNDLES = [ - { - title: "실속 번들", - icon: "/main/coin.png", - description: "뽑기권 5개+옵션권 2개", - bonus: "옵션권 1개 무료 증정!", - price: "5,000원", - priceValue: 5000, - }, - { - title: "슈퍼 번들", - icon: "/main/coin.png", - description: "뽑기권 10개+옵션권 10개", - bonus: "옵션권 5개 무료 증정!", - price: "10,000원", - priceValue: 10000, - }, -] as const; - -/* ── API 응답 데이터 타입 ── */ -export interface Reward { - itemType: "MATCHING_TICKET" | "OPTION_TICKET"; - itemName: string; - quantity: number; -} - -export interface ShopItemAPI { - id: number; - name: string; - price: number; - rewards: Reward[]; -} - -/* ── 전체 아이템 (API 더미 데이터) ── */ -export const SHOP_ITEMS_API: ShopItemAPI[] = [ - { - id: 1, - name: "뽑기권 1개", - price: 1000, - rewards: [{ itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 1 }], - }, - { - id: 2, - name: "옵션권 1개", - price: 200, - rewards: [{ itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 1 }], - }, - { - id: 10, - name: "미니 번들", - price: 2500, - rewards: [ - { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 3 }, - { itemType: "OPTION_TICKET", itemName: "보너스 옵션권", quantity: 1 }, - ], - }, - { - id: 11, - name: "실속 번들", - price: 5000, - rewards: [ - { itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 5 }, - { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 2 }, - ], - }, - { - id: 12, - name: "슈퍼 번들", - price: 10000, - rewards: [ - { itemType: "MATCHING_TICKET", itemName: "매칭권", quantity: 10 }, - { itemType: "OPTION_TICKET", itemName: "옵션권", quantity: 10 }, - ], - }, -]; - /* ── 이용안내 및 사업자 정보 ── */ export const USAGE_INFO = `* 충전된 포인트의 소멸시효 기한은 서비스 종료일까지 입니다. * 각 상품은 아이템 개별 구매 또는 번들 상품 형태로 제공됩니다. From a4086cf853a0aa64b277d4c1802fc212cc00b26e Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 28 Apr 2026 16:47:10 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20api=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/common/ChargeDrawer.tsx | 45 ++++++++++-------- .../charge-confirm/ConfirmChargeDrawer.tsx | 46 +++++++++++++++++-- .../common/charge/DepositorNameContent.tsx | 24 +++++++++- components/common/charge/QuickBundleCard.tsx | 1 + components/common/charge/ShopContent.tsx | 25 +++++----- components/common/charge/ShopRows.tsx | 2 + hooks/usePurchaseProduct.ts | 39 ++++++++++++++++ hooks/useShopProducts.ts | 1 + hooks/useUpdateRealName.ts | 43 +++++++++++++++++ 9 files changed, 189 insertions(+), 37 deletions(-) create mode 100644 hooks/usePurchaseProduct.ts create mode 100644 hooks/useUpdateRealName.ts diff --git a/components/common/ChargeDrawer.tsx b/components/common/ChargeDrawer.tsx index b6d8bd1..9b9b204 100644 --- a/components/common/ChargeDrawer.tsx +++ b/components/common/ChargeDrawer.tsx @@ -16,6 +16,13 @@ import DepositorNameContent from "@/components/common/charge/DepositorNameConten import ChargeTabs from "@/components/common/charge/ChargeTabs"; import { TABS } from "@/lib/constants/charge"; +/* ── Context ── */ +interface ChargeDrawerContextType { + setActiveTab: (index: number) => void; +} +export const ChargeDrawerContext = + React.createContext(null); + interface ChargeDrawerProps { trigger: React.ReactNode; } @@ -38,26 +45,28 @@ export default function ChargeDrawer({ trigger }: ChargeDrawerProps) { showHandle={false} >
- {/* ── Header ── */} - -
-
- - {TABS[activeTab].title} - - - 닫기 - -
- + {/* ... */} + + +
+
+ + {TABS[activeTab].title} + + + 닫기 + +
+ - {/* ── 탭 칩 ── */} - + {/* ── 탭 칩 ── */} + - {/* ── Scrollable Content ── */} -
- -
+ {/* ── Scrollable Content ── */} +
+ +
+
diff --git a/components/common/charge-confirm/ConfirmChargeDrawer.tsx b/components/common/charge-confirm/ConfirmChargeDrawer.tsx index 593f88e..5492f82 100644 --- a/components/common/charge-confirm/ConfirmChargeDrawer.tsx +++ b/components/common/charge-confirm/ConfirmChargeDrawer.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { AxiosError } from "axios"; import { cn } from "@/lib/utils"; import { X, PencilLine, Check } from "lucide-react"; import { @@ -19,6 +20,8 @@ import { BANK_INFO } from "@/lib/constants/charge"; /* ── Props ── */ interface ConfirmChargeDrawerProps { trigger: React.ReactNode; + /** 상품 ID */ + productId: number; /** 입금액 (원 단위 숫자) */ amount: number; /** 입금자명 (사전 설정된 값) */ @@ -27,12 +30,19 @@ interface ConfirmChargeDrawerProps { /* ────────────────────────────────────── */ +import { usePurchaseProduct } from "@/hooks/usePurchaseProduct"; +import { ChargeDrawerContext } from "@/components/common/ChargeDrawer"; + export default function ConfirmChargeDrawer({ trigger, + productId, amount, depositorName = "천승환", }: ConfirmChargeDrawerProps) { const queryClient = useQueryClient(); + const drawerContext = React.useContext(ChargeDrawerContext); + const { mutate: purchase, isPending } = usePurchaseProduct(); + const [open, setOpen] = React.useState(false); const [agreed, setAgreed] = React.useState(false); const [name, setName] = React.useState(depositorName); const [isEditingName, setIsEditingName] = React.useState(false); @@ -57,11 +67,39 @@ export default function ConfirmChargeDrawer({ /* 충전 완료 알림 */ const handleConfirm = () => { - alert("충전 요청이 완료되었습니다!"); + purchase(productId, { + onSuccess: () => { + alert("충전 요청이 완료되었습니다!"); + setOpen(false); + }, + onError: (error: AxiosError<{ code?: string; message?: string }>) => { + const errorData = error.response?.data; + if (errorData?.code === "PAY-003") { + alert("이미 입금 확인 대기 중인 주문이 존재합니다."); + setOpen(false); + } else if (errorData?.code === "PAY-004") { + alert("먼저 입금자명을 설정해 주세요."); + setOpen(false); + // 실명 설정 탭으로 이동 (TABS[2]가 입금자명 설정) + drawerContext?.setActiveTab(2); + } else { + alert( + errorData?.message || + "충전 요청 중 오류가 발생했습니다. 다시 시도해 주세요.", + ); + } + }, + }); }; return ( - setAgreed(false)}> + { + setOpen(val); + if (!val) setAgreed(false); + }} + > {trigger} {/* CTA 버튼 */} - {/* Toss 링크 */} diff --git a/components/common/charge/DepositorNameContent.tsx b/components/common/charge/DepositorNameContent.tsx index da06dbe..85d9531 100644 --- a/components/common/charge/DepositorNameContent.tsx +++ b/components/common/charge/DepositorNameContent.tsx @@ -1,12 +1,15 @@ "use client"; import React from "react"; +import { AxiosError } from "axios"; import { cn } from "@/lib/utils"; import { isValidDepositorName } from "@/lib/validators"; import Button from "@/components/ui/Button"; +import { useUpdateRealName } from "@/hooks/useUpdateRealName"; export default function DepositorNameContent() { const [name, setName] = React.useState(""); + const { mutate: updateName, isPending } = useUpdateRealName(); const handleNameChange = (e: React.ChangeEvent) => { const value = e.target.value; @@ -15,6 +18,18 @@ export default function DepositorNameContent() { } }; + const handleSave = () => { + updateName(name, { + onSuccess: () => { + alert("입금자명이 성공적으로 설정되었습니다."); + }, + onError: (error: AxiosError<{ message: string }>) => { + const errorData = error.response?.data; + alert(errorData?.message || "입금자명 설정 중 오류가 발생했습니다."); + }, + }); + }; + return (
{/* ── 입금자명 입력 ── */} @@ -74,8 +89,13 @@ export default function DepositorNameContent() {
-
); diff --git a/components/common/charge/QuickBundleCard.tsx b/components/common/charge/QuickBundleCard.tsx index 41f2f57..008c9d3 100644 --- a/components/common/charge/QuickBundleCard.tsx +++ b/components/common/charge/QuickBundleCard.tsx @@ -59,6 +59,7 @@ export default function QuickBundleCard({ product }: QuickBundleCardProps) { } amount={product.price} + productId={product.id} />
); diff --git a/components/common/charge/ShopContent.tsx b/components/common/charge/ShopContent.tsx index 6ca094f..21530c0 100644 --- a/components/common/charge/ShopContent.tsx +++ b/components/common/charge/ShopContent.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { useShopProducts, ShopProduct } from "@/hooks/useShopProducts"; +import { useShopProducts } from "@/hooks/useShopProducts"; import ChargeInventoryCard from "./ChargeInventoryCard"; import QuickBundleCard from "./QuickBundleCard"; import { ShopItemRow } from "./ShopRows"; @@ -9,23 +9,17 @@ import { ShopBundleRow } from "./ShopRows"; import { USAGE_INFO, BUSINESS_INFO } from "@/lib/constants/charge"; import { Loader2 } from "lucide-react"; -/** rewards의 itemType 종류가 2개 이상이면 번들(빠른 구매) */ -function isBundle(product: ShopProduct): boolean { - const types = new Set(product.rewards.map((r) => r.itemType)); - return types.size >= 2; -} - export default function ShopContent() { const { data: productsData, isLoading: isProductsLoading } = useShopProducts(); const products = productsData?.data ?? []; - // 개별 아이템: rewards의 itemType이 1종류 - const individualItems = products.filter((item) => !isBundle(item)); + // 개별 아이템: isBundle이 false인 경우 + const individualItems = products.filter((item) => !item.isBundle); - // 번들 아이템: rewards의 itemType이 2종류 이상 → 빠른 구매/번들 상세 - const bundleItems = products.filter((item) => isBundle(item)); + // 번들 아이템: isBundle이 true인 경우 -> 빠른 구매/번들 상세 + const bundleItems = products.filter((item) => item.isBundle); return (
@@ -42,9 +36,14 @@ export default function ShopContent() { {bundleItems.length > 0 && (
빠른 구매 -
+
{bundleItems.map((item) => ( - +
+ +
))}
diff --git a/components/common/charge/ShopRows.tsx b/components/common/charge/ShopRows.tsx index 896da28..17eb252 100644 --- a/components/common/charge/ShopRows.tsx +++ b/components/common/charge/ShopRows.tsx @@ -22,6 +22,7 @@ export function ShopItemRow({ product }: ShopItemRowProps) { } amount={product.price} + productId={product.id} />
); @@ -72,6 +73,7 @@ export function ShopBundleRow({ product }: ShopBundleRowProps) { } amount={product.price} + productId={product.id} />
); diff --git a/hooks/usePurchaseProduct.ts b/hooks/usePurchaseProduct.ts new file mode 100644 index 0000000..c1399dd --- /dev/null +++ b/hooks/usePurchaseProduct.ts @@ -0,0 +1,39 @@ +import { api } from "@/lib/axios"; +import { useMutation } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +interface PurchaseResponse { + code: string; + status: number; + message: string; + data: null; +} + +/** + * 상품 구매(주문 생성) API 호출 함수 + */ +export const postPurchaseProduct = async ( + productId: number, +): Promise => { + const { data } = await api.post( + `/api/v1/shop/purchase/${productId}`, + {}, + ); + return data; +}; + +/** + * 상품 구매(주문 생성) Mutation 훅 + */ +export const usePurchaseProduct = () => { + return useMutation({ + mutationFn: (productId: number) => postPurchaseProduct(productId), + onSuccess: (data) => { + console.log("✅ 주문 생성 성공:", data); + }, + onError: (error: AxiosError<{ message: string }>) => { + const errorData = error.response?.data; + console.error("❌ 주문 생성 실패:", errorData?.message || error.message); + }, + }); +}; diff --git a/hooks/useShopProducts.ts b/hooks/useShopProducts.ts index ccc7aa8..9a39454 100644 --- a/hooks/useShopProducts.ts +++ b/hooks/useShopProducts.ts @@ -15,6 +15,7 @@ export interface ShopProduct { price: number; displayOrder: number; isActive: boolean; + isBundle: boolean; rewards: ShopReward[]; bonusRewards: ShopReward[]; } diff --git a/hooks/useUpdateRealName.ts b/hooks/useUpdateRealName.ts new file mode 100644 index 0000000..bd7493f --- /dev/null +++ b/hooks/useUpdateRealName.ts @@ -0,0 +1,43 @@ +import { api } from "@/lib/axios"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +interface UpdateRealNameResponse { + code: string; + status: number; + message: string; + data: null; +} + +/** + * 사용자 실명 수정 API 호출 함수 + */ +export const postRealName = async ( + realName: string, +): Promise => { + const { data } = await api.post( + "/api/members/real-name", + { realName }, + ); + return data; +}; + +/** + * 사용자 실명 수정 Mutation 훅 + */ +export const useUpdateRealName = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (realName: string) => postRealName(realName), + onSuccess: (data) => { + console.log("✅ 실명 수정 성공:", data); + // 실명이 바뀌었으므로 관련 쿼리 무효화 (필요시) + // queryClient.invalidateQueries({ queryKey: ["userProfile"] }); + }, + onError: (error: AxiosError<{ message: string }>) => { + const errorData = error.response?.data; + console.error("❌ 실명 수정 실패:", errorData?.message || error.message); + }, + }); +}; From cb160b2c1d6c0dd885b08a3aef44bb785ebea46f Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 28 Apr 2026 16:50:57 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20post=20->=20patch=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/useUpdateRealName.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/useUpdateRealName.ts b/hooks/useUpdateRealName.ts index bd7493f..437d25e 100644 --- a/hooks/useUpdateRealName.ts +++ b/hooks/useUpdateRealName.ts @@ -15,7 +15,7 @@ interface UpdateRealNameResponse { export const postRealName = async ( realName: string, ): Promise => { - const { data } = await api.post( + const { data } = await api.patch( "/api/members/real-name", { realName }, ); From a0050e23300768826a79481d9d0fc3a6685912f8 Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 28 Apr 2026 17:39:13 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20=EC=B0=B8=EA=B0=80=EC=9E=90?= =?UTF-8?q?=EC=88=98=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_components/BubbleDiv.tsx | 9 ++++- .../_components/NoMatchingList.tsx | 12 +++++-- hooks/useParticipantsCount.ts | 35 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 hooks/useParticipantsCount.ts diff --git a/app/_components/BubbleDiv.tsx b/app/_components/BubbleDiv.tsx index df8ea98..edb65d5 100644 --- a/app/_components/BubbleDiv.tsx +++ b/app/_components/BubbleDiv.tsx @@ -2,6 +2,8 @@ import Image from "next/image"; import React from "react"; import { cn } from "@/lib/utils"; +import { useParticipantsCount } from "@/hooks/useParticipantsCount"; + const BubbleDiv = ({ children, w, @@ -21,6 +23,8 @@ const BubbleDiv = ({ textColor?: string; className?: string; }) => { + const { data: participantsData } = useParticipantsCount(); + const count = participantsData?.data?.count ?? 775; const shadowClass = shadow ? "drop-shadow-md" : ""; return ( @@ -47,7 +51,10 @@ const BubbleDiv = ({
{children || ( <> - 현재 775명{" "} + 현재{" "} + + {count.toLocaleString()}명 + {" "} 참여중이에요! )} diff --git a/app/matching-list/_components/NoMatchingList.tsx b/app/matching-list/_components/NoMatchingList.tsx index 8789bec..3631046 100644 --- a/app/matching-list/_components/NoMatchingList.tsx +++ b/app/matching-list/_components/NoMatchingList.tsx @@ -5,12 +5,16 @@ import Image from "next/image"; import { ArrowLeft } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useParticipantsCount } from "@/hooks/useParticipantsCount"; + interface NoMatchingListProps { nickname?: string; } const NoMatchingList = ({ nickname = "회원" }: NoMatchingListProps) => { const router = useRouter(); + const { data: participantsData } = useParticipantsCount(); + const count = participantsData?.data?.count ?? 732; // 기본값 732 유지 return (
@@ -28,9 +32,11 @@ const NoMatchingList = ({ nickname = "회원" }: NoMatchingListProps) => {

아직 매칭된 상대가 없어요.
- 아직 - 732 - 명이 {nickname}님을 기다리고 있어요. + 아직{" "} + + {count.toLocaleString()} + + 명이 {nickname}님을 기다리고 있어요.
나와 딱 맞는 이성친구를 만들어봐요!

diff --git a/hooks/useParticipantsCount.ts b/hooks/useParticipantsCount.ts new file mode 100644 index 0000000..83bb135 --- /dev/null +++ b/hooks/useParticipantsCount.ts @@ -0,0 +1,35 @@ +import { api } from "@/lib/axios"; +import { useQuery } from "@tanstack/react-query"; + +/* ── API 응답 타입 ── */ +export interface ParticipantsCountResponse { + code: string; + status: number; + message: string; + data: { + count: number; + }; +} + +/* ── fetcher ── */ +export const fetchParticipantsCount = + async (): Promise => { + const { data } = await api.get( + "/api/auth/participants", + ); + return data; + }; + +/* ── hook ── */ +/** + * 활성 사용자 수(ROLE_USER + ACTIVE)를 조회하는 훅 + * 30분 동안 캐싱됩니다. + */ +export const useParticipantsCount = () => { + return useQuery({ + queryKey: ["participantsCount"], + queryFn: fetchParticipantsCount, + staleTime: 1000 * 60 * 30, // 30분 + gcTime: 1000 * 60 * 35, // staleTime보다 약간 길게 설정하여 캐시 유지 + }); +}; From 6261fb5bec3b206efd26e776b958781fa2021727 Mon Sep 17 00:00:00 2001 From: dasosann Date: Wed, 29 Apr 2026 16:37:53 +0900 Subject: [PATCH 14/16] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/adminpage/_components/AdminLoginForm.tsx | 92 +++++ .../dashboard/_components/AdminDashboard.tsx | 144 +++++++ .../dashboard/_components/ExpiryCountdown.tsx | 62 +++ .../dashboard/_components/OrderCard.tsx | 216 +++++++++++ app/adminpage/dashboard/page.tsx | 5 + app/adminpage/layout.tsx | 11 + .../main/_components/AdminMainScreen.tsx | 107 ++++++ app/adminpage/main/_components/MenuCard.tsx | 100 +++++ app/adminpage/main/page.tsx | 5 + app/adminpage/page.tsx | 44 +++ .../products/_components/AdminProducts.tsx | 248 ++++++++++++ .../_components/ProductCreateForm.tsx | 357 ++++++++++++++++++ app/adminpage/products/page.tsx | 5 + app/globals.css | 7 + hooks/useAdminOrderSocket.ts | 189 ++++++++++ hooks/useAdminOrders.ts | 91 +++++ hooks/useAdminProducts.ts | 103 +++++ lib/actions/adminLoginAction.ts | 101 +++++ package.json | 3 + pnpm-lock.yaml | 62 +++ 20 files changed, 1952 insertions(+) create mode 100644 app/adminpage/_components/AdminLoginForm.tsx create mode 100644 app/adminpage/dashboard/_components/AdminDashboard.tsx create mode 100644 app/adminpage/dashboard/_components/ExpiryCountdown.tsx create mode 100644 app/adminpage/dashboard/_components/OrderCard.tsx create mode 100644 app/adminpage/dashboard/page.tsx create mode 100644 app/adminpage/layout.tsx create mode 100644 app/adminpage/main/_components/AdminMainScreen.tsx create mode 100644 app/adminpage/main/_components/MenuCard.tsx create mode 100644 app/adminpage/main/page.tsx create mode 100644 app/adminpage/page.tsx create mode 100644 app/adminpage/products/_components/AdminProducts.tsx create mode 100644 app/adminpage/products/_components/ProductCreateForm.tsx create mode 100644 app/adminpage/products/page.tsx create mode 100644 hooks/useAdminOrderSocket.ts create mode 100644 hooks/useAdminOrders.ts create mode 100644 hooks/useAdminProducts.ts create mode 100644 lib/actions/adminLoginAction.ts diff --git a/app/adminpage/_components/AdminLoginForm.tsx b/app/adminpage/_components/AdminLoginForm.tsx new file mode 100644 index 0000000..e6caf98 --- /dev/null +++ b/app/adminpage/_components/AdminLoginForm.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React, { useActionState } from "react"; +import { adminLoginAction } from "@/lib/actions/adminLoginAction"; + +export default function AdminLoginForm() { + const [state, formAction, isPending] = useActionState(adminLoginAction, { + success: false, + message: "", + }); + + return ( +
+ {/* 이메일 */} +
+ + +
+ + {/* 비밀번호 */} +
+ + + {!state.success && state.message && ( + + * {state.message} + + )} +
+ + {/* 로그인 버튼 */} + +
+ ); +} diff --git a/app/adminpage/dashboard/_components/AdminDashboard.tsx b/app/adminpage/dashboard/_components/AdminDashboard.tsx new file mode 100644 index 0000000..958ac46 --- /dev/null +++ b/app/adminpage/dashboard/_components/AdminDashboard.tsx @@ -0,0 +1,144 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { useAdminOrders } from "@/hooks/useAdminOrders"; +import { useAdminOrderSocket } from "@/hooks/useAdminOrderSocket"; +import OrderCard from "./OrderCard"; +import { + ArrowLeft, + CreditCard, + Loader2, + Wifi, + WifiOff, + RefreshCw, + Inbox, +} from "lucide-react"; + +export default function AdminDashboard() { + const { + data: ordersData, + isLoading, + refetch, + isRefetching, + } = useAdminOrders(); + const { status: socketStatus } = useAdminOrderSocket(); + + const orders = ordersData?.data ?? []; + + // PENDING 주문을 먼저, 나머지는 뒤에 + const sortedOrders = [...orders].sort((a, b) => { + if (a.status === "PENDING" && b.status !== "PENDING") return -1; + if (a.status !== "PENDING" && b.status === "PENDING") return 1; + return ( + new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime() + ); + }); + + const pendingCount = orders.filter((o) => o.status === "PENDING").length; + + return ( +
+ {/* 헤더 */} +
+
+ + + +
+
+ +
+
+

+ 결제 승인 관리 +

+

+ 대기 중인 요청{" "} + + {pendingCount} + + 건 +

+
+
+
+ +
+ {/* 새로고침 */} + + + {/* 연결 상태 */} +
+ {socketStatus === "connected" ? ( + <> + + 실시간 연결 + + + ) : socketStatus === "reconnecting" ? ( + <> + + 재연결 중 + + ) : ( + <> + + 연결 끊김 + + )} +
+
+
+ + {/* 주문 목록 */} + {isLoading ? ( +
+ +

+ 주문 목록 불러오는 중... +

+
+ ) : sortedOrders.length === 0 ? ( +
+
+ +
+

+ 대기 중인 결제 요청이 없습니다 +

+

+ 새로운 요청이 들어오면 실시간으로 표시됩니다 +

+
+ ) : ( +
+ {sortedOrders.map((order) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/adminpage/dashboard/_components/ExpiryCountdown.tsx b/app/adminpage/dashboard/_components/ExpiryCountdown.tsx new file mode 100644 index 0000000..2a464f5 --- /dev/null +++ b/app/adminpage/dashboard/_components/ExpiryCountdown.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Timer } from "lucide-react"; + +interface ExpiryCountdownProps { + expiresAt: string; + onExpired?: () => void; +} + +export default function ExpiryCountdown({ + expiresAt, + onExpired, +}: ExpiryCountdownProps) { + const [remaining, setRemaining] = useState(() => + calculateRemaining(expiresAt), + ); + + useEffect(() => { + const timer = setInterval(() => { + const newRemaining = calculateRemaining(expiresAt); + setRemaining(newRemaining); + + if (newRemaining <= 0) { + clearInterval(timer); + onExpired?.(); + } + }, 1000); + + return () => clearInterval(timer); + }, [expiresAt, onExpired]); + + if (remaining <= 0) { + return ( + + + 만료됨 + + ); + } + + const minutes = Math.floor(remaining / 60); + const seconds = remaining % 60; + const isUrgent = remaining < 120; // 2분 미만 + + return ( + + + {String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")} + + ); +} + +function calculateRemaining(expiresAt: string): number { + const expiry = new Date(expiresAt).getTime(); + const now = Date.now(); + return Math.max(0, Math.floor((expiry - now) / 1000)); +} diff --git a/app/adminpage/dashboard/_components/OrderCard.tsx b/app/adminpage/dashboard/_components/OrderCard.tsx new file mode 100644 index 0000000..07d43aa --- /dev/null +++ b/app/adminpage/dashboard/_components/OrderCard.tsx @@ -0,0 +1,216 @@ +"use client"; + +import React, { useState } from "react"; +import { + type AdminOrder, + useApproveOrder, + useRejectOrder, +} from "@/hooks/useAdminOrders"; +import ExpiryCountdown from "./ExpiryCountdown"; +import { Check, X, User, Ticket, Tag, Clock, Coins } from "lucide-react"; + +interface OrderCardProps { + order: AdminOrder; +} + +const STATUS_CONFIG = { + PENDING: { + badge: "대기 중", + badgeClass: "bg-amber-500/10 text-amber-400 border-amber-500/30", + cardBorder: "border-amber-500/20", + glow: "shadow-amber-500/5", + }, + APPROVED: { + badge: "승인 완료", + badgeClass: "bg-emerald-500/10 text-emerald-400 border-emerald-500/30", + cardBorder: "border-emerald-500/20", + glow: "", + }, + REJECTED: { + badge: "거절됨", + badgeClass: "bg-red-500/10 text-red-400 border-red-500/30", + cardBorder: "border-red-500/20", + glow: "", + }, + CANCELED: { + badge: "취소됨", + badgeClass: "bg-gray-500/10 text-gray-400 border-gray-500/30", + cardBorder: "border-[#1e2030]", + glow: "", + }, + EXPIRED: { + badge: "만료됨", + badgeClass: "bg-gray-500/10 text-gray-400 border-gray-500/30", + cardBorder: "border-[#1e2030]", + glow: "", + }, +} as const; + +export default function OrderCard({ order }: OrderCardProps) { + const approveMutation = useApproveOrder(); + const rejectMutation = useRejectOrder(); + const [confirmAction, setConfirmAction] = useState< + "approve" | "reject" | null + >(null); + + const isPending = order.status === "PENDING"; + const isProcessing = approveMutation.isPending || rejectMutation.isPending; + const config = STATUS_CONFIG[order.status] || STATUS_CONFIG.PENDING; + + const handleApprove = () => { + if (confirmAction === "approve") { + approveMutation.mutate(order.requestId); + setConfirmAction(null); + } else { + setConfirmAction("approve"); + } + }; + + const handleReject = () => { + if (confirmAction === "reject") { + rejectMutation.mutate(order.requestId); + setConfirmAction(null); + } else { + setConfirmAction("reject"); + } + }; + + const formatPrice = (price: number) => { + return price.toLocaleString("ko-KR") + "원"; + }; + + const formatDateTime = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleString("ko-KR", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + return ( +
+ {/* 상단: 상태 배지 + 만료 시간 */} +
+ + {isPending && ( + + )} + {config.badge} + + + {isPending && ( + { + // 만료 시 캐시 갱신은 STOMP 또는 polling이 처리 + }} + /> + )} +
+ + {/* 본문 */} +
+ {/* 상품명 */} +
+ +
+

상품

+

+ {order.requestedItemName} +

+
+
+ + {/* 요청자 정보 */} +
+ +
+

요청자

+

+ {order.requesterRealName} + + @{order.requesterUsername} + +

+
+
+ + {/* 티켓 수량 + 가격 */} +
+
+ +
+

티켓

+
+ {order.matchingTicketQty > 0 && ( + + 매칭 {order.matchingTicketQty} + + )} + {order.optionTicketQty > 0 && ( + + 옵션 {order.optionTicketQty} + + )} +
+
+
+ +
+ +
+

금액

+

+ {formatPrice(order.requestedPrice)} +

+
+
+
+ + {/* 요청 시간 */} +
+ + {formatDateTime(order.requestedAt)} 요청 +
+
+ + {/* 액션 버튼 (PENDING에서만 표시) */} + {isPending && ( +
+ + +
+ )} +
+ ); +} diff --git a/app/adminpage/dashboard/page.tsx b/app/adminpage/dashboard/page.tsx new file mode 100644 index 0000000..3614eb4 --- /dev/null +++ b/app/adminpage/dashboard/page.tsx @@ -0,0 +1,5 @@ +import AdminDashboard from "./_components/AdminDashboard"; + +export default function AdminDashboardPage() { + return ; +} diff --git a/app/adminpage/layout.tsx b/app/adminpage/layout.tsx new file mode 100644 index 0000000..f9b4fcf --- /dev/null +++ b/app/adminpage/layout.tsx @@ -0,0 +1,11 @@ +export default function AdminLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ {children} +
+ ); +} diff --git a/app/adminpage/main/_components/AdminMainScreen.tsx b/app/adminpage/main/_components/AdminMainScreen.tsx new file mode 100644 index 0000000..b8c731c --- /dev/null +++ b/app/adminpage/main/_components/AdminMainScreen.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import MenuCard from "./MenuCard"; +import { + CreditCard, + Package, + Users, + BarChart3, + LogOut, + Shield, +} from "lucide-react"; + +const MENU_ITEMS = [ + { + id: "payment", + title: "결제 승인 관리", + description: "사용자 결제 요청을 실시간으로 확인하고 승인/거절합니다", + icon: CreditCard, + href: "/adminpage/dashboard", + active: true, + gradient: "from-[#ff4d61] to-[#ff775e]", + badge: "LIVE", + }, + { + id: "products", + title: "상품 관리", + description: "매칭권/옵션권 상품을 등록, 수정, 관리합니다", + icon: Package, + href: "/adminpage/products", + active: true, + gradient: "from-[#6366f1] to-[#8b5cf6]", + }, + { + id: "users", + title: "사용자 관리", + description: "가입된 사용자 목록 조회 및 관리", + icon: Users, + href: "#", + active: false, + gradient: "from-[#06b6d4] to-[#3b82f6]", + }, + { + id: "stats", + title: "통계", + description: "서비스 이용 현황 및 매출 데이터를 확인합니다", + icon: BarChart3, + href: "#", + active: false, + gradient: "from-[#f59e0b] to-[#ef4444]", + }, +]; + +export default function AdminMainScreen() { + const router = useRouter(); + + const handleLogout = () => { + // 쿠키 삭제 후 로그인 페이지로 이동 + document.cookie = + "accessToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + document.cookie = + "refreshToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + router.push("/adminpage"); + }; + + return ( +
+ {/* 헤더 */} +
+
+
+ +
+
+

+ 관리자 콘솔 +

+

+ COMAtching Admin Dashboard +

+
+
+ + +
+ + {/* 메뉴 그리드 */} +
+

+ 관리 메뉴 +

+
+ {MENU_ITEMS.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/app/adminpage/main/_components/MenuCard.tsx b/app/adminpage/main/_components/MenuCard.tsx new file mode 100644 index 0000000..ff03963 --- /dev/null +++ b/app/adminpage/main/_components/MenuCard.tsx @@ -0,0 +1,100 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { type LucideIcon, ArrowRight } from "lucide-react"; + +interface MenuCardProps { + item: { + id: string; + title: string; + description: string; + icon: LucideIcon; + href: string; + active: boolean; + gradient: string; + badge?: string; + }; +} + +export default function MenuCard({ item }: MenuCardProps) { + const Icon = item.icon; + + if (!item.active) { + return ( +
+ {/* 아이콘 */} +
+ +
+ + {/* 텍스트 */} +
+
+

+ {item.title} +

+ + 준비 중 + +
+

+ {item.description} +

+
+
+ ); + } + + return ( + + {/* 호버 글로우 효과 */} +
+ + {/* 아이콘 */} +
+
+ +
+ + {item.badge && ( + + + {item.badge} + + )} +
+ + {/* 텍스트 */} +
+

+ {item.title} +

+

+ {item.description} +

+
+ + {/* 화살표 */} +
+ 바로가기 + +
+ + ); +} diff --git a/app/adminpage/main/page.tsx b/app/adminpage/main/page.tsx new file mode 100644 index 0000000..9eced39 --- /dev/null +++ b/app/adminpage/main/page.tsx @@ -0,0 +1,5 @@ +import AdminMainScreen from "./_components/AdminMainScreen"; + +export default function AdminMainPage() { + return ; +} diff --git a/app/adminpage/page.tsx b/app/adminpage/page.tsx new file mode 100644 index 0000000..288b357 --- /dev/null +++ b/app/adminpage/page.tsx @@ -0,0 +1,44 @@ +import AdminLoginForm from "./_components/AdminLoginForm"; + +export default function AdminLoginPage() { + return ( +
+
+ {/* 헤더 */} +
+
+
+ + + +
+ + COMAtching + +
+

+ 관리자 콘솔에 로그인하세요 +

+
+ + {/* 로그인 카드 */} +
+ +
+ +

+ 관리자 계정이 필요하신 경우 시스템 관리자에게 문의하세요 +

+
+
+ ); +} diff --git a/app/adminpage/products/_components/AdminProducts.tsx b/app/adminpage/products/_components/AdminProducts.tsx new file mode 100644 index 0000000..28a0969 --- /dev/null +++ b/app/adminpage/products/_components/AdminProducts.tsx @@ -0,0 +1,248 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import { useAdminProducts, useDeleteProduct } from "@/hooks/useAdminProducts"; +import ProductCreateForm from "./ProductCreateForm"; +import { + ArrowLeft, + Package, + Loader2, + Plus, + Trash2, + X, + Inbox, + ToggleLeft, + ToggleRight, +} from "lucide-react"; + +type FilterType = "all" | "bundle" | "individual"; + +export default function AdminProducts() { + const [filter, setFilter] = useState("all"); + const [showCreateForm, setShowCreateForm] = useState(false); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const isBundleParam = + filter === "all" ? undefined : filter === "bundle" ? true : false; + + const { + data: productsData, + isLoading, + refetch, + } = useAdminProducts(isBundleParam); + const deleteMutation = useDeleteProduct(); + + const products = productsData?.data ?? []; + + const handleDelete = (productId: number) => { + if (deleteConfirmId === productId) { + deleteMutation.mutate(productId, { + onSuccess: () => setDeleteConfirmId(null), + }); + } else { + setDeleteConfirmId(productId); + } + }; + + const formatPrice = (price: number) => price.toLocaleString("ko-KR") + "원"; + + return ( +
+ {/* 헤더 */} +
+
+ + + +
+
+ +
+
+

+ 상품 관리 +

+

+ 전체 {products.length}개 상품 +

+
+
+
+ + +
+ + {/* 상품 등록 폼 */} + {showCreateForm && ( +
+ { + setShowCreateForm(false); + refetch(); + }} + /> +
+ )} + + {/* 필터 탭 */} +
+ {( + [ + { key: "all", label: "전체" }, + { key: "bundle", label: "번들" }, + { key: "individual", label: "개별" }, + ] as const + ).map(({ key, label }) => ( + + ))} +
+ + {/* 상품 목록 */} + {isLoading ? ( +
+ +

+ 상품 목록 불러오는 중... +

+
+ ) : products.length === 0 ? ( +
+
+ +
+

+ 등록된 상품이 없습니다 +

+

+ 상품 등록 버튼을 눌러 새 상품을 추가하세요 +

+
+ ) : ( +
+ {products.map((product) => ( +
+ {/* 상품 정보 */} +
+
+

+ {product.name} +

+ + {/* 상태 배지 */} +
+ {product.isActive ? ( + + + 판매 중 + + ) : ( + + + 판매 중지 + + )} + {product.isBundle && ( + + 번들 + + )} +
+
+ +

{product.description}

+ + {/* 구성품 */} +
+ {product.rewards.map((r) => ( + + {r.itemName} ×{r.quantity} + + ))} + {product.bonusRewards.map((r) => ( + + +{r.itemName} ×{r.quantity} (보너스) + + ))} +
+
+ + {/* 가격 + 액션 */} +
+
+

가격

+

+ {formatPrice(product.price)} +

+

+ 순서: {product.displayOrder} +

+
+ + {product.isActive && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/adminpage/products/_components/ProductCreateForm.tsx b/app/adminpage/products/_components/ProductCreateForm.tsx new file mode 100644 index 0000000..45db92b --- /dev/null +++ b/app/adminpage/products/_components/ProductCreateForm.tsx @@ -0,0 +1,357 @@ +"use client"; + +import React, { useState } from "react"; +import { + useCreateProduct, + type CreateProductBody, + type CreateProductReward, +} from "@/hooks/useAdminProducts"; +import { Loader2, Plus, Minus } from "lucide-react"; + +interface ProductCreateFormProps { + onSuccess: () => void; +} + +const EMPTY_REWARD: CreateProductReward = { + itemType: "MATCHING_TICKET", + quantity: 1, +}; + +export default function ProductCreateForm({ + onSuccess, +}: ProductCreateFormProps) { + const createMutation = useCreateProduct(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [price, setPrice] = useState(1000); + const [displayOrder, setDisplayOrder] = useState(0); + const [isActive, setIsActive] = useState(true); + const [isBundle, setIsBundle] = useState(false); + const [rewards, setRewards] = useState([ + { ...EMPTY_REWARD }, + ]); + const [bonusRewards, setBonusRewards] = useState([]); + const [error, setError] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + // 기본 유효성 검증 + if (!name.trim()) { + setError("상품명을 입력해주세요."); + return; + } + if (!description.trim() || description.length > 50) { + setError("설명은 1~50자로 입력해주세요."); + return; + } + if (price < 1) { + setError("가격은 1원 이상이어야 합니다."); + return; + } + if (rewards.length === 0) { + setError("구성품을 최소 1개 이상 추가해주세요."); + return; + } + + const body: CreateProductBody = { + name: name.trim(), + description: description.trim(), + price, + displayOrder, + isActive, + isBundle, + rewards, + bonusRewards, + }; + + createMutation.mutate(body, { + onSuccess: () => { + // 폼 초기화 + setName(""); + setDescription(""); + setPrice(1000); + setDisplayOrder(0); + setIsActive(true); + setIsBundle(false); + setRewards([{ ...EMPTY_REWARD }]); + setBonusRewards([]); + onSuccess(); + }, + onError: (err) => { + const msg = err.response?.data?.message || "상품 등록에 실패했습니다."; + setError(msg); + }, + }); + }; + + const updateReward = ( + index: number, + field: keyof CreateProductReward, + value: string | number, + list: CreateProductReward[], + setter: React.Dispatch>, + ) => { + const updated = [...list]; + if (field === "itemType") { + updated[index] = { + ...updated[index], + itemType: value as CreateProductReward["itemType"], + }; + } else { + updated[index] = { ...updated[index], quantity: Number(value) }; + } + setter(updated); + }; + + const inputClass = + "h-10 w-full rounded-lg border border-[#2a2d42] bg-[#0f1117] px-3 text-sm text-white placeholder-[#4a4e69] outline-none transition-all focus:border-[#6366f1] focus:ring-1 focus:ring-[#6366f1]/30"; + const selectClass = + "h-10 rounded-lg border border-[#2a2d42] bg-[#0f1117] px-3 text-sm text-white outline-none transition-all focus:border-[#6366f1] focus:ring-1 focus:ring-[#6366f1]/30"; + + return ( +
+

새 상품 등록

+ +
+ {/* 상품명 */} +
+ + setName(e.target.value)} + placeholder="매칭권 10개 (+옵션권 5개)" + required + className={inputClass} + /> +
+ + {/* 설명 */} +
+ + setDescription(e.target.value)} + placeholder="매칭권과 옵션권을 함께 충전해요." + required + maxLength={50} + className={inputClass} + /> +
+ + {/* 가격 */} +
+ + setPrice(Number(e.target.value))} + min={1} + required + className={inputClass} + /> +
+ + {/* 정렬 순서 */} +
+ + setDisplayOrder(Number(e.target.value))} + min={0} + required + className={inputClass} + /> +
+ + {/* 토글: 활성 / 번들 */} +
+ + +
+
+ + {/* 구성품 (rewards) */} +
+
+ + +
+
+ {rewards.map((r, i) => ( +
+ + + updateReward( + i, + "quantity", + e.target.value, + rewards, + setRewards, + ) + } + min={1} + className={inputClass + " !w-20"} + /> + + {rewards.length > 1 && ( + + )} +
+ ))} +
+
+ + {/* 보너스 구성품 */} +
+
+ + +
+ {bonusRewards.length === 0 ? ( +

보너스 구성품 없음

+ ) : ( +
+ {bonusRewards.map((r, i) => ( +
+ + + updateReward( + i, + "quantity", + e.target.value, + bonusRewards, + setBonusRewards, + ) + } + min={1} + className={inputClass + " !w-20"} + /> + + +
+ ))} +
+ )} +
+ + {/* 에러 메시지 */} + {error && ( +

* {error}

+ )} + + {/* 제출 */} + +
+ ); +} diff --git a/app/adminpage/products/page.tsx b/app/adminpage/products/page.tsx new file mode 100644 index 0000000..fdb9d09 --- /dev/null +++ b/app/adminpage/products/page.tsx @@ -0,0 +1,5 @@ +import AdminProducts from "./_components/AdminProducts"; + +export default function AdminProductsPage() { + return ; +} diff --git a/app/globals.css b/app/globals.css index da94dde..0267535 100644 --- a/app/globals.css +++ b/app/globals.css @@ -165,4 +165,11 @@ .scrollbar-hide::-webkit-scrollbar { display: none; /* Chrome, Safari and Opera */ } + + /* 관리자 페이지: root layout의 max-width 제한 해제 */ + div:has(> .admin-layout) { + max-width: none !important; + box-shadow: none !important; + background: #0f1117 !important; + } } diff --git a/hooks/useAdminOrderSocket.ts b/hooks/useAdminOrderSocket.ts new file mode 100644 index 0000000..88e2d11 --- /dev/null +++ b/hooks/useAdminOrderSocket.ts @@ -0,0 +1,189 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Client } from "@stomp/stompjs"; +import SockJS from "sockjs-client"; +import type { AdminOrder } from "./useAdminOrders"; + +/* ── STOMP 이벤트 타입 ── */ +interface OrderCreatedPayload { + orderId: number; + memberId: number; + requestedItemName: string; + requesterRealName: string; + requesterUsername: string; + optionTicketQty: number; + matchingTicketQty: number; + requestedPrice: number; + expectedPrice: number; + status: string; + requestedAt: string; + expiresAt: string; +} + +interface OrderStatusChangedPayload { + orderId: number; + fromStatus: string; + toStatus: string; + decidedAt: string; + decidedByAdminId: number; + reason: string | null; +} + +interface StompEvent { + eventId: number; + eventType: "ORDER_CREATED" | "ORDER_STATUS_CHANGED"; + occurredAt: string; + payload: OrderCreatedPayload | OrderStatusChangedPayload; +} + +export type ConnectionStatus = "connected" | "disconnected" | "reconnecting"; + +/* ── API 응답 래퍼 ── */ +interface AdminOrdersResponse { + code: string; + status: number; + message: string; + data: AdminOrder[]; +} + +/** + * STOMP over SockJS 실시간 주문 모니터링 훅 + */ +export function useAdminOrderSocket() { + const queryClient = useQueryClient(); + const clientRef = useRef(null); + const [status, setStatus] = useState("disconnected"); + + const handleOrderCreated = useCallback( + (payload: OrderCreatedPayload) => { + // 새 주문을 React Query 캐시에 직접 추가 + queryClient.setQueryData(["adminOrders"], (old) => { + if (!old) return old; + + // 이미 존재하는 주문이면 무시 + const exists = old.data.some( + (order) => order.requestId === payload.orderId, + ); + if (exists) return old; + + const newOrder: AdminOrder = { + requestId: payload.orderId, + memberId: payload.memberId, + requestedItemName: payload.requestedItemName, + requesterRealName: payload.requesterRealName, + requesterUsername: payload.requesterUsername, + optionTicketQty: payload.optionTicketQty, + matchingTicketQty: payload.matchingTicketQty, + requestedPrice: payload.requestedPrice, + expectedPrice: payload.expectedPrice, + status: payload.status as AdminOrder["status"], + requestedAt: payload.requestedAt, + expiresAt: payload.expiresAt, + }; + + return { + ...old, + data: [newOrder, ...old.data], + }; + }); + }, + [queryClient], + ); + + const handleOrderStatusChanged = useCallback( + (payload: OrderStatusChangedPayload) => { + // 기존 주문의 상태를 캐시에서 직접 업데이트 + queryClient.setQueryData(["adminOrders"], (old) => { + if (!old) return old; + + return { + ...old, + data: old.data.map((order) => + order.requestId === payload.orderId + ? { + ...order, + status: payload.toStatus as AdminOrder["status"], + } + : order, + ), + }; + }); + }, + [queryClient], + ); + + useEffect(() => { + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + console.error("[STOMP] NEXT_PUBLIC_API_URL is not defined"); + return; + } + + // SockJS URL 구성 + const wsUrl = `${API_URL}/ws/payment`; + + const client = new Client({ + // SockJS를 웹소켓 팩토리로 사용 + webSocketFactory: () => new SockJS(wsUrl) as unknown as WebSocket, + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + + onConnect: () => { + console.log("✅ [STOMP] Connected"); + setStatus("connected"); + + // 관리자 주문 토픽 구독 + client.subscribe("/topic/admin/orders", (message) => { + try { + const event: StompEvent = JSON.parse(message.body); + console.log("[STOMP] Event received:", event.eventType, event); + + switch (event.eventType) { + case "ORDER_CREATED": + handleOrderCreated(event.payload as OrderCreatedPayload); + break; + case "ORDER_STATUS_CHANGED": + handleOrderStatusChanged( + event.payload as OrderStatusChangedPayload, + ); + break; + default: + console.warn("[STOMP] Unknown event type:", event.eventType); + } + } catch (err) { + console.error("[STOMP] Failed to parse message:", err); + } + }); + }, + + onDisconnect: () => { + console.log("🔌 [STOMP] Disconnected"); + setStatus("disconnected"); + }, + + onStompError: (frame) => { + console.error("❌ [STOMP] Error:", frame.headers?.message || frame); + setStatus("disconnected"); + }, + + onWebSocketClose: () => { + console.log("🔄 [STOMP] WebSocket closed, reconnecting..."); + setStatus("reconnecting"); + }, + }); + + client.activate(); + clientRef.current = client; + + return () => { + if (clientRef.current?.connected) { + clientRef.current.deactivate(); + } + }; + }, [handleOrderCreated, handleOrderStatusChanged]); + + return { status }; +} diff --git a/hooks/useAdminOrders.ts b/hooks/useAdminOrders.ts new file mode 100644 index 0000000..92ab6c3 --- /dev/null +++ b/hooks/useAdminOrders.ts @@ -0,0 +1,91 @@ +import { api } from "@/lib/axios"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +/* ── 타입 정의 ── */ +export interface AdminOrder { + requestId: number; + memberId: number; + requestedItemName: string; + requesterRealName: string; + requesterUsername: string; + optionTicketQty: number; + matchingTicketQty: number; + requestedPrice: number; + expectedPrice: number; + status: "PENDING" | "APPROVED" | "REJECTED" | "CANCELED" | "EXPIRED"; + requestedAt: string; + expiresAt: string; +} + +interface ApiResponse { + code: string; + status: number; + message: string; + data: T; +} + +/* ── 대기 주문 목록 조회 ── */ +const fetchAdminOrders = async (): Promise> => { + const { data } = await api.get>( + "/api/v1/admin/payment/requests", + ); + return data; +}; + +/* ── 승인 ── */ +const approveOrder = async (requestId: number): Promise> => { + const { data } = await api.post>( + `/api/v1/admin/payment/approve/${requestId}`, + ); + return data; +}; + +/* ── 거절 ── */ +const rejectOrder = async (requestId: number): Promise> => { + const { data } = await api.post>( + `/api/v1/admin/payment/reject/${requestId}`, + ); + return data; +}; + +/* ── 대기 주문 목록 훅 ── */ +export const useAdminOrders = () => { + return useQuery({ + queryKey: ["adminOrders"], + queryFn: fetchAdminOrders, + refetchInterval: 30_000, // 30초 간격 백그라운드 갱신 (STOMP 보완) + }); +}; + +/* ── 승인 뮤테이션 ── */ +export const useApproveOrder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (requestId: number) => approveOrder(requestId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["adminOrders"] }); + }, + onError: (error: AxiosError<{ code: string; message: string }>) => { + const errorData = error.response?.data; + console.error("❌ 승인 실패:", errorData?.message || error.message); + }, + }); +}; + +/* ── 거절 뮤테이션 ── */ +export const useRejectOrder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (requestId: number) => rejectOrder(requestId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["adminOrders"] }); + }, + onError: (error: AxiosError<{ code: string; message: string }>) => { + const errorData = error.response?.data; + console.error("❌ 거절 실패:", errorData?.message || error.message); + }, + }); +}; diff --git a/hooks/useAdminProducts.ts b/hooks/useAdminProducts.ts new file mode 100644 index 0000000..68288bf --- /dev/null +++ b/hooks/useAdminProducts.ts @@ -0,0 +1,103 @@ +import { api } from "@/lib/axios"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import type { ShopProduct, ShopReward } from "./useShopProducts"; + +/* ── 타입 정의 ── */ +interface ApiResponse { + code: string; + status: number; + message: string; + data: T; +} + +export interface CreateProductReward { + itemType: "MATCHING_TICKET" | "OPTION_TICKET"; + quantity: number; +} + +export interface CreateProductBody { + name: string; + description: string; + price: number; + displayOrder: number; + isActive: boolean; + isBundle: boolean; + rewards: CreateProductReward[]; + bonusRewards: CreateProductReward[]; +} + +/* ── 관리자 상품 목록 조회 ── */ +const fetchAdminProducts = async ( + isBundle?: boolean, +): Promise> => { + const params = isBundle !== undefined ? { isBundle } : {}; + const { data } = await api.get>( + "/api/v1/admin/shop/products", + { params }, + ); + return data; +}; + +/* ── 상품 등록 ── */ +const createProduct = async ( + body: CreateProductBody, +): Promise> => { + const { data } = await api.post>( + "/api/v1/admin/shop/products", + body, + ); + return data; +}; + +/* ── 상품 삭제(판매 중지) ── */ +const deleteProduct = async (productId: number): Promise> => { + const { data } = await api.delete>( + `/api/v1/admin/shop/products/${productId}`, + ); + return data; +}; + +/* ── 훅: 상품 목록 조회 ── */ +export const useAdminProducts = (isBundle?: boolean) => { + return useQuery({ + queryKey: ["adminProducts", isBundle], + queryFn: () => fetchAdminProducts(isBundle), + }); +}; + +/* ── 훅: 상품 등록 ── */ +export const useCreateProduct = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: CreateProductBody) => createProduct(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["adminProducts"] }); + }, + onError: (error: AxiosError<{ code: string; message: string }>) => { + console.error( + "❌ 상품 등록 실패:", + error.response?.data?.message || error.message, + ); + }, + }); +}; + +/* ── 훅: 상품 삭제(판매 중지) ── */ +export const useDeleteProduct = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (productId: number) => deleteProduct(productId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["adminProducts"] }); + }, + onError: (error: AxiosError<{ code: string; message: string }>) => { + console.error( + "❌ 상품 삭제 실패:", + error.response?.data?.message || error.message, + ); + }, + }); +}; diff --git a/lib/actions/adminLoginAction.ts b/lib/actions/adminLoginAction.ts new file mode 100644 index 0000000..7db6660 --- /dev/null +++ b/lib/actions/adminLoginAction.ts @@ -0,0 +1,101 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { serverApi } from "@/lib/server-api"; +import { isAxiosError } from "axios"; + +type LoginState = { + success: boolean; + message: string; +}; + +type LoginResponse = { + code: string; + status: number; + message: string; +}; + +/** + * 관리자 전용 로그인 액션 + * 기존 loginAction과 동일한 로직이지만, 성공 시 /adminpage/main으로 리다이렉트합니다. + */ +export async function adminLoginAction( + prevState: LoginState, + formData: FormData, +): Promise { + const email = formData.get("email"); + const password = formData.get("password"); + + try { + const { setCookie } = await serverApi.post({ + path: "/api/auth/login", + body: { email, password }, + }); + + // 🍪 백엔드로부터 받은 쿠키가 있다면 브라우저에 배달해줍니다. + if (setCookie) { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + + setCookie.forEach((cookieStr) => { + const [nameValue, ...options] = cookieStr.split(";"); + const [name, ...nameParts] = nameValue.split("="); + const value = nameParts.join("="); + + const cookieOptions: { + path?: string; + httpOnly?: boolean; + secure?: boolean; + maxAge?: number; + expires?: Date; + sameSite?: "strict" | "lax" | "none" | boolean; + } = {}; + + options.forEach((opt) => { + const [key, ...valueParts] = opt.trim().split("="); + const val = valueParts.join("="); + const k = key.toLowerCase(); + if (k === "path") cookieOptions.path = val; + if (k === "httponly") cookieOptions.httpOnly = true; + if (k === "secure") cookieOptions.secure = true; + if (k === "max-age") cookieOptions.maxAge = parseInt(val); + if (k === "expires") cookieOptions.expires = new Date(val); + if (k === "samesite") + cookieOptions.sameSite = val.toLowerCase() as + | "strict" + | "lax" + | "none"; + }); + + cookieStore.set(name, value, cookieOptions); + }); + } + } catch (error) { + if ( + error instanceof Error && + (error as Error & { digest?: string }).digest?.startsWith("NEXT_REDIRECT") + ) { + throw error; + } + + if (isAxiosError(error)) { + const status = error.response?.status; + if (status === 400 || status === 401) { + return { success: false, message: "이메일 혹은 비밀번호가 틀립니다" }; + } + console.error("[adminLoginAction] API error", { + status, + data: error.response?.data, + }); + } else { + console.error("[adminLoginAction] Unexpected error", error); + } + return { + success: false, + message: "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", + }; + } + + // 관리자는 항상 /adminpage/main으로 이동 + redirect("/adminpage/main"); +} diff --git a/package.json b/package.json index 8c0857f..fdf2105 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", + "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.2", "axios": "^1.13.4", @@ -25,6 +26,7 @@ "next": "16.1.3", "react": "19.2.3", "react-dom": "19.2.3", + "sockjs-client": "^1.6.1", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", "zustand": "^5.0.12" @@ -34,6 +36,7 @@ "@types/node": "^20.19.30", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", + "@types/sockjs-client": "^1.5.4", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9.39.2", "eslint-config-next": "16.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e58cb91..8583998 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@stomp/stompjs': + specifier: ^7.3.0 + version: 7.3.0 '@tanstack/react-query': specifier: ^5.90.20 version: 5.90.20(react@19.2.3) @@ -44,6 +47,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + sockjs-client: + specifier: ^1.6.1 + version: 1.6.1 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -66,6 +72,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.10) + '@types/sockjs-client': + specifier: ^1.5.4 + version: 1.5.4 babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -882,6 +891,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@stomp/stompjs@7.3.0': + resolution: {integrity: sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1013,6 +1025,9 @@ packages: '@types/react@19.2.10': resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} + '@types/sockjs-client@1.5.4': + resolution: {integrity: sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==} + '@typescript-eslint/eslint-plugin@8.54.0': resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1610,6 +1625,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource@2.0.2: + resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1813,6 +1832,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2352,6 +2374,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2409,6 +2434,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2514,6 +2542,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + sockjs-client@1.6.1: + resolution: {integrity: sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==} + engines: {node: '>=12'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2685,6 +2717,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -3648,6 +3683,8 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@stomp/stompjs@7.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3759,6 +3796,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sockjs-client@1.5.4': {} + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4520,6 +4559,8 @@ snapshots: eventemitter3@5.0.4: {} + eventsource@2.0.2: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -4732,6 +4773,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -5213,6 +5256,8 @@ snapshots: punycode@2.3.1: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} react-dom@19.2.3(react@19.2.3): @@ -5273,6 +5318,8 @@ snapshots: require-directory@2.1.1: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -5424,6 +5471,16 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + sockjs-client@1.6.1: + dependencies: + debug: 3.2.7 + eventsource: 2.0.2 + faye-websocket: 0.11.4 + inherits: 2.0.4 + url-parse: 1.5.10 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} stable-hash@0.0.5: {} @@ -5650,6 +5707,11 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + use-callback-ref@1.3.3(@types/react@19.2.10)(react@19.2.3): dependencies: react: 19.2.3 From 1e922530f8d5ca1fdc69e77609ba61aea446ff68 Mon Sep 17 00:00:00 2001 From: dasosann Date: Wed, 29 Apr 2026 16:43:26 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/adminpage/_components/AdminLoginForm.tsx | 2 +- app/adminpage/dashboard/_components/AdminDashboard.tsx | 4 ++-- app/adminpage/dashboard/_components/OrderCard.tsx | 2 +- app/adminpage/products/_components/AdminProducts.tsx | 5 ++++- app/adminpage/products/_components/ProductCreateForm.tsx | 2 +- hooks/{ => admin}/useAdminOrderSocket.ts | 6 ------ hooks/{ => admin}/useAdminOrders.ts | 0 hooks/{ => admin}/useAdminProducts.ts | 2 +- lib/actions/{ => admin}/adminLoginAction.ts | 0 9 files changed, 10 insertions(+), 13 deletions(-) rename hooks/{ => admin}/useAdminOrderSocket.ts (94%) rename hooks/{ => admin}/useAdminOrders.ts (100%) rename hooks/{ => admin}/useAdminProducts.ts (97%) rename lib/actions/{ => admin}/adminLoginAction.ts (100%) diff --git a/app/adminpage/_components/AdminLoginForm.tsx b/app/adminpage/_components/AdminLoginForm.tsx index e6caf98..08d12ba 100644 --- a/app/adminpage/_components/AdminLoginForm.tsx +++ b/app/adminpage/_components/AdminLoginForm.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useActionState } from "react"; -import { adminLoginAction } from "@/lib/actions/adminLoginAction"; +import { adminLoginAction } from "@/lib/actions/admin/adminLoginAction"; export default function AdminLoginForm() { const [state, formAction, isPending] = useActionState(adminLoginAction, { diff --git a/app/adminpage/dashboard/_components/AdminDashboard.tsx b/app/adminpage/dashboard/_components/AdminDashboard.tsx index 958ac46..bae76c1 100644 --- a/app/adminpage/dashboard/_components/AdminDashboard.tsx +++ b/app/adminpage/dashboard/_components/AdminDashboard.tsx @@ -2,8 +2,8 @@ import React from "react"; import Link from "next/link"; -import { useAdminOrders } from "@/hooks/useAdminOrders"; -import { useAdminOrderSocket } from "@/hooks/useAdminOrderSocket"; +import { useAdminOrders } from "@/hooks/admin/useAdminOrders"; +import { useAdminOrderSocket } from "@/hooks/admin/useAdminOrderSocket"; import OrderCard from "./OrderCard"; import { ArrowLeft, diff --git a/app/adminpage/dashboard/_components/OrderCard.tsx b/app/adminpage/dashboard/_components/OrderCard.tsx index 07d43aa..b1691db 100644 --- a/app/adminpage/dashboard/_components/OrderCard.tsx +++ b/app/adminpage/dashboard/_components/OrderCard.tsx @@ -5,7 +5,7 @@ import { type AdminOrder, useApproveOrder, useRejectOrder, -} from "@/hooks/useAdminOrders"; +} from "@/hooks/admin/useAdminOrders"; import ExpiryCountdown from "./ExpiryCountdown"; import { Check, X, User, Ticket, Tag, Clock, Coins } from "lucide-react"; diff --git a/app/adminpage/products/_components/AdminProducts.tsx b/app/adminpage/products/_components/AdminProducts.tsx index 28a0969..77ef953 100644 --- a/app/adminpage/products/_components/AdminProducts.tsx +++ b/app/adminpage/products/_components/AdminProducts.tsx @@ -2,7 +2,10 @@ import React, { useState } from "react"; import Link from "next/link"; -import { useAdminProducts, useDeleteProduct } from "@/hooks/useAdminProducts"; +import { + useAdminProducts, + useDeleteProduct, +} from "@/hooks/admin/useAdminProducts"; import ProductCreateForm from "./ProductCreateForm"; import { ArrowLeft, diff --git a/app/adminpage/products/_components/ProductCreateForm.tsx b/app/adminpage/products/_components/ProductCreateForm.tsx index 45db92b..3c53a30 100644 --- a/app/adminpage/products/_components/ProductCreateForm.tsx +++ b/app/adminpage/products/_components/ProductCreateForm.tsx @@ -5,7 +5,7 @@ import { useCreateProduct, type CreateProductBody, type CreateProductReward, -} from "@/hooks/useAdminProducts"; +} from "@/hooks/admin/useAdminProducts"; import { Loader2, Plus, Minus } from "lucide-react"; interface ProductCreateFormProps { diff --git a/hooks/useAdminOrderSocket.ts b/hooks/admin/useAdminOrderSocket.ts similarity index 94% rename from hooks/useAdminOrderSocket.ts rename to hooks/admin/useAdminOrderSocket.ts index 88e2d11..0c61f35 100644 --- a/hooks/useAdminOrderSocket.ts +++ b/hooks/admin/useAdminOrderSocket.ts @@ -58,11 +58,9 @@ export function useAdminOrderSocket() { const handleOrderCreated = useCallback( (payload: OrderCreatedPayload) => { - // 새 주문을 React Query 캐시에 직접 추가 queryClient.setQueryData(["adminOrders"], (old) => { if (!old) return old; - // 이미 존재하는 주문이면 무시 const exists = old.data.some( (order) => order.requestId === payload.orderId, ); @@ -94,7 +92,6 @@ export function useAdminOrderSocket() { const handleOrderStatusChanged = useCallback( (payload: OrderStatusChangedPayload) => { - // 기존 주문의 상태를 캐시에서 직접 업데이트 queryClient.setQueryData(["adminOrders"], (old) => { if (!old) return old; @@ -121,11 +118,9 @@ export function useAdminOrderSocket() { return; } - // SockJS URL 구성 const wsUrl = `${API_URL}/ws/payment`; const client = new Client({ - // SockJS를 웹소켓 팩토리로 사용 webSocketFactory: () => new SockJS(wsUrl) as unknown as WebSocket, reconnectDelay: 5000, heartbeatIncoming: 10000, @@ -135,7 +130,6 @@ export function useAdminOrderSocket() { console.log("✅ [STOMP] Connected"); setStatus("connected"); - // 관리자 주문 토픽 구독 client.subscribe("/topic/admin/orders", (message) => { try { const event: StompEvent = JSON.parse(message.body); diff --git a/hooks/useAdminOrders.ts b/hooks/admin/useAdminOrders.ts similarity index 100% rename from hooks/useAdminOrders.ts rename to hooks/admin/useAdminOrders.ts diff --git a/hooks/useAdminProducts.ts b/hooks/admin/useAdminProducts.ts similarity index 97% rename from hooks/useAdminProducts.ts rename to hooks/admin/useAdminProducts.ts index 68288bf..0b24831 100644 --- a/hooks/useAdminProducts.ts +++ b/hooks/admin/useAdminProducts.ts @@ -1,7 +1,7 @@ import { api } from "@/lib/axios"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; -import type { ShopProduct, ShopReward } from "./useShopProducts"; +import type { ShopProduct, ShopReward } from "@/hooks/useShopProducts"; /* ── 타입 정의 ── */ interface ApiResponse { diff --git a/lib/actions/adminLoginAction.ts b/lib/actions/admin/adminLoginAction.ts similarity index 100% rename from lib/actions/adminLoginAction.ts rename to lib/actions/admin/adminLoginAction.ts From 45c5155c6f2e1ae85ff0e6d6a787077258b9a906 Mon Sep 17 00:00:00 2001 From: dasosann Date: Wed, 29 Apr 2026 16:56:14 +0900 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/matching-list/_components/YesMatchingList.tsx | 4 ++-- lib/constants/date.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 lib/constants/date.ts diff --git a/app/matching-list/_components/YesMatchingList.tsx b/app/matching-list/_components/YesMatchingList.tsx index f67c971..b51c3c6 100644 --- a/app/matching-list/_components/YesMatchingList.tsx +++ b/app/matching-list/_components/YesMatchingList.tsx @@ -12,6 +12,7 @@ import { Search, ArrowUpNarrowWide, Loader2 } from "lucide-react"; import MatchingListCard from "./MatchingListCard"; import { getAge } from "@/lib/utils/date"; import SortDrawer, { SortOrder } from "./SortDrawer"; +import { CURRENT_YEAR } from "@/lib/constants/date"; const SORT_LABELS: Record = { oldest: "오래된 순", @@ -38,7 +39,6 @@ const YesMatchingList = ({ const filteredHistory = useMemo(() => { let result = [...history]; - const currentYear = new Date().getFullYear(); // 검색 필터 if (searchQuery.trim()) { @@ -50,7 +50,7 @@ const YesMatchingList = ({ p.mbti.toLowerCase().includes(query) || p.major.toLowerCase().includes(query) || (p.birthDate && - String(getAge(p.birthDate, currentYear)).includes(query)) + String(getAge(p.birthDate, CURRENT_YEAR)).includes(query)) ); }); } diff --git a/lib/constants/date.ts b/lib/constants/date.ts new file mode 100644 index 0000000..9cbfa02 --- /dev/null +++ b/lib/constants/date.ts @@ -0,0 +1 @@ +export const CURRENT_YEAR = 2026;