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 ( +
+ {/* 이메일 */} +
+ + +
+ + {/* 비밀번호 */} +
+ + + {!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..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 ( +
+ {/* 헤더 */} +
+
+ + + +
+
+ +
+
+

+ 결제 승인 관리 +

+

+ 대기 중인 요청{" "} + + {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..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 ( +
+
+ {/* 헤더 */} +
+
+
+ + + +
+ + COMAtching + +
+

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

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

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

+
+
+ ); +} 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 ( +
+

새 상품 등록

+ +
+ {/* 상품명 */} +
+ + 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/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 +
+
+ + {/* 닉네임 */} +
+ 내가 뽑은 사람 + + {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 ( +
+ 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-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 */} +
+ her +
+ + {/* 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 버튼 */} - {/* Toss 링크 */} 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/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 5dc8d3e..008c9d3 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,11 @@ 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} + productId={product.id} />
); diff --git a/components/common/charge/ShopContent.tsx b/components/common/charge/ShopContent.tsx index cba821b..21530c0 100644 --- a/components/common/charge/ShopContent.tsx +++ b/components/common/charge/ShopContent.tsx @@ -1,68 +1,79 @@ "use client"; import React from "react"; -import { useItems } from "@/hooks/useItems"; +import { useShopProducts } 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"; 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, - ); + // 개별 아이템: isBundle이 false인 경우 + const individualItems = products.filter((item) => !item.isBundle); - // 번들 아이템: 리워드가 2개 이상인 경우 - const bundleItems = SHOP_ITEMS_API.filter((item) => item.rewards.length >= 2); + // 번들 아이템: isBundle이 true인 경우 -> 빠른 구매/번들 상세 + const bundleItems = products.filter((item) => item.isBundle); 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..17eb252 100644 --- a/components/common/charge/ShopRows.tsx +++ b/components/common/charge/ShopRows.tsx @@ -1,63 +1,66 @@ 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} + productId={product.id} />
); } -/* ── 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} + productId={product.id} />
); diff --git a/hooks/admin/useAdminOrderSocket.ts b/hooks/admin/useAdminOrderSocket.ts new file mode 100644 index 0000000..0c61f35 --- /dev/null +++ b/hooks/admin/useAdminOrderSocket.ts @@ -0,0 +1,183 @@ +"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) => { + 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; + } + + const wsUrl = `${API_URL}/ws/payment`; + + const client = new Client({ + 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/admin/useAdminOrders.ts b/hooks/admin/useAdminOrders.ts new file mode 100644 index 0000000..92ab6c3 --- /dev/null +++ b/hooks/admin/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/admin/useAdminProducts.ts b/hooks/admin/useAdminProducts.ts new file mode 100644 index 0000000..0b24831 --- /dev/null +++ b/hooks/admin/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 "@/hooks/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/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/hooks/useMatchingHistory.ts b/hooks/useMatchingHistory.ts index 479f7e1..4747c4d 100644 --- a/hooks/useMatchingHistory.ts +++ b/hooks/useMatchingHistory.ts @@ -6,25 +6,27 @@ import { Hobby, ContactFrequency, } from "@/lib/types/profile"; -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; export interface MatchingPartner { memberId: number; email: string; nickname: string; gender: Gender; - birthDate: string; + birthDate: string | null; mbti: MBTI; intro: string | null; - profileImageKey: string; + profileImageUrl: string | null; + profileImageKey: string | null; socialType: SocialType | null; socialAccountId: string | null; university: string; major: string; - contactFrequency: ContactFrequency; - hobbies: Hobby[]; + contactFrequency: string; + hobbies: { category: string; name: string }[] | null; tags: { tag: string }[] | null; song: string | null; + intros: { question: string; answer: string }[]; } export interface MatchingHistoryItem { @@ -34,34 +36,54 @@ 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", + }, + }, + ); + console.log("Matching History Data:", data); + 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) => { + return lastPage?.data?.hasNext + ? lastPage.data.currentPage + 1 + : undefined; + }, + staleTime: Infinity, + gcTime: 1000 * 60 * 60, }); }; 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보다 약간 길게 설정하여 캐시 유지 + }); +}; 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 new file mode 100644 index 0000000..9a39454 --- /dev/null +++ b/hooks/useShopProducts.ts @@ -0,0 +1,44 @@ +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; + isBundle: 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: 1000 * 60 * 30, // 30분: 이후 백그라운드 갱신 + gcTime: Infinity, // 메모리에서 삭제하지 않음 → 로딩바 없음 + }); +}; diff --git a/hooks/useUpdateRealName.ts b/hooks/useUpdateRealName.ts new file mode 100644 index 0000000..437d25e --- /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.patch( + "/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); + }, + }); +}; diff --git a/lib/actions/admin/adminLoginAction.ts b/lib/actions/admin/adminLoginAction.ts new file mode 100644 index 0000000..7db6660 --- /dev/null +++ b/lib/actions/admin/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/lib/constants/charge.ts b/lib/constants/charge.ts index 5c32990..8bfdd13 100644 --- a/lib/constants/charge.ts +++ b/lib/constants/charge.ts @@ -26,90 +26,14 @@ 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 = `충전된 포인트의 소멸시효 기한은 충전 후 5년입니다. -1 포인트는 1원입니다. -충전한 포인트로 서비스를 이용할 수 있습니다. -포인트는 이벤트 포인트 먼저 사용되고, 유상 포인트가 사용됩니다. -이벤트 포인트는 유효기한이 임박한 순으로 먼저 사용됩니다. -유효기간은 포인트 충전내역을 통해 확인하실 수 있습니다.`; +export const USAGE_INFO = `* 충전된 포인트의 소멸시효 기한은 서비스 종료일까지 입니다. +* 각 상품은 아이템 개별 구매 또는 번들 상품 형태로 제공됩니다. +* 매칭 1회 진행 시 뽑기권 1장이 차감됩니다. +* 추가 옵션 선택 시 옵션권이 각각 차감되며, 풀옵션 적용 시 최대 옵션권 3장이 사용될 수 있습니다. +* 사용하지 못한 아이템이 남아있더라도 매칭 횟수 제한으로 인해 이용이 제한될 수 있습니다. +* 매칭 이용제한(30회)으로 인해 사용하지 못한 아이템은 환불이 가능합니다. +* 일부 사용된 상품(번들 포함)은 사용된 비율에 따라 환불금액이 산정됩니다.`; export const NOTICE_INFO = "결제 혹은 환불에 필요한 유의사항을 적습니다."; 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; diff --git a/lib/utils/date.ts b/lib/utils/date.ts new file mode 100644 index 0000000..4677a56 --- /dev/null +++ b/lib/utils/date.ts @@ -0,0 +1,7 @@ +export const getAge = ( + birthDate?: string | null, + currentYear: number = new Date().getFullYear(), +) => { + if (!birthDate) return "??"; + return currentYear - new Date(birthDate).getFullYear() + 1; +}; 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