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/adminpage/_components/AdminLoginForm.tsx b/app/adminpage/_components/AdminLoginForm.tsx
new file mode 100644
index 0000000..08d12ba
--- /dev/null
+++ b/app/adminpage/_components/AdminLoginForm.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import React, { useActionState } from "react";
+import { adminLoginAction } from "@/lib/actions/admin/adminLoginAction";
+
+export default function AdminLoginForm() {
+ const [state, formAction, isPending] = useActionState(adminLoginAction, {
+ success: false,
+ message: "",
+ });
+
+ return (
+
+ );
+}
diff --git a/app/adminpage/dashboard/_components/AdminDashboard.tsx b/app/adminpage/dashboard/_components/AdminDashboard.tsx
new file mode 100644
index 0000000..bae76c1
--- /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/admin/useAdminOrders";
+import { useAdminOrderSocket } from "@/hooks/admin/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 (
+
+ {/* 헤더 */}
+
+
+ {/* 주문 목록 */}
+ {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..b1691db
--- /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/admin/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 (
+
+
+ {/* 헤더 */}
+
+
+ {/* 로그인 카드 */}
+
+
+
+ 관리자 계정이 필요하신 경우 시스템 관리자에게 문의하세요
+
+
+
+ );
+}
diff --git a/app/adminpage/products/_components/AdminProducts.tsx b/app/adminpage/products/_components/AdminProducts.tsx
new file mode 100644
index 0000000..77ef953
--- /dev/null
+++ b/app/adminpage/products/_components/AdminProducts.tsx
@@ -0,0 +1,251 @@
+"use client";
+
+import React, { useState } from "react";
+import Link from "next/link";
+import {
+ useAdminProducts,
+ useDeleteProduct,
+} from "@/hooks/admin/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..3c53a30
--- /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/admin/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 (
+
+ );
+}
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/app/main/_components/ScreenMainPage.tsx b/app/main/_components/ScreenMainPage.tsx
index b2c34e4..21f30f5 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,17 +49,31 @@ const ScreenMainPage = () => {
};
// 매칭 히스토리 데이터에서 파트너 정보를 추출하여 프로필 목록 생성
- const profileList: ProfileData[] =
- historyData?.data.content.map(({ partner }) => ({
- ...partner,
- // API에서 필드명이 변경되었거나 null로 올 수 있는 필드들만 안전하게 변환
- profileImageUrl: partner.profileImageKey,
+ 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,
+ 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: { category: string; name: string }) => h.name,
+ ),
tags: partner.tags ?? undefined,
song: partner.song ?? undefined,
- })) || [];
+ }),
+ );
return (
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/app/matching-list/_components/MatchingListCard.tsx b/app/matching-list/_components/MatchingListCard.tsx
new file mode 100644
index 0000000..390b1d0
--- /dev/null
+++ b/app/matching-list/_components/MatchingListCard.tsx
@@ -0,0 +1,285 @@
+"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";
+import { getAge } from "@/lib/utils/date";
+
+/* ── 태그 컴포넌트 ── */
+const Tag = ({ text }: { text: string }) => (
+
+
+ {text}
+
+
+);
+
+/* ── 프로필 헤더 (이미지 + 닉네임 + 액션 아이콘) ── */
+const CardHeader = ({
+ partner,
+ isFavorite,
+ onFavoriteToggle,
+}: {
+ partner: MatchingPartner;
+ isFavorite: boolean;
+ onFavoriteToggle?: () => void;
+}) => (
+
+ {/* 프로필 이미지 (48x48 container, 44x44 image) */}
+
+
+ {/* 닉네임 */}
+
+ 내가 뽑은 사람
+
+ {partner.nickname || "익명"}
+
+
+
+ {/* 액션 아이콘들 */}
+
+
+
+
+
+
+);
+
+/* ── 나이 + MBTI + 연락빈도 ── */
+const CardStats = ({ partner }: { partner: MatchingPartner }) => (
+
+
+ 나이
+
+ {getAge(partner.birthDate)}
+
+
+
+ MBTI
+ {partner.mbti}
+
+
+
+ 연락빈도
+
+
+ {partner.contactFrequency}
+
+
+
+);
+
+/* ── 확장 가능한 상세 정보 ── */
+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 || "잘 부탁드립니다!! 😆"}
+
+
+
+);
+
+/* ── 소셜 ID 표시 ── */
+const SocialIdDisplay = ({ partner }: { partner: MatchingPartner }) => {
+ if (!partner.socialType || !partner.socialAccountId) return null;
+
+ if (partner.socialType === "KAKAO") {
+ return (
+
+
+
+ {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-start 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..3631046
--- /dev/null
+++ b/app/matching-list/_components/NoMatchingList.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import React from "react";
+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 (
+
+ {/* her image */}
+
+
+
+
+ {/* Message */}
+
+ 아직 매칭된 상대가 없어요.
+
+ 아직{" "}
+
+ {count.toLocaleString()}
+
+ 명이 {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..379bcfb
--- /dev/null
+++ b/app/matching-list/_components/ScreenMatchingList.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { BackButton } from "@/components/ui/BackButton";
+import React, { useMemo } 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, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useMatchingHistory();
+
+ // 모든 페이지의 content를 하나의 배열로 평탄화
+ const allHistory = useMemo(() => {
+ const apiData = data?.pages.flatMap((page) => page.data.content) ?? [];
+ const result = apiData.length > 0 ? apiData : DUMMY_MATCHING_HISTORY;
+ console.log("Processed allHistory data:", result);
+ return result;
+ }, [data]);
+
+ return (
+
+
+
+ 내가 뽑은 상대들을 여기서 확인할 수 있어요.
+
+ 용기내서 연락해보세요!
+
+
+ {allHistory.length === 0 ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+};
+
+export default ScreenMatchingList;
diff --git a/app/matching-list/_components/SortDrawer.tsx b/app/matching-list/_components/SortDrawer.tsx
new file mode 100644
index 0000000..cda3492
--- /dev/null
+++ b/app/matching-list/_components/SortDrawer.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import React, { useState } from "react";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
+import { Check } from "lucide-react";
+
+export type SortOrder = "oldest" | "newest" | "age";
+
+interface SortOption {
+ value: SortOrder;
+ label: string;
+}
+
+const SORT_OPTIONS: SortOption[] = [
+ { value: "oldest", label: "오래된순" },
+ { value: "newest", label: "최신순" },
+ { value: "age", label: "나이순" },
+];
+
+interface SortDrawerProps {
+ currentSort: SortOrder;
+ onSortChange: (sort: SortOrder) => void;
+ trigger: React.ReactNode;
+}
+
+export default function SortDrawer({
+ currentSort,
+ onSortChange,
+ trigger,
+}: SortDrawerProps) {
+ const [selected, setSelected] = useState(currentSort);
+
+ return (
+
+ {trigger}
+
+
+ {/* 옵션 리스트 */}
+
+ {SORT_OPTIONS.map((option) => {
+ const isActive = selected === option.value;
+ return (
+
+ );
+ })}
+
+
+ {/* 확인 버튼 */}
+
+
+
+
+
+
+ );
+}
diff --git a/app/matching-list/_components/YesMatchingList.tsx b/app/matching-list/_components/YesMatchingList.tsx
new file mode 100644
index 0000000..b51c3c6
--- /dev/null
+++ b/app/matching-list/_components/YesMatchingList.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import React, {
+ useState,
+ useMemo,
+ useRef,
+ useEffect,
+ useCallback,
+} from "react";
+import { MatchingHistoryItem } from "@/hooks/useMatchingHistory";
+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: "오래된 순",
+ newest: "최신 순",
+ age: "나이 순",
+};
+
+interface YesMatchingListProps {
+ history: MatchingHistoryItem[];
+ fetchNextPage: () => void;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+}
+
+const YesMatchingList = ({
+ history,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+}: YesMatchingListProps) => {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
+ const [sortOrder, setSortOrder] = useState("newest");
+
+ 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, CURRENT_YEAR)).includes(query))
+ );
+ });
+ }
+
+ // 즐겨찾기 필터
+ if (showFavoritesOnly) {
+ result = result.filter((item) => item.favorite);
+ }
+
+ // 정렬
+ 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;
+ });
+
+ return result;
+ }, [history, searchQuery, showFavoritesOnly, sortOrder]);
+
+ /* ── 무한 스크롤: 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 (
+
+ {/* 검색 바 */}
+
+ setSearchQuery(e.target.value)}
+ className="typo-12-500 text-color-text-black placeholder:text-color-gray-400 flex-1 bg-transparent outline-none"
+ />
+
+
+
+ {/* 필터 바 */}
+
+ {/* 즐겨찾기 필터 */}
+
+
+ {/* 정렬 - SortDrawer 트리거 */}
+
+
+
+ {SORT_LABELS[sortOrder]}
+
+
+ }
+ />
+
+
+ {/* 카드 리스트 */}
+
+ {filteredHistory.length > 0 ? (
+ filteredHistory.map((item) => (
+
+ ))
+ ) : (
+
+
+ 검색 결과가 없습니다.
+
+
+ )}
+
+
+ {/* 무한 스크롤 sentinel + 로딩 인디케이터 */}
+
+ {isFetchingNextPage && (
+
+ )}
+ {!hasNextPage && filteredHistory.length > 0 && (
+
+ 모든 매칭 결과를 불러왔어요.
+
+ )}
+
+
+ );
+};
+
+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..b0ca7a9
--- /dev/null
+++ b/app/matching-list/_components/dummyData.ts
@@ -0,0 +1,312 @@
+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,
+ profileImageKey: null,
+ socialType: "INSTAGRAM",
+ socialAccountId: "spring_sunshine_",
+ university: "한국대학교",
+ major: "경영학과",
+ contactFrequency: "자주",
+ hobbies: [
+ { category: "CULTURE", name: "카페투어" },
+ { category: "TRAVEL", name: "여행" },
+ { category: "SPORTS", name: "필라테스" },
+ ],
+ tags: [{ tag: "친절함" }, { tag: "활발함" }],
+ song: "IU - Love Wins All",
+ 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,
+ profileImageKey: null,
+ socialType: "INSTAGRAM",
+ socialAccountId: "winter_sea22",
+ university: "서울대학교",
+ major: "심리학과",
+ contactFrequency: "보통",
+ hobbies: [
+ { category: "CULTURE", name: "독서" },
+ { category: "MUSIC", name: "피아노" },
+ ],
+ tags: [{ tag: "조용함" }, { tag: "진중함" }],
+ song: "백예린 - 산책",
+ 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,
+ profileImageKey: null,
+ socialType: "KAKAO",
+ socialAccountId: "moonlight_girl",
+ university: "연세대학교",
+ major: "컴퓨터공학과",
+ contactFrequency: "자주",
+ hobbies: [
+ { category: "GAME", name: "보드게임" },
+ { category: "DAILY", name: "코딩" },
+ { category: "SPORTS", name: "배드민턴" },
+ { category: "CULTURE", name: "영화감상" },
+ ],
+ tags: [{ tag: "재치있음" }, { tag: "논리적" }],
+ song: "NewJeans - Ditto",
+ 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,
+ profileImageKey: null,
+ socialType: "INSTAGRAM",
+ socialAccountId: "summer_scent__",
+ university: "고려대학교",
+ major: "미술학과",
+ contactFrequency: "적음",
+ hobbies: [
+ { category: "MUSIC", name: "그림" },
+ { category: "TRAVEL", name: "사진촬영" },
+ ],
+ tags: [{ tag: "예술적" }, { tag: "차분함" }],
+ song: "10CM - 그라데이션",
+ 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,
+ profileImageKey: null,
+ socialType: "INSTAGRAM",
+ socialAccountId: "star_narae",
+ university: "한양대학교",
+ major: "체육학과",
+ contactFrequency: "자주",
+ hobbies: [
+ { category: "SPORTS", name: "헬스" },
+ { category: "SPORTS", name: "러닝" },
+ { category: "DAILY", name: "요리" },
+ ],
+ tags: [{ tag: "에너지넘침" }, { tag: "건강함" }],
+ song: "LE SSERAFIM - UNFORGIVEN",
+ 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,
+ profileImageKey: null,
+ socialType: "INSTAGRAM",
+ socialAccountId: "flower_between",
+ university: "이화여자대학교",
+ major: "국문학과",
+ contactFrequency: "보통",
+ hobbies: [
+ { category: "CULTURE", name: "시 쓰기" },
+ { category: "CULTURE", name: "전시회" },
+ ],
+ tags: [{ tag: "섬세함" }, { tag: "감성적" }],
+ song: "성시경 - 거리에서",
+ 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,
+ profileImageKey: null,
+ socialType: null,
+ socialAccountId: null,
+ university: "성균관대학교",
+ major: "의상학과",
+ contactFrequency: "자주",
+ hobbies: [
+ { category: "DAILY", name: "쇼핑" },
+ { category: "CULTURE", name: "뮤지컬" },
+ ],
+ tags: null,
+ song: null,
+ 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,
+ profileImageKey: null,
+ socialType: "INSTAGRAM",
+ socialAccountId: "wave_sound25",
+ university: "KAIST",
+ major: "전산학부",
+ contactFrequency: "적음",
+ hobbies: [
+ { category: "GAME", name: "체스" },
+ { category: "DAILY", name: "독서" },
+ { category: "DAILY", name: "자기계발" },
+ ],
+ tags: [{ tag: "공부하는" }, { tag: "계획적" }],
+ song: "Day6 - 한 페이지가 될 수 있게",
+ 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,
+ profileImageKey: null,
+ socialType: null,
+ socialAccountId: null,
+ university: "한국대학교",
+ major: "(알 수 없음)",
+ contactFrequency: "보통",
+ hobbies: [{ category: "SPORTS", name: "헬스" }],
+ tags: null,
+ song: null,
+ 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,
+ profileImageKey: null,
+ socialType: "INSTAGRAM",
+ socialAccountId: "dawn_dew_18",
+ university: "중앙대학교",
+ major: "기계공학과",
+ contactFrequency: "보통",
+ hobbies: [
+ { category: "SPORTS", name: "자전거" },
+ { category: "TRAVEL", name: "캠핑" },
+ { category: "DAILY", name: "요리" },
+ ],
+ tags: [{ tag: "쿨함" }, { tag: "독립적" }],
+ song: "ZICO - Any Song",
+ 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..ac92782
--- /dev/null
+++ b/app/matching-list/page.tsx
@@ -0,0 +1,36 @@
+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.prefetchInfiniteQuery({
+ queryKey: ["matchingHistory"],
+ 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/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 b01e59e..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 {
@@ -12,12 +13,15 @@ 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";
/* ── Props ── */
interface ConfirmChargeDrawerProps {
trigger: React.ReactNode;
+ /** 상품 ID */
+ productId: number;
/** 입금액 (원 단위 숫자) */
amount: number;
/** 입금자명 (사전 설정된 값) */
@@ -26,11 +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);
@@ -55,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 버튼 */}
-