Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/_components/BubbleDiv.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand All @@ -47,7 +51,10 @@ const BubbleDiv = ({
<div>
{children || (
<>
현재 <span className="text-bubble-text-highight">775명</span>{" "}
현재{" "}
<span className="text-bubble-text-highight">
{count.toLocaleString()}명
</span>{" "}
참여중이에요!
</>
)}
Expand Down
92 changes: 92 additions & 0 deletions app/adminpage/_components/AdminLoginForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form className="flex flex-col gap-5" action={formAction}>
{/* 이메일 */}
<div className="flex flex-col gap-2">
<label
htmlFor="admin-email"
className="text-sm font-medium text-[#a0a3bd]"
>
아이디
</label>
<input
id="admin-email"
type="text"
name="email"
placeholder="아이디 입력"
required
autoComplete="username"
className="h-12 w-full rounded-xl border border-[#2a2d42] bg-[#0f1117] px-4 text-sm font-medium text-white placeholder-[#4a4e69] transition-all duration-200 outline-none focus:border-[#ff4d61] focus:ring-1 focus:ring-[#ff4d61]/30"
/>
</div>

{/* 비밀번호 */}
<div className="relative flex flex-col gap-2">
<label
htmlFor="admin-password"
className="text-sm font-medium text-[#a0a3bd]"
>
비밀번호
</label>
<input
id="admin-password"
type="password"
name="password"
placeholder="비밀번호 입력"
required
autoComplete="current-password"
className="h-12 w-full rounded-xl border border-[#2a2d42] bg-[#0f1117] px-4 text-sm font-medium text-white placeholder-[#4a4e69] transition-all duration-200 outline-none focus:border-[#ff4d61] focus:ring-1 focus:ring-[#ff4d61]/30"
/>
{!state.success && state.message && (
<span className="absolute -bottom-5 left-0 text-xs font-medium text-[#ff4d61]">
* {state.message}
</span>
)}
</div>

{/* 로그인 버튼 */}
<button
type="submit"
disabled={isPending}
className="mt-4 flex h-12 w-full items-center justify-center rounded-xl bg-gradient-to-r from-[#ff4d61] to-[#ff775e] text-base font-semibold text-white shadow-lg shadow-[#ff4d61]/20 transition-all duration-200 hover:shadow-xl hover:shadow-[#ff4d61]/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{isPending ? (
<div className="flex items-center gap-2">
<svg
className="h-5 w-5 animate-spin"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
로그인 중...
</div>
) : (
"로그인"
)}
</button>
</form>
);
}
144 changes: 144 additions & 0 deletions app/adminpage/dashboard/_components/AdminDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto min-h-dvh max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
{/* 헤더 */}
<header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link
href="/adminpage/main"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-[#2a2d42] bg-[#161827] text-[#8b8fa3] transition-colors duration-200 hover:border-[#ff4d61]/40 hover:text-white"
>
<ArrowLeft size={18} />
</Link>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[#ff4d61] to-[#ff775e]">
<CreditCard size={20} className="text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-white sm:text-xl">
결제 승인 관리
</h1>
<p className="text-xs text-[#6b7094]">
대기 중인 요청{" "}
<span className="font-semibold text-[#ff4d61]">
{pendingCount}
</span>
</p>
</div>
</div>
</div>

<div className="flex items-center gap-3">
{/* 새로고침 */}
<button
onClick={() => refetch()}
disabled={isRefetching}
className="flex items-center gap-2 rounded-xl border border-[#2a2d42] bg-[#161827] px-3 py-2.5 text-xs font-medium text-[#8b8fa3] transition-all duration-200 hover:border-[#ff4d61]/40 hover:text-white disabled:opacity-50 sm:px-4 sm:text-sm"
>
<RefreshCw
size={14}
className={isRefetching ? "animate-spin" : ""}
/>
<span className="hidden sm:inline">새로고침</span>
</button>

{/* 연결 상태 */}
<div
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-xs font-medium sm:px-4 sm:text-sm ${
socketStatus === "connected"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: socketStatus === "reconnecting"
? "border-amber-500/30 bg-amber-500/10 text-amber-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
{socketStatus === "connected" ? (
<>
<Wifi size={14} />
<span className="hidden sm:inline">실시간 연결</span>
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-400" />
</>
) : socketStatus === "reconnecting" ? (
<>
<RefreshCw size={14} className="animate-spin" />
<span className="hidden sm:inline">재연결 중</span>
</>
) : (
<>
<WifiOff size={14} />
<span className="hidden sm:inline">연결 끊김</span>
</>
)}
</div>
</div>
</header>

{/* 주문 목록 */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 size={32} className="animate-spin text-[#6b7094]" />
<p className="mt-4 text-sm text-[#6b7094]">
주문 목록 불러오는 중...
</p>
</div>
) : sortedOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#161827]">
<Inbox size={28} className="text-[#4a4e69]" />
</div>
<p className="text-base font-medium text-[#6b7094]">
대기 중인 결제 요청이 없습니다
</p>
<p className="mt-1 text-sm text-[#4a4e69]">
새로운 요청이 들어오면 실시간으로 표시됩니다
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{sortedOrders.map((order) => (
<OrderCard key={order.requestId} order={order} />
))}
</div>
)}
</div>
);
}
62 changes: 62 additions & 0 deletions app/adminpage/dashboard/_components/ExpiryCountdown.tsx
Original file line number Diff line number Diff line change
@@ -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<number>(() =>
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 (
<span className="flex items-center gap-1 text-xs font-medium text-[#4a4e69]">
<Timer size={12} />
만료됨
</span>
);
}

const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
const isUrgent = remaining < 120; // 2분 미만

return (
<span
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-bold tabular-nums ${
isUrgent ? "bg-red-500/10 text-red-400" : "bg-[#1e2030] text-[#8b8fa3]"
}`}
>
<Timer size={12} className={isUrgent ? "animate-pulse" : ""} />
{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")}
</span>
);
}

function calculateRemaining(expiresAt: string): number {
const expiry = new Date(expiresAt).getTime();
const now = Date.now();
return Math.max(0, Math.floor((expiry - now) / 1000));
}
Loading
Loading