feat: 로또 추천 API, 텔레그램 봇 연동, 관리자 페이지 추가

- 로또 번호 추천 구독자 전용 페이지 (/services/lotto/recommend)
- NAS 몬테카를로 API 연동 + 클라이언트 사이드 폴백
- 무료 미리보기 1개 + 구독자용 프리미엄 번호 추천
- 구독 플랜 변경: 골드(900원)/플래티넘(2,900원)/다이아(9,900원)
- 텔레그램 봇 연동: 연결/해제, 웹훅, /start 명령 처리
- 마이페이지 텔레그램 연결 UI + 가이드 모달
- 관리자 페이지 (/admin): 대시보드, 회원, 서비스, 문의 관리
- Supabase 마이그레이션: profiles 텔레그램 컬럼, 신규 상품

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 02:12:17 +09:00
parent 2469063979
commit a95715ec6b
32 changed files with 3060 additions and 35 deletions

View File

@@ -0,0 +1,124 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useState } from 'react';
const NAV_ITEMS = [
{
href: '/admin/dashboard',
label: '대시보드',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
href: '/admin/members',
label: '회원 관리',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
href: '/admin/services',
label: '서비스 설정',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
href: '/admin/contacts',
label: '문의 내역',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
},
];
export default function AdminSidebar() {
const pathname = usePathname();
const router = useRouter();
const [loggingOut, setLoggingOut] = useState(false);
async function handleLogout() {
setLoggingOut(true);
await fetch('/api/admin/logout', { method: 'POST' });
router.push('/admin/login');
}
return (
<aside className="w-60 flex-shrink-0 bg-slate-900 flex flex-col h-screen sticky top-0">
{/* 로고 */}
<div className="px-5 py-5 border-b border-slate-700/60">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center text-white font-bold text-sm">
</div>
<div>
<p className="text-white font-bold text-sm leading-tight"> </p>
<p className="text-slate-400 text-xs"></p>
</div>
</div>
</div>
{/* 네비게이션 */}
<nav className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
{NAV_ITEMS.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all ${
active
? 'bg-gradient-to-r from-red-600/30 to-orange-500/20 text-white border border-red-500/30'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<span className={active ? 'text-red-400' : ''}>{item.icon}</span>
{item.label}
</Link>
);
})}
</nav>
{/* 사이트로 돌아가기 + 로그아웃 */}
<div className="px-3 py-4 border-t border-slate-700/60 space-y-2">
<Link
href="/"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</Link>
<button
onClick={handleLogout}
disabled={loggingOut}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-red-400 hover:text-red-300 hover:bg-red-900/20 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
{loggingOut ? '로그아웃 중...' : '로그아웃'}
</button>
</div>
</aside>
);
}

230
app/admin/contacts/page.tsx Normal file
View File

@@ -0,0 +1,230 @@
'use client';
import { useEffect, useState } from 'react';
interface Contact {
id: string;
email: string;
name: string | null;
service: string;
message: string;
status: 'pending' | 'in_progress' | 'completed';
created_at: string;
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '미처리', color: 'bg-yellow-900/40 text-yellow-400' },
in_progress: { label: '처리중', color: 'bg-blue-900/40 text-blue-400' },
completed: { label: '완료', color: 'bg-green-900/40 text-green-400' },
};
const SERVICE_LABELS: Record<string, string> = {
lotto: '로또 추천',
stock: '주식 자동매매',
automation: '업무 자동화',
prompt: '프롬프트 엔지니어링',
freelance: '외주 개발',
saju: 'AI 사주',
general: '일반 문의',
};
export default function AdminContactsPage() {
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Contact | null>(null);
const [updating, setUpdating] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('all');
useEffect(() => {
fetch('/api/admin/contacts')
.then((r) => r.json())
.then((d) => setContacts(d.contacts ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
async function updateStatus(id: string, status: string) {
setUpdating(id);
try {
const res = await fetch('/api/admin/contacts', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
});
if (res.ok) {
setContacts((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: status as Contact['status'] } : c))
);
if (selected?.id === id) {
setSelected((prev) => prev ? { ...prev, status: status as Contact['status'] } : null);
}
}
} catch (e) {
console.error(e);
} finally {
setUpdating(null);
}
}
const filtered = contacts.filter((c) => filterStatus === 'all' || c.status === filterStatus);
const pendingCount = contacts.filter((c) => c.status === 'pending').length;
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5"> </p>
</div>
{pendingCount > 0 && (
<span className="bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-3 py-1 rounded-full text-sm font-medium">
{pendingCount}
</span>
)}
</div>
{/* 필터 탭 */}
<div className="flex gap-2 mb-4">
{[['all', '전체'], ['pending', '미처리'], ['in_progress', '처리중'], ['completed', '완료']].map(([val, label]) => (
<button
key={val}
onClick={() => setFilterStatus(val)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
filterStatus === val
? 'bg-red-600/30 text-red-300 border border-red-500/30'
: 'bg-slate-800 text-slate-400 hover:text-white'
}`}
>
{label}
{val !== 'all' && (
<span className="ml-1.5 text-xs opacity-70">
{contacts.filter((c) => c.status === val).length}
</span>
)}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
</div>
) : (
<div className="flex gap-4">
{/* 목록 */}
<div className="flex-1 space-y-2">
{filtered.length === 0 ? (
<div className="bg-slate-900 rounded-2xl p-10 text-center text-slate-500 border border-slate-700/50">
</div>
) : (
filtered.map((contact) => (
<button
key={contact.id}
onClick={() => setSelected(contact)}
className={`w-full text-left bg-slate-900 rounded-xl p-4 border transition-all hover:border-slate-600 ${
selected?.id === contact.id ? 'border-red-500/50' : 'border-slate-700/50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-white font-medium text-sm truncate">
{contact.name ?? contact.email}
</span>
<span className="text-xs bg-slate-700 text-slate-300 px-2 py-0.5 rounded-full flex-shrink-0">
{SERVICE_LABELS[contact.service] ?? contact.service}
</span>
</div>
<p className="text-slate-400 text-xs truncate">{contact.message}</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_LABELS[contact.status]?.color}`}>
{STATUS_LABELS[contact.status]?.label}
</span>
<span className="text-slate-600 text-xs">
{new Date(contact.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
</div>
</button>
))
)}
</div>
{/* 상세 패널 */}
{selected && (
<div className="w-80 flex-shrink-0 bg-slate-900 rounded-2xl border border-slate-700/50 p-5 h-fit sticky top-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-white font-semibold"> </h3>
<button onClick={() => setSelected(null)} className="text-slate-500 hover:text-white">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<dl className="space-y-3 text-sm mb-4">
<div>
<dt className="text-slate-500 mb-0.5"></dt>
<dd className="text-white">{selected.name ?? '-'}</dd>
</div>
<div>
<dt className="text-slate-500 mb-0.5"></dt>
<dd className="text-blue-400">{selected.email}</dd>
</div>
<div>
<dt className="text-slate-500 mb-0.5"></dt>
<dd className="text-white">{SERVICE_LABELS[selected.service] ?? selected.service}</dd>
</div>
<div>
<dt className="text-slate-500 mb-0.5"></dt>
<dd className="text-slate-300">{new Date(selected.created_at).toLocaleString('ko-KR')}</dd>
</div>
<div>
<dt className="text-slate-500 mb-1"></dt>
<dd className="text-slate-200 bg-slate-800 rounded-lg p-3 leading-relaxed whitespace-pre-wrap">
{selected.message}
</dd>
</div>
</dl>
{/* 상태 변경 */}
<div>
<p className="text-slate-500 text-xs mb-2"> </p>
<div className="flex gap-2">
{(['pending', 'in_progress', 'completed'] as const).map((s) => (
<button
key={s}
onClick={() => updateStatus(selected.id, s)}
disabled={selected.status === s || updating === selected.id}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition ${
selected.status === s
? STATUS_LABELS[s].color + ' opacity-100'
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'
} disabled:opacity-50`}
>
{STATUS_LABELS[s].label}
</button>
))}
</div>
</div>
{/* 이메일 바로 보내기 링크 */}
<a
href={`mailto:${selected.email}?subject=[쟁승메이드] 문의 답변&body=안녕하세요, 쟁승메이드입니다.%0A%0A`}
className="mt-3 w-full flex items-center justify-center gap-2 py-2 bg-blue-600/20 text-blue-400 rounded-lg text-xs hover:bg-blue-600/30 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</a>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useEffect, useState } from 'react';
interface Stats {
totalMembers: number;
totalOrders: number;
totalRevenue: number;
pendingContacts: number;
monthlyChart: Array<{ month: string; revenue: number }>;
}
function StatCard({ label, value, icon, color }: { label: string; value: string; icon: React.ReactNode; color: string }) {
return (
<div className="bg-slate-900 rounded-2xl p-5 border border-slate-700/50">
<div className="flex items-center justify-between mb-3">
<span className="text-slate-400 text-sm">{label}</span>
<div className={`w-9 h-9 rounded-xl flex items-center justify-center ${color}`}>
{icon}
</div>
</div>
<p className="text-white text-2xl font-bold">{value}</p>
</div>
);
}
export default function AdminDashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/admin/stats')
.then((r) => r.json())
.then((d) => setStats(d))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const maxRevenue = stats ? Math.max(...stats.monthlyChart.map((m) => m.revenue), 1) : 1;
return (
<div className="p-6 max-w-6xl mx-auto">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-white text-2xl font-bold"></h1>
<p className="text-slate-400 text-sm mt-0.5"> </p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
</div>
) : (
<>
{/* 통계 카드 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard
label="총 회원 수"
value={`${stats?.totalMembers ?? 0}`}
color="bg-blue-500/20 text-blue-400"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
/>
<StatCard
label="총 결제 건수"
value={`${stats?.totalOrders ?? 0}`}
color="bg-green-500/20 text-green-400"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
<StatCard
label="총 수익"
value={`${(stats?.totalRevenue ?? 0).toLocaleString()}`}
color="bg-yellow-500/20 text-yellow-400"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
<StatCard
label="미처리 문의"
value={`${stats?.pendingContacts ?? 0}`}
color="bg-red-500/20 text-red-400"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
}
/>
</div>
{/* 월별 수익 차트 */}
<div className="bg-slate-900 rounded-2xl p-5 border border-slate-700/50">
<h2 className="text-white font-semibold mb-5"> ( 6)</h2>
<div className="flex items-end gap-3 h-48">
{stats?.monthlyChart.map((item) => {
const height = maxRevenue > 0 ? Math.max((item.revenue / maxRevenue) * 100, item.revenue > 0 ? 4 : 0) : 0;
const monthLabel = item.month.slice(5); // MM
return (
<div key={item.month} className="flex-1 flex flex-col items-center gap-2">
<span className="text-slate-400 text-xs">
{item.revenue > 0 ? `${(item.revenue / 1000).toFixed(0)}K` : ''}
</span>
<div className="w-full flex items-end justify-center h-32">
<div
className="w-full rounded-t-lg bg-gradient-to-t from-red-600 to-orange-400 transition-all duration-500"
style={{ height: `${height}%`, minHeight: item.revenue > 0 ? '4px' : '0' }}
/>
</div>
<span className="text-slate-400 text-xs">{monthLabel}</span>
</div>
);
})}
</div>
{(stats?.totalRevenue ?? 0) === 0 && (
<p className="text-center text-slate-600 text-sm mt-2"> </p>
)}
</div>
</>
)}
</div>
);
}

18
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
import AdminSidebar from './components/AdminSidebar';
export const metadata: Metadata = {
title: '관리자 패널 — 쟁승메이드',
robots: { index: false, follow: false },
};
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen bg-slate-950 overflow-hidden">
<AdminSidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
);
}

98
app/admin/login/page.tsx Normal file
View File

@@ -0,0 +1,98 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function AdminLoginPage() {
const router = useRouter();
const [id, setId] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || '로그인에 실패했습니다.');
} else {
router.push('/admin/dashboard');
}
} catch {
setError('서버 연결에 실패했습니다.');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
{/* 로고 */}
<div className="text-center mb-8">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center text-white font-bold text-2xl mx-auto mb-4">
</div>
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-1"> </p>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="bg-slate-900 rounded-2xl p-6 space-y-4 border border-slate-700/50">
<div>
<label className="block text-slate-300 text-sm font-medium mb-1.5"> ID</label>
<input
type="text"
value={id}
onChange={(e) => setId(e.target.value)}
required
autoComplete="username"
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition"
placeholder="관리자 ID 입력"
/>
</div>
<div>
<label className="block text-slate-300 text-sm font-medium mb-1.5"></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition"
placeholder="비밀번호 입력"
/>
</div>
{error && (
<div className="bg-red-900/30 border border-red-700/50 rounded-lg px-4 py-2.5 text-red-300 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-red-600 to-orange-500 text-white font-semibold py-2.5 rounded-lg text-sm hover:opacity-90 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
<p className="text-center text-slate-600 text-xs mt-4">
.env.local의 ADMIN_ID / ADMIN_PASSWORD로
</p>
</div>
</div>
);
}

104
app/admin/members/page.tsx Normal file
View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useState } from 'react';
interface Member {
id: string;
email: string;
full_name: string | null;
created_at: string;
orderCount: number;
totalPaid: number;
}
export default function AdminMembersPage() {
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
useEffect(() => {
fetch('/api/admin/members')
.then((r) => r.json())
.then((d) => setMembers(d.members ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const filtered = members.filter(
(m) =>
m.email?.toLowerCase().includes(search.toLowerCase()) ||
m.full_name?.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5"> </p>
</div>
<span className="bg-slate-700 text-slate-300 px-3 py-1 rounded-full text-sm">
{members.length}
</span>
</div>
{/* 검색 */}
<div className="mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="이메일 또는 이름으로 검색..."
className="w-full max-w-sm bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-red-500 transition"
/>
</div>
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
</div>
) : (
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700/50">
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-right px-5 py-3 text-slate-400 font-medium"> </th>
<th className="text-right px-5 py-3 text-slate-400 font-medium"> </th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-12 text-slate-500">
</td>
</tr>
) : (
filtered.map((m) => (
<tr key={m.id} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/50 transition">
<td className="px-5 py-3 text-white">{m.email ?? '-'}</td>
<td className="px-5 py-3 text-slate-300">{m.full_name ?? '-'}</td>
<td className="px-5 py-3 text-slate-400">
{new Date(m.created_at).toLocaleDateString('ko-KR')}
</td>
<td className="px-5 py-3 text-right">
<span className={`px-2 py-0.5 rounded-full text-xs ${m.orderCount > 0 ? 'bg-green-900/40 text-green-400' : 'bg-slate-700 text-slate-500'}`}>
{m.orderCount}
</span>
</td>
<td className="px-5 py-3 text-right text-slate-200 font-medium">
{m.totalPaid > 0 ? `${m.totalPaid.toLocaleString()}` : '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
}

5
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function AdminRootPage() {
redirect('/admin/dashboard');
}

137
app/admin/services/page.tsx Normal file
View File

@@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
interface Service {
id: string;
name: string;
description: string;
is_active: boolean;
order_index: number;
}
const SERVICE_ICONS: Record<string, string> = {
saju: '🔮',
lotto: '🎰',
stock: '📈',
automation: '🤖',
prompt: '💡',
freelance: '🛠',
};
export default function AdminServicesPage() {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [toggling, setToggling] = useState<string | null>(null);
useEffect(() => {
fetch('/api/admin/services')
.then((r) => r.json())
.then((d) => setServices(d.services ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
async function toggleService(id: string, current: boolean) {
setToggling(id);
try {
const res = await fetch('/api/admin/services', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, is_active: !current }),
});
if (res.ok) {
setServices((prev) =>
prev.map((s) => (s.id === id ? { ...s, is_active: !current } : s))
);
}
} catch (e) {
console.error(e);
} finally {
setToggling(null);
}
}
return (
<div className="p-6 max-w-3xl mx-auto">
<div className="mb-6">
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5"> </p>
</div>
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
</div>
) : (
<div className="space-y-3">
{services.map((service) => (
<div
key={service.id}
className={`bg-slate-900 rounded-2xl p-5 border transition-all ${
service.is_active ? 'border-slate-700/50' : 'border-slate-800 opacity-60'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-2xl">{SERVICE_ICONS[service.id] ?? '📦'}</span>
<div>
<h3 className="text-white font-semibold">{service.name}</h3>
<p className="text-slate-400 text-sm">{service.description}</p>
</div>
</div>
{/* 토글 스위치 */}
<button
onClick={() => toggleService(service.id, service.is_active)}
disabled={toggling === service.id}
aria-label={`${service.name} ${service.is_active ? '비활성화' : '활성화'}`}
className={`relative w-12 h-6 rounded-full transition-colors duration-200 focus:outline-none ${
service.is_active ? 'bg-green-500' : 'bg-slate-600'
} ${toggling === service.id ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${
service.is_active ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* 상태 배지 */}
<div className="mt-3 flex items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
service.is_active
? 'bg-green-900/40 text-green-400'
: 'bg-slate-700 text-slate-500'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${service.is_active ? 'bg-green-400' : 'bg-slate-500'}`} />
{service.is_active ? '활성' : '비활성'}
</span>
{!service.is_active && (
<span className="text-slate-500 text-xs"> </span>
)}
</div>
</div>
))}
</div>
)}
<div className="mt-6 bg-slate-800/50 rounded-xl p-4 border border-slate-700/30">
<p className="text-slate-400 text-xs">
💡 on/off는 Supabase의 <code className="text-slate-300">service_settings</code> .
SQL을 .
</p>
<pre className="text-slate-500 text-xs mt-2 bg-slate-900 rounded p-3 overflow-x-auto">{`CREATE TABLE service_settings (
id text PRIMARY KEY,
name text,
description text,
is_active boolean DEFAULT true,
order_index integer DEFAULT 0,
updated_at timestamptz DEFAULT now()
);`}</pre>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createAdminClient();
const { data, error } = await supabase
.from('contact_requests')
.select('*')
.order('created_at', { ascending: false })
.limit(100);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ contacts: data ?? [] });
}
export async function PATCH(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id, status } = await request.json();
const supabase = createAdminClient();
const { error } = await supabase
.from('contact_requests')
.update({ status })
.eq('id', id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { createAdminToken, checkAdminCredentials } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function POST(request: Request) {
try {
const { id, password } = await request.json();
if (!checkAdminCredentials(id, password)) {
return NextResponse.json({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }, { status: 401 });
}
const token = createAdminToken();
const response = NextResponse.json({ success: true });
response.cookies.set('admin_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24시간
path: '/',
});
return response;
} catch {
return NextResponse.json({ error: '서버 오류가 발생했습니다.' }, { status: 500 });
}
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.set('admin_token', '', {
httpOnly: true,
maxAge: 0,
path: '/',
});
return response;
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token || !verifyAdminTokenNode(token)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createAdminClient();
const { data: profiles, error } = await supabase
.from('profiles')
.select('id, email, full_name, created_at')
.order('created_at', { ascending: false })
.limit(100);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// 각 회원의 주문 수 + 결제 금액 집계
const enriched = await Promise.all(
(profiles ?? []).map(async (p: { id: string; email: string; full_name: string; created_at: string }) => {
const [ordersRes, paymentsRes] = await Promise.all([
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('user_id', p.id).eq('status', 'paid'),
supabase.from('payments').select('amount').eq('user_id', p.id).eq('status', 'paid'),
]);
const totalPaid = (paymentsRes.data ?? []).reduce((s: number, x: { amount: number }) => s + x.amount, 0);
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid };
})
);
return NextResponse.json({ members: enriched });
}

View File

@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createAdminClient();
const { data, error } = await supabase
.from('service_settings')
.select('*')
.order('order_index');
if (error) {
// 테이블이 없으면 기본값 반환
return NextResponse.json({ services: DEFAULT_SERVICES });
}
return NextResponse.json({ services: data?.length ? data : DEFAULT_SERVICES });
}
export async function PATCH(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id, is_active } = await request.json();
const supabase = createAdminClient();
const { error } = await supabase
.from('service_settings')
.upsert({ id, is_active, updated_at: new Date().toISOString() });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
const DEFAULT_SERVICES = [
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 서비스', is_active: true, order_index: 1 },
{ id: 'lotto', name: '로또 번호 추천', description: '빅데이터 기반 로또 번호 분석', is_active: true, order_index: 2 },
{ id: 'stock', name: '주식 자동매매', description: '텔레그램 연동 자동매매 프로그램', is_active: true, order_index: 3 },
{ id: 'automation', name: '업무 자동화 RPA', description: '반복 업무 자동화 개발', is_active: true, order_index: 4 },
{ id: 'prompt', name: '프롬프트 엔지니어링', description: 'AI 프롬프트 설계 서비스', is_active: true, order_index: 5 },
{ id: 'freelance', name: '외주 개발', description: '맞춤형 소프트웨어 개발', is_active: true, order_index: 6 },
];

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token || !verifyAdminTokenNode(token)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createAdminClient();
// 병렬 쿼리
const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes] = await Promise.all([
supabase.from('profiles').select('id', { count: 'exact', head: true }),
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('status', 'paid'),
supabase.from('payments').select('amount').eq('status', 'paid'),
supabase.from('contact_requests').select('id', { count: 'exact', head: true }).eq('status', 'pending'),
supabase.from('payments').select('amount, created_at').eq('status', 'paid').order('created_at', { ascending: true }),
]);
const totalMembers = profilesRes.count ?? 0;
const totalOrders = ordersRes.count ?? 0;
const totalRevenue = (paymentsRes.data ?? []).reduce((sum: number, p: { amount: number }) => sum + p.amount, 0);
const pendingContacts = contactsRes.count ?? 0;
// 최근 6개월 월별 수익 집계
const monthly: Record<string, number> = {};
const now = new Date();
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
monthly[key] = 0;
}
for (const p of (monthlyRes.data ?? []) as Array<{ amount: number; created_at: string }>) {
const d = new Date(p.created_at);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (key in monthly) {
monthly[key] += p.amount;
}
}
const monthlyChart = Object.entries(monthly).map(([month, revenue]) => ({ month, revenue }));
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, monthlyChart });
}

View File

@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
const LOTTO_PRODUCT_IDS = ['lotto_gold', 'lotto_platinum', 'lotto_diamond'];
async function nasGet(path: string): Promise<unknown> {
const base = process.env.NAS_LOTTO_API_URL;
if (!base) throw new Error('NAS_URL_NOT_CONFIGURED');
const headers: Record<string, string> = {};
if (process.env.NAS_LOTTO_API_KEY) {
headers['Authorization'] = `Bearer ${process.env.NAS_LOTTO_API_KEY}`;
}
const res = await fetch(`${base}${path}`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`NAS_${res.status}`);
return res.json();
}
/**
* GET /api/lotto/dashboard
* 페이지 초기 로드용: latest + analysis + simulation 이력 병렬 조회
*
* Response:
* {
* plan: string,
* latest: { drawNo, date, numbers, bonus, metrics },
* analysis: { total_draws, mean_sum, std_sum, number_stats[] },
* simulation: { runs[] }
* }
*/
export async function GET() {
try {
// 1. 인증
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
// 2. 구독 확인
const { data: orders } = await supabase
.from('orders')
.select('id, product_id, status, created_at')
.eq('user_id', user.id)
.eq('status', 'paid')
.in('product_id', LOTTO_PRODUCT_IDS)
.order('created_at', { ascending: false })
.limit(1);
if (!orders || orders.length === 0) {
return NextResponse.json({ error: 'NOT_SUBSCRIBED' }, { status: 403 });
}
const order = orders[0];
const diffDays =
(Date.now() - new Date(order.created_at).getTime()) / (1000 * 60 * 60 * 24);
const maxDays = order.product_id === 'lotto_annual' ? 366 : 31;
if (diffDays > maxDays) {
return NextResponse.json({ error: 'NOT_SUBSCRIBED' }, { status: 403 });
}
// 3. NAS 병렬 조회
const [latest, analysis, simulation] = await Promise.allSettled([
nasGet('/api/lotto/latest'),
nasGet('/api/lotto/analysis'),
nasGet('/api/lotto/simulation'),
]);
return NextResponse.json({
ok: true,
plan: order.product_id,
latest: latest.status === 'fulfilled' ? latest.value : null,
analysis: analysis.status === 'fulfilled' ? analysis.value : null,
simulation: simulation.status === 'fulfilled' ? simulation.value : null,
});
} catch (err: unknown) {
const e = err as { name?: string; message?: string };
if (e?.name === 'TimeoutError') {
return NextResponse.json({ error: 'NAS_TIMEOUT' }, { status: 504 });
}
console.error('Lotto dashboard error:', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
/**
* GET /api/lotto/preview
* 인증 없이 NAS /api/lotto/recommend 단일 호출 (맛보기용 무료 추천)
* NAS 미연결 시 → { error: 'NAS_UNAVAILABLE' } 503 반환
* 클라이언트에서 이 경우 자체 Monte Carlo 폴백 처리
*/
export async function GET() {
const base = process.env.NAS_LOTTO_API_URL;
if (!base) {
return NextResponse.json({ error: 'NAS_UNAVAILABLE' }, { status: 503 });
}
try {
const headers: Record<string, string> = {};
if (process.env.NAS_LOTTO_API_KEY) {
headers['Authorization'] = `Bearer ${process.env.NAS_LOTTO_API_KEY}`;
}
const res = await fetch(`${base}/api/lotto/recommend`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(8000),
});
if (!res.ok) {
console.warn(`[lotto/preview] NAS returned ${res.status}`);
return NextResponse.json({ error: 'NAS_UNAVAILABLE' }, { status: 503 });
}
const data = await res.json();
return NextResponse.json({
ok: true,
source: 'nas',
numbers: data.numbers ?? [],
metrics: data.metrics ?? null,
});
} catch (err: unknown) {
// ECONNREFUSED, 타임아웃 등 — 클라이언트 폴백 신호
const e = err as { name?: string; code?: string; message?: string };
console.warn('[lotto/preview] NAS unreachable:', e?.code ?? e?.message ?? e?.name);
return NextResponse.json({ error: 'NAS_UNAVAILABLE' }, { status: 503 });
}
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
const LOTTO_PRODUCT_IDS = ['lotto_gold', 'lotto_platinum', 'lotto_diamond'];
/** 구독 유효 여부 확인 */
async function checkSubscription(supabase: Awaited<ReturnType<typeof createClient>>, userId: string) {
const { data: orders } = await supabase
.from('orders')
.select('id, product_id, status, created_at')
.eq('user_id', userId)
.eq('status', 'paid')
.in('product_id', LOTTO_PRODUCT_IDS)
.order('created_at', { ascending: false })
.limit(1);
if (!orders || orders.length === 0) return null;
const order = orders[0];
const diffDays =
(Date.now() - new Date(order.created_at).getTime()) / (1000 * 60 * 60 * 24);
const maxDays = order.product_id === 'lotto_annual' ? 366 : 31;
return diffDays <= maxDays ? order : null;
}
/** NAS API 호출 헬퍼 */
async function nasGet(path: string): Promise<Response> {
const base = process.env.NAS_LOTTO_API_URL;
if (!base) throw new Error('NAS_URL_NOT_CONFIGURED');
const headers: Record<string, string> = {};
if (process.env.NAS_LOTTO_API_KEY) {
headers['Authorization'] = `Bearer ${process.env.NAS_LOTTO_API_KEY}`;
}
return fetch(`${base}${path}`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(15000),
});
}
/**
* GET /api/lotto/recommend
* Query params:
* mode = "single" (기본) | "batch" | "best"
*
* single → NAS GET /api/lotto/recommend
* batch → NAS GET /api/lotto/recommend/batch (5개 조합)
* best → NAS GET /api/lotto/best (Monte Carlo 상위 20쌍)
*/
export async function GET(req: NextRequest) {
try {
// 1. 세션 확인
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
// 2. 구독 확인
const order = await checkSubscription(supabase, user.id);
if (!order) {
return NextResponse.json({ error: 'NOT_SUBSCRIBED' }, { status: 403 });
}
const mode = req.nextUrl.searchParams.get('mode') ?? 'single';
// 3. NAS API 호출
const nasPath =
mode === 'batch'
? '/api/lotto/recommend/batch'
: mode === 'best'
? '/api/lotto/best'
: '/api/lotto/recommend';
const nasRes = await nasGet(nasPath);
if (!nasRes.ok) {
const errText = await nasRes.text();
console.error('NAS API error:', nasRes.status, errText);
return NextResponse.json({ error: 'NAS_API_ERROR' }, { status: 502 });
}
const nasData = await nasRes.json();
return NextResponse.json({
ok: true,
plan: order.product_id,
mode,
...nasData,
});
} catch (err: unknown) {
const e = err as { name?: string; message?: string };
if (e?.name === 'TimeoutError') {
return NextResponse.json({ error: 'NAS_TIMEOUT' }, { status: 504 });
}
if (e?.message === 'NAS_URL_NOT_CONFIGURED') {
return NextResponse.json({ error: 'NAS_URL_NOT_CONFIGURED' }, { status: 500 });
}
console.error('Lotto recommend error:', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}

View File

@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
/**
* POST /api/telegram/connect
* 인증된 유저에게 15분 유효 연결 토큰을 발급하고
* 텔레그램 봇 딥링크를 반환합니다.
*
* Response: { deepLink: string, expiresAt: string }
*/
export async function POST() {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
const botUsername = process.env.TELEGRAM_BOT_USERNAME;
if (!botUsername) {
return NextResponse.json({ error: 'TELEGRAM_BOT_USERNAME이 설정되지 않았습니다.' }, { status: 500 });
}
// 15분 유효 토큰 생성
const token = crypto.randomUUID().replace(/-/g, '');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
// 프로필 upsert (없는 경우 대비)
await supabase
.from('profiles')
.upsert({ id: user.id, email: user.email }, { onConflict: 'id' });
const { error: updateError } = await supabase
.from('profiles')
.update({
telegram_connect_token: token,
telegram_token_expires: expiresAt,
})
.eq('id', user.id);
if (updateError) {
console.error('telegram connect token update error:', updateError);
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
}
const deepLink = `https://t.me/${botUsername}?start=${token}`;
return NextResponse.json({ deepLink, expiresAt });
}
/**
* DELETE /api/telegram/connect
* 텔레그램 연결 해제 (chat_id 및 토큰 초기화)
*/
export async function DELETE() {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
const { error } = await supabase
.from('profiles')
.update({
telegram_chat_id: null,
telegram_connect_token: null,
telegram_token_expires: null,
})
.eq('id', user.id);
if (error) {
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { setWebhook, getWebhookInfo } from '@/lib/telegram';
/**
* GET /api/telegram/setup — 현재 웹훅 등록 상태 확인
* POST /api/telegram/setup — 텔레그램 웹훅 등록 (최초 1회 or 도메인 변경 시)
*
* 보안: TELEGRAM_SETUP_SECRET 헤더로 보호 (환경변수와 일치해야 접근 가능)
* 사용: curl -X POST https://your-domain/api/telegram/setup \
* -H "x-setup-secret: YOUR_SECRET"
*/
function checkSecret(req: NextRequest): boolean {
const secret = process.env.TELEGRAM_SETUP_SECRET;
if (!secret) return false; // 시크릿 미설정이면 항상 거부
return req.headers.get('x-setup-secret') === secret;
}
export async function GET(req: NextRequest) {
if (!checkSecret(req)) {
return NextResponse.json({ error: 'FORBIDDEN' }, { status: 403 });
}
const info = await getWebhookInfo();
return NextResponse.json(info);
}
export async function POST(req: NextRequest) {
if (!checkSecret(req)) {
return NextResponse.json({ error: 'FORBIDDEN' }, { status: 403 });
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.VERCEL_URL;
if (!appUrl) {
return NextResponse.json(
{ error: 'NEXT_PUBLIC_APP_URL 또는 VERCEL_URL 환경변수가 필요합니다.' },
{ status: 500 }
);
}
const webhookUrl = `${appUrl.startsWith('http') ? appUrl : `https://${appUrl}`}/api/telegram/webhook`;
const secretToken = process.env.TELEGRAM_WEBHOOK_SECRET;
const result = await setWebhook(webhookUrl, secretToken);
return NextResponse.json({ webhookUrl, result });
}

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { sendMessage, type TelegramUpdate } from '@/lib/telegram';
/**
* POST /api/telegram/webhook
* Telegram이 호출하는 웹훅 엔드포인트
* - X-Telegram-Bot-Api-Secret-Token 헤더로 요청 검증
* - /start <TOKEN> 명령으로 유저 텔레그램 계정 연결
*/
export async function POST(req: NextRequest) {
// 1. 웹훅 시크릿 토큰 검증
const secretToken = process.env.TELEGRAM_WEBHOOK_SECRET;
if (secretToken) {
const incoming = req.headers.get('x-telegram-bot-api-secret-token');
if (incoming !== secretToken) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
}
let update: TelegramUpdate;
try {
update = await req.json();
} catch {
return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 });
}
const message = update.message;
if (!message?.text || !message.from) {
// 지원하지 않는 업데이트 타입 — 200으로 응답해야 재전송 방지
return NextResponse.json({ ok: true });
}
const chatId = message.chat.id;
const text = message.text.trim();
const firstName = message.from.first_name ?? '고객';
// 2. /start 명령 처리
if (text.startsWith('/start')) {
const parts = text.split(' ');
const token = parts[1]; // /start <TOKEN>
if (!token) {
await sendMessage(
chatId,
`안녕하세요, ${firstName}님! 👋\n\n쟁승메이드 로또 알림 봇입니다.\n\n[마이페이지](https://jaengseung.com/mypage)에서 텔레그램 연결 버튼을 클릭하여 계정을 연결해주세요.`
);
return NextResponse.json({ ok: true });
}
// 3. 토큰으로 유저 조회
const supabase = createAdminClient();
const now = new Date().toISOString();
const { data: profile, error } = await supabase
.from('profiles')
.select('id, email, telegram_chat_id, telegram_connect_token, telegram_token_expires')
.eq('telegram_connect_token', token)
.gt('telegram_token_expires', now)
.maybeSingle();
if (error || !profile) {
await sendMessage(
chatId,
`❌ 연결 코드가 유효하지 않거나 만료되었습니다.\n\n마이페이지에서 다시 연결을 시도해주세요.`
);
return NextResponse.json({ ok: true });
}
if (profile.telegram_chat_id) {
await sendMessage(
chatId,
`✅ 이미 연결된 계정입니다.\n\n📧 ${profile.email}`
);
return NextResponse.json({ ok: true });
}
// 4. chat_id 저장 + 토큰 초기화
await supabase
.from('profiles')
.update({
telegram_chat_id: String(chatId),
telegram_connect_token: null,
telegram_token_expires: null,
})
.eq('id', profile.id);
await sendMessage(
chatId,
`🎉 텔레그램 연결 완료!\n\n📧 ${profile.email} 계정과 연결되었습니다.\n\n이제 매주 로또 번호를 이 채팅으로 받아보실 수 있습니다. 🎰`
);
return NextResponse.json({ ok: true });
}
// 5. 그 외 명령어
if (text === '/status') {
const supabase = createAdminClient();
const { data: profile } = await supabase
.from('profiles')
.select('email')
.eq('telegram_chat_id', String(chatId))
.maybeSingle();
if (profile) {
await sendMessage(chatId, `✅ 연결 상태: 정상\n📧 ${profile.email}`);
} else {
await sendMessage(chatId, `❌ 연결된 계정이 없습니다.\n마이페이지에서 연결해주세요.`);
}
return NextResponse.json({ ok: true });
}
if (text === '/help') {
await sendMessage(
chatId,
`*쟁승메이드 로또 봇 명령어*\n\n/status — 연결 상태 확인\n/help — 도움말`
);
return NextResponse.json({ ok: true });
}
// 기본 응답
await sendMessage(chatId, `/help 를 입력하면 사용 가능한 명령어를 확인할 수 있습니다.`);
return NextResponse.json({ ok: true });
}

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { usePathname } from 'next/navigation';
import Sidebar from './Sidebar';
const AUTH_PATHS = ['/login', '/signup'];
const AUTH_PATHS = ['/login', '/signup', '/admin'];
export default function DashboardShell({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);

View File

@@ -0,0 +1,256 @@
'use client';
/**
* TelegramGuideModal
* 고객에게 텔레그램 연결 방법을 단계별로 시각적으로 설명하는 모달
* - 이미지로 캡처해서 공유하거나 인앱으로 보여줄 수 있음
*/
export default function TelegramGuideModal({ onClose }: { onClose: () => void }) {
const steps = [
{
no: 1,
title: '마이페이지 접속',
desc: '로그인 후 우측 상단 프로필 메뉴 → 마이페이지로 이동합니다.',
icon: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
),
color: 'bg-blue-50 border-blue-200 text-blue-600',
dot: 'bg-blue-500',
mockup: (
<div className="bg-white rounded-xl border border-slate-200 p-3 text-xs shadow-sm">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-slate-100">
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-blue-500 to-violet-500" />
<span className="font-semibold text-slate-700"> </span>
<span className="ml-auto text-slate-400"> </span>
</div>
<div className="text-slate-500"> · · </div>
</div>
),
},
{
no: 2,
title: '\'내 정보\' 탭 → 텔레그램 연결하기 클릭',
desc: '\'내 정보\' 탭의 텔레그램 알림 연동 섹션에서 버튼을 클릭합니다.',
icon: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5" />
</svg>
),
color: 'bg-sky-50 border-sky-200 text-sky-600',
dot: 'bg-sky-500',
mockup: (
<div className="bg-white rounded-xl border border-slate-200 p-3 shadow-sm">
<div className="text-xs font-semibold text-slate-600 mb-2 flex items-center gap-1">
<span className="w-1 h-3.5 bg-sky-400 rounded-full inline-block" />
<span className="ml-auto text-xs text-slate-400 font-normal"> · </span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center">
<svg className="w-4 h-4 text-slate-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
</div>
<div>
<div className="text-xs font-semibold text-slate-700"> </div>
<div className="text-xs text-slate-400"> </div>
</div>
</div>
<div className="px-3 py-1.5 bg-gradient-to-r from-sky-500 to-blue-600 text-white text-xs font-bold rounded-lg shadow-sm animate-pulse">
</div>
</div>
</div>
),
},
{
no: 3,
title: '\'텔레그램 봇 열기\' 버튼 클릭',
desc: '연결 코드가 생성되면 파란색 버튼을 클릭합니다. 자동으로 텔레그램 앱이 열립니다.',
icon: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
),
color: 'bg-indigo-50 border-indigo-200 text-indigo-600',
dot: 'bg-indigo-500',
mockup: (
<div className="bg-white rounded-xl border border-slate-200 p-3 shadow-sm space-y-2">
<div className="bg-sky-50 border border-sky-200 rounded-lg p-2.5">
<p className="text-xs font-semibold text-sky-700 mb-1">📱 </p>
<ol className="text-xs text-sky-600 space-y-0.5 list-decimal list-inside">
<li> </li>
<li> <strong></strong> </li>
<li> </li>
</ol>
</div>
<div className="flex items-center justify-center gap-2 py-1">
<div className="flex items-center gap-1.5 px-4 py-2 bg-sky-500 text-white text-xs font-bold rounded-lg shadow-sm w-full justify-center">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
</div>
</div>
</div>
),
},
{
no: 4,
title: '텔레그램에서 \'시작\' 버튼 클릭',
desc: '텔레그램 앱이 열리면 채팅창 하단의 파란 \'시작\' 버튼을 클릭합니다. 자동으로 연결이 완료됩니다.',
icon: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
),
color: 'bg-sky-50 border-sky-200 text-sky-500',
dot: 'bg-sky-400',
mockup: (
<div className="bg-[#1c2733] rounded-xl p-3 shadow-sm">
{/* 텔레그램 UI 모킹 */}
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-white/10">
<div className="w-7 h-7 rounded-full bg-sky-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
</div>
<div>
<div className="text-xs font-semibold text-white"> </div>
<div className="text-xs text-white/40">bot</div>
</div>
</div>
<div className="bg-[#2b3d52] rounded-lg p-2 text-xs text-white/70 mb-2">
! . .
</div>
<div className="flex justify-center">
<div className="px-6 py-1.5 bg-sky-500 text-white text-xs font-bold rounded-full animate-bounce">
</div>
</div>
</div>
),
},
{
no: 5,
title: '연결 완료!',
desc: '봇이 "연결 완료" 메시지를 보냅니다. 마이페이지로 돌아와 \'연결 확인 새로고침\' 버튼을 누르면 완료됩니다.',
icon: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
color: 'bg-emerald-50 border-emerald-200 text-emerald-600',
dot: 'bg-emerald-500',
mockup: (
<div className="space-y-2">
<div className="bg-[#1c2733] rounded-xl p-3 shadow-sm">
<div className="bg-[#2b3d52] rounded-lg p-2 text-xs text-white/80">
🎉 <strong className="text-white"> !</strong><br />
<span className="text-white/60"> . 🎰</span>
</div>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-3 shadow-sm">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-sky-50 border border-sky-200 flex items-center justify-center">
<svg className="w-4 h-4 text-sky-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
</div>
<div>
<div className="text-xs font-semibold text-[#04102b] flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 inline-block" />
</div>
<div className="text-xs text-slate-500"> </div>
</div>
</div>
</div>
</div>
),
},
];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-white rounded-3xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="sticky top-0 bg-white rounded-t-3xl border-b border-slate-100 px-6 py-4 flex items-center justify-between z-10">
<div>
<h2 className="text-base font-extrabold text-[#04102b] flex items-center gap-2">
<svg className="w-5 h-5 text-sky-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
</h2>
<p className="text-xs text-slate-500 mt-0.5">5 </p>
</div>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200 transition text-slate-500 text-sm font-bold"
>
</button>
</div>
{/* 스텝 목록 */}
<div className="px-6 py-5 space-y-5">
{steps.map((step, idx) => (
<div key={step.no} className="relative">
{/* 연결선 */}
{idx < steps.length - 1 && (
<div className="absolute left-5 top-14 w-0.5 h-4 bg-slate-200" />
)}
<div className="flex gap-3">
{/* 스텝 번호 + 아이콘 */}
<div className={`w-10 h-10 rounded-2xl border-2 flex items-center justify-center flex-shrink-0 ${step.color}`}>
{step.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`w-5 h-5 rounded-full text-white text-xs font-extrabold flex items-center justify-center flex-shrink-0 ${step.dot}`}>
{step.no}
</span>
<h3 className="text-sm font-extrabold text-[#04102b] leading-tight">{step.title}</h3>
</div>
<p className="text-xs text-slate-500 leading-relaxed mb-2.5">{step.desc}</p>
{/* 화면 목업 */}
<div className="rounded-xl overflow-hidden">
{step.mockup}
</div>
</div>
</div>
</div>
))}
{/* 안내 메시지 */}
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-4">
<p className="text-xs font-bold text-amber-700 mb-1.5 flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</p>
<ul className="text-xs text-amber-600 space-y-1">
<li> <strong>15</strong> </li>
<li> (iOS / Android / PC )</li>
<li> </li>
<li> · </li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js';
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
function buildSajuResultUrl(rec: SajuRecord) {
const { birth_year, birth_month, birth_day, birth_hour, gender } = rec.saju_data;
@@ -15,6 +16,7 @@ function buildSajuResultUrl(rec: SajuRecord) {
}
type Tab = 'profile' | 'saju' | 'payments' | 'orders';
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
interface SajuRecord {
id: number;
@@ -56,6 +58,13 @@ export default function MyPage() {
const [payments, setPayments] = useState<Payment[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
// 텔레그램 연동 상태
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
const [telegramLinkState, setTelegramLinkState] = useState<TelegramLinkState>('idle');
const [telegramDeepLink, setTelegramDeepLink] = useState<string>('');
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
useEffect(() => {
async function init() {
const { data: { user } } = await supabase.auth.getUser();
@@ -92,6 +101,14 @@ export default function MyPage() {
.limit(20);
setOrders(ord || []);
// 텔레그램 chat_id 조회
const { data: profile } = await supabase
.from('profiles')
.select('telegram_chat_id')
.eq('id', user.id)
.maybeSingle();
setTelegramChatId(profile?.telegram_chat_id ?? null);
setLoading(false);
}
init();
@@ -103,6 +120,51 @@ export default function MyPage() {
router.refresh();
};
// ── 텔레그램 연결 ──
const handleTelegramConnect = async () => {
setTelegramLinkState('generating');
try {
const res = await fetch('/api/telegram/connect', { method: 'POST' });
if (!res.ok) throw new Error('API_ERROR');
const data = await res.json();
setTelegramDeepLink(data.deepLink);
setTelegramLinkExpiry(new Date(data.expiresAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }));
setTelegramLinkState('waiting');
// 15분 후 자동으로 idle 복귀
setTimeout(() => setTelegramLinkState('idle'), 15 * 60 * 1000);
} catch {
setTelegramLinkState('idle');
alert('연결 코드 발급 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
}
};
// 연결 후 상태 새로고침 (버튼 클릭 시)
const handleTelegramRefresh = async () => {
const { data: profile } = await supabase
.from('profiles')
.select('telegram_chat_id')
.eq('id', user!.id)
.maybeSingle();
const chatId = profile?.telegram_chat_id ?? null;
setTelegramChatId(chatId);
if (chatId) setTelegramLinkState('idle');
};
// ── 텔레그램 연결 해제 ──
const handleTelegramDisconnect = async () => {
if (!confirm('텔레그램 연결을 해제하시겠습니까?')) return;
setTelegramLinkState('disconnecting');
try {
await fetch('/api/telegram/connect', { method: 'DELETE' });
setTelegramChatId(null);
setTelegramDeepLink('');
} catch {
alert('연결 해제 중 오류가 발생했습니다.');
}
setTelegramLinkState('idle');
};
if (loading) {
return (
<div className="min-h-full flex items-center justify-center bg-[#f0f5ff]">
@@ -122,6 +184,11 @@ export default function MyPage() {
return (
<div className="min-h-full bg-[#f0f5ff]">
{/* 텔레그램 가이드 모달 */}
{showTelegramGuide && (
<TelegramGuideModal onClose={() => setShowTelegramGuide(false)} />
)}
{/* 헤더 */}
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-10">
<div className="max-w-4xl mx-auto">
@@ -202,6 +269,109 @@ export default function MyPage() {
</div>
</div>
{/* 텔레그램 연동 카드 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-sky-500 to-blue-600 rounded-full" />
<button
onClick={() => setShowTelegramGuide(true)}
className="ml-1 w-5 h-5 rounded-full bg-slate-100 hover:bg-sky-100 text-slate-400 hover:text-sky-500 text-xs font-bold flex items-center justify-center transition"
title="연결 방법 보기"
>
?
</button>
<span className="ml-auto text-xs text-slate-400 font-normal"> · </span>
</h2>
{telegramChatId ? (
/* ── 연결됨 ── */
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-sky-50 border border-sky-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-sky-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#04102b] flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 inline-block" />
</div>
<div className="text-xs text-slate-500">Chat ID: {telegramChatId}</div>
</div>
</div>
<button
onClick={handleTelegramDisconnect}
disabled={telegramLinkState === 'disconnecting'}
className="px-4 py-2 text-xs font-semibold text-red-500 border border-red-200 rounded-xl hover:bg-red-50 transition disabled:opacity-50"
>
{telegramLinkState === 'disconnecting' ? '해제 중...' : '연결 해제'}
</button>
</div>
) : telegramLinkState === 'waiting' ? (
/* ── 연결 대기 중 ── */
<div className="space-y-4">
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4">
<p className="text-sm font-semibold text-sky-700 mb-1">📱 </p>
<ol className="text-xs text-sky-600 space-y-1 list-decimal list-inside">
<li> </li>
<li> <strong></strong> </li>
<li> &quot; &quot; </li>
</ol>
<p className="text-xs text-sky-500 mt-2"> : {telegramLinkExpiry}</p>
</div>
<div className="flex gap-2 flex-wrap">
<a
href={telegramDeepLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-sky-500 hover:bg-sky-400 text-white text-sm font-bold rounded-xl transition shadow-sm shadow-sky-200"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
</a>
<button
onClick={handleTelegramRefresh}
className="px-4 py-2.5 text-sm font-semibold text-slate-600 border border-slate-200 rounded-xl hover:bg-slate-50 transition"
>
</button>
<button
onClick={() => setTelegramLinkState('idle')}
className="px-4 py-2.5 text-sm text-slate-400 rounded-xl hover:text-slate-600 transition"
>
</button>
</div>
</div>
) : (
/* ── 미연결 ── */
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-slate-50 border border-slate-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-slate-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z"/>
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#04102b]"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</div>
<button
onClick={handleTelegramConnect}
disabled={telegramLinkState === 'generating'}
className="px-5 py-2.5 text-sm font-bold text-white bg-gradient-to-r from-sky-500 to-blue-600 hover:from-sky-400 hover:to-blue-500 rounded-xl shadow-sm shadow-sky-200 transition disabled:opacity-60"
>
{telegramLinkState === 'generating' ? '생성 중...' : '텔레그램 연결하기'}
</button>
</div>
)}
</div>
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-blue-600 to-violet-600 rounded-full" />
@@ -219,6 +389,17 @@ export default function MyPage() {
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
<Link href="/services/lotto/recommend" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-amber-300 hover:bg-amber-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#04102b]"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
<Link href="/freelance" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-blue-300 hover:bg-blue-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-blue-50 border border-blue-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -6,7 +6,7 @@ import ContactModal from '../../components/ContactModal';
import PaymentButton from '../../components/PaymentButton';
const CHECKLIST = [
'구독 플랜 선택 (기본 / 프리미엄 / 연간)',
'구독 플랜 선택 (골드 / 플래티넘 / 다이아)',
'번호 수신 방법 (이메일 / 텔레그램 중 선택)',
'로또 구매 후 직접 확인 필요 (자동 구매 아님)',
'당첨 보장 없음 — 통계 기반 확률 최적화 서비스',
@@ -15,8 +15,9 @@ const CHECKLIST = [
const plans = [
{
name: '기본 플랜',
price: '4,900원',
name: '골드 플랜',
badge: '🥇',
price: '900원',
period: '/ 월',
desc: '매주 1회 번호 추천',
features: [
@@ -25,11 +26,12 @@ const plans = [
'이메일 발송',
],
highlight: false,
productId: 'lotto_basic',
productId: 'lotto_gold',
},
{
name: '프리미엄 플랜',
price: '9,900원',
name: '플래티넘 플랜',
badge: '💎',
price: '2,900원',
period: '/ 월',
desc: '매주 3회 + 상세 분석 보고서',
features: [
@@ -40,21 +42,22 @@ const plans = [
'이메일 + 텔레그램 알림',
],
highlight: true,
productId: 'lotto_premium',
productId: 'lotto_platinum',
},
{
name: '연간 플랜',
price: '89,900원',
period: '/ 년',
desc: '프리미엄 12개월 (2개월 무료)',
name: '다이아 플랜',
badge: '👑',
price: '9,900원',
period: '/ 월',
desc: '횟수 무제한 + 전체 기능',
features: [
'프리미엄 플랜 전체 기능',
'플래티넘 플랜 전체 기능',
'번호 생성 횟수 무제한',
'연간 당첨 패턴 리포트',
'우선 고객 지원',
'2개월 무료 혜택',
],
highlight: false,
productId: 'lotto_annual',
productId: 'lotto_diamond',
},
];
@@ -160,6 +163,43 @@ export default function LottoPage() {
</div>
</div>
{/* ─── 구독자 전용 번호 추천 CTA ─── */}
<div className="px-6 pt-10 lg:px-12">
<div className="max-w-5xl mx-auto">
<div className="relative overflow-hidden rounded-2xl border border-amber-400/30 bg-gradient-to-r from-amber-950/60 via-orange-950/40 to-amber-950/60 p-6 flex flex-col sm:flex-row items-center gap-5">
{/* glow */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-64 h-24 bg-amber-400/10 blur-3xl rounded-full" />
</div>
<div className="relative flex items-center gap-4 flex-1">
<div className="w-14 h-14 rounded-2xl bg-amber-400/15 border border-amber-400/30 flex items-center justify-center flex-shrink-0">
<svg className="w-8 h-8 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
<div>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-amber-400 font-extrabold text-sm"> </span>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
</div>
<h3 className="text-white font-extrabold text-base leading-tight">
</h3>
<p className="text-amber-200/45 text-xs mt-0.5">
5
</p>
</div>
</div>
<Link
href="/services/lotto/recommend"
className="relative flex-shrink-0 inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[#78350f] px-6 py-3 rounded-xl font-extrabold text-sm transition-all shadow-lg shadow-amber-900/40 whitespace-nowrap"
>
</Link>
</div>
</div>
</div>
{/* ─── 분석 기능 ─── */}
<div className="px-6 py-12 lg:px-12">
<div className="max-w-5xl mx-auto">
@@ -198,7 +238,10 @@ export default function LottoPage() {
{plan.highlight && (
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-amber-400 text-[#04102b] text-xs font-extrabold px-4 py-1 rounded-full tracking-wide"></div>
)}
<div className={`text-xs font-bold mb-2 tracking-wide ${plan.highlight ? 'text-amber-400' : 'text-slate-400'}`}>{plan.name.toUpperCase()}</div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-base">{plan.badge}</span>
<span className={`text-xs font-bold tracking-wide ${plan.highlight ? 'text-amber-400' : 'text-slate-400'}`}>{plan.name.toUpperCase()}</span>
</div>
<div className="flex items-baseline gap-1 mb-1">
<span className={`text-3xl font-extrabold ${plan.highlight ? 'text-white' : 'text-[#04102b]'}`}>{plan.price}</span>
<span className={`text-sm ${plan.highlight ? 'text-amber-300/60' : 'text-slate-400'}`}>{plan.period}</span>

View File

@@ -0,0 +1,724 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
// ─── 클라이언트 Monte Carlo 폴백 ─────────────────────────────────────────────
// NAS 서버가 응답하지 않을 때 브라우저에서 직접 실행하는 간단한 시뮬레이션
function clientMonteCarlo(): { numbers: number[]; metrics: { sum: number; odd: number; even: number; min: number; max: number; range: number } } {
const SIMS = 5000;
let best: number[] = [];
let bestScore = -Infinity;
for (let i = 0; i < SIMS; i++) {
const nums = pickRandom6();
const score = scoreCombo(nums);
if (score > bestScore) { bestScore = score; best = nums; }
}
const sorted = [...best].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
const odd = sorted.filter(n => n % 2 !== 0).length;
return {
numbers: sorted,
metrics: { sum, odd, even: 6 - odd, min: sorted[0], max: sorted[5], range: sorted[5] - sorted[0] },
};
}
function pickRandom6(): number[] {
const pool = Array.from({ length: 45 }, (_, i) => i + 1);
const result: number[] = [];
while (result.length < 6) {
const idx = Math.floor(Math.random() * pool.length);
result.push(pool.splice(idx, 1)[0]);
}
return result;
}
function scoreCombo(nums: number[]): number {
const sorted = [...nums].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
const odd = sorted.filter(n => n % 2 !== 0).length;
// 합계 100~175 선호 (역대 평균 138)
const sumScore = -Math.abs(sum - 138) / 35;
// 홀짝 2~4개 선호
const oddScore = odd >= 2 && odd <= 4 ? 0.5 : -0.5;
// 구간 분산 (1-9, 10-19, 20-29, 30-39, 40-45)
const zones = new Set(sorted.map(n => Math.min(Math.floor((n - 1) / 10), 4)));
const zoneScore = zones.size * 0.4;
return sumScore + oddScore + zoneScore + Math.random() * 0.05;
}
// ─── Types ───────────────────────────────────────────────────────────────────
interface LottoMetrics {
sum: number;
odd: number;
even: number;
min: number;
max: number;
range: number;
}
interface RecommendResponse {
ok: boolean;
plan: string;
numbers: number[];
metrics?: LottoMetrics;
recent_overlap?: { repeated_numbers: number[] };
}
interface BatchResponse {
ok: boolean;
plan: string;
count: number;
items: Array<{ numbers: number[]; metrics?: LottoMetrics }>;
}
interface NumberStat {
number: number;
frequency_pct: number;
z_score: number;
gap: number;
}
interface DashboardResponse {
ok: boolean;
plan: string;
latest: {
drawNo: number;
date: string;
numbers: number[];
bonus: number;
metrics: LottoMetrics;
} | null;
analysis: {
total_draws: number;
mean_sum: number;
number_stats: NumberStat[];
} | null;
simulation: {
runs: Array<{
id: number;
run_at: string;
strategy: string;
total_generated: number;
avg_score: number;
}>;
} | null;
}
interface Combo {
id: number;
numbers: number[];
metrics?: LottoMetrics;
overlap?: number[];
createdAt: Date;
}
type GenMode = 'single' | 'batch';
const PLAN_LABELS: Record<string, string> = {
lotto_gold: '🥇 골드',
lotto_platinum: '💎 플래티넘',
lotto_diamond: '👑 다이아',
};
// 다이아 플랜은 무제한 (사실상 999)
const PLAN_MAX_COMBOS: Record<string, number> = {
lotto_gold: 1,
lotto_platinum: 3,
lotto_diamond: 999,
};
// ─── Lotto Ball ───────────────────────────────────────────────────────────────
function getBallStyle(n: number): { bg: string; shadow: string; text: string } {
if (n <= 10) return { bg: 'linear-gradient(145deg,#fde68a,#fbbf24,#d97706)', shadow: 'rgba(251,191,36,.6)', text: '#78350f' };
if (n <= 20) return { bg: 'linear-gradient(145deg,#93c5fd,#3b82f6,#1d4ed8)', shadow: 'rgba(59,130,246,.6)', text: '#fff' };
if (n <= 30) return { bg: 'linear-gradient(145deg,#fca5a5,#ef4444,#b91c1c)', shadow: 'rgba(239,68,68,.6)', text: '#fff' };
if (n <= 40) return { bg: 'linear-gradient(145deg,#d1d5db,#9ca3af,#4b5563)', shadow: 'rgba(107,114,128,.6)', text: '#fff' };
return { bg: 'linear-gradient(145deg,#86efac,#22c55e,#15803d)', shadow: 'rgba(34,197,94,.6)', text: '#fff' };
}
function LottoBall({ n, size = 52, delay = 0, bounce = false, highlight = false }: {
n: number; size?: number; delay?: number; bounce?: boolean; highlight?: boolean;
}) {
const [show, setShow] = useState(!bounce);
const { bg, shadow, text } = getBallStyle(n);
useEffect(() => {
if (!bounce) return;
const t = setTimeout(() => setShow(true), delay);
return () => clearTimeout(t);
}, [bounce, delay]);
return (
<div style={{
width: size, height: size, borderRadius: '50%', background: bg,
boxShadow: `0 ${size * .08}px ${size * .3}px ${shadow}${highlight ? ',0 0 0 3px rgba(251,191,36,.8)' : ''},inset 0 1px 0 rgba(255,255,255,.45),inset 0 -2px 4px rgba(0,0,0,.18)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * .35, fontWeight: 900, color: text, flexShrink: 0,
position: 'relative', userSelect: 'none',
opacity: show ? 1 : 0,
transform: show ? 'scale(1) translateY(0)' : 'scale(.2) translateY(20px)',
transition: `opacity .35s ease ${delay}ms,transform .5s cubic-bezier(.34,1.56,.64,1) ${delay}ms`,
}}>
<div style={{ position: 'absolute', top: '14%', left: '18%', width: '38%', height: '28%', background: 'rgba(255,255,255,.38)', borderRadius: '50%', filter: 'blur(2px)', transform: 'rotate(-30deg)', pointerEvents: 'none' }} />
<span style={{ position: 'relative', zIndex: 1 }}>{n}</span>
</div>
);
}
function SpinBall({ n, delay = 0 }: { n: number; delay?: number }) {
const { bg, shadow, text } = getBallStyle(n);
return (
<div style={{
width: 52, height: 52, borderRadius: '50%', background: bg,
boxShadow: `0 4px 16px ${shadow},inset 0 1px 0 rgba(255,255,255,.4)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 900, color: text, flexShrink: 0,
animation: 'spinBounce .9s ease-in-out infinite', animationDelay: `${delay}ms`,
}}>
{n}
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function LottoRecommendPage() {
const supabase = createClient();
// 구독 상태
const [isSubscribed, setIsSubscribed] = useState(false);
const [plan, setPlan] = useState('');
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [pageReady, setPageReady] = useState(false);
// 무료 맛보기
const [previewNumbers, setPreviewNumbers] = useState<number[]>([]);
const [previewMetrics, setPreviewMetrics] = useState<LottoMetrics | null>(null);
const [previewState, setPreviewState] = useState<'idle' | 'loading' | 'result' | 'error'>('idle');
const [previewUsed, setPreviewUsed] = useState(false);
const [previewSource, setPreviewSource] = useState<'nas' | 'client'>('client');
// 프리미엄 생성
const [genMode, setGenMode] = useState<GenMode>('single');
const [combos, setCombos] = useState<Combo[]>([]);
const [proState, setProState] = useState<'idle' | 'loading' | 'result' | 'error'>('idle');
const [proError, setProError] = useState('');
const idRef = useRef(0);
// 플랜별 최대 조합 수 (plan 상태가 확정된 후 계산)
const MAX_COMBOS = PLAN_MAX_COMBOS[plan] ?? 5;
const SPIN_NUMS = [7, 23, 41, 14, 35, 3];
// ── 초기화: 인증 + 대시보드 ──
useEffect(() => {
async function init() {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
try {
const res = await fetch('/api/lotto/dashboard');
if (res.ok) {
const data: DashboardResponse = await res.json();
setDashboard(data);
setPlan(data.plan ?? '');
setIsSubscribed(true);
}
// 403 = 미구독 (pageReady는 true)
} catch { /* ignore */ }
}
setPageReady(true);
}
init();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── 무료 맛보기 생성 ──
const handlePreview = async () => {
if (previewState === 'loading') return;
setPreviewState('loading');
try {
// 1) NAS API 시도
const res = await fetch('/api/lotto/preview');
if (res.ok) {
const data = await res.json();
setPreviewNumbers([...data.numbers].sort((a, b) => a - b));
setPreviewMetrics(data.metrics ?? null);
setPreviewSource('nas');
} else {
// 2) NAS 불가 → 클라이언트 Monte Carlo 폴백
const { numbers, metrics } = clientMonteCarlo();
setPreviewNumbers(numbers);
setPreviewMetrics(metrics);
setPreviewSource('client');
}
setPreviewState('result');
setPreviewUsed(true);
} catch {
// 네트워크 자체 오류도 클라이언트 폴백
try {
const { numbers, metrics } = clientMonteCarlo();
setPreviewNumbers(numbers);
setPreviewMetrics(metrics);
setPreviewSource('client');
setPreviewState('result');
setPreviewUsed(true);
} catch {
setPreviewState('error');
}
}
};
// ── 프리미엄 번호 생성 ──
const handleGenerate = async () => {
if (proState === 'loading' || combos.length >= MAX_COMBOS) return;
setProState('loading');
setProError('');
try {
const url = genMode === 'batch'
? '/api/lotto/recommend?mode=batch'
: '/api/lotto/recommend?mode=single';
const res = await fetch(url);
if (res.status === 403) { setIsSubscribed(false); setProState('idle'); return; }
if (!res.ok) { const e = await res.json(); throw new Error(e.error ?? 'API_ERROR'); }
if (genMode === 'batch') {
const data: BatchResponse = await res.json();
const newCombos: Combo[] = (data.items ?? []).map((item) => {
idRef.current += 1;
return { id: idRef.current, numbers: [...item.numbers].sort((a,b)=>a-b), metrics: item.metrics, createdAt: new Date() };
});
setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS));
} else {
const data: RecommendResponse = await res.json();
if (!data.numbers?.length) throw new Error('EMPTY_RESULT');
idRef.current += 1;
setCombos((prev) => [...prev, {
id: idRef.current,
numbers: [...data.numbers].sort((a,b)=>a-b),
metrics: data.metrics,
overlap: data.recent_overlap?.repeated_numbers,
createdAt: new Date(),
}]);
}
setProState('result');
} catch (err: unknown) {
const e = err as { message?: string };
setProError(e?.message === 'NAS_TIMEOUT' ? 'NAS 서버 응답 시간 초과.' : '생성 중 오류가 발생했습니다.');
setProState('error');
}
};
const clearCombos = () => { setCombos([]); setProState('idle'); setProError(''); };
// 핫/콜드 계산
const hotNumbers = dashboard?.analysis?.number_stats
?.filter(s => s.z_score > 0.3)
.sort((a,b) => b.z_score - a.z_score)
.slice(0, 8)
.map(s => s.number) ?? [];
const coldNumbers = dashboard?.analysis?.number_stats
?.filter(s => s.z_score < -0.3)
.sort((a,b) => b.gap - a.gap)
.slice(0, 8)
.map(s => s.number) ?? [];
const latestRun = dashboard?.simulation?.runs?.[0];
const totalDraws = dashboard?.analysis?.total_draws;
const isProLoading = proState === 'loading';
const isMaxed = combos.length >= MAX_COMBOS;
const latestCombo = combos.length > 0 ? combos[combos.length - 1] : null;
if (!pageReady) {
return (
<div style={{ minHeight: '100vh', background: 'linear-gradient(160deg,#0a0500,#1a0a00 40%,#04102b 75%,#020b1a)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', border: '3px solid rgba(251,191,36,.2)', borderTop: '3px solid #fbbf24', animation: 'spin .8s linear infinite' }} />
</div>
);
}
return (
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Black+Han+Sans&family=Noto+Sans+KR:wght@400;600;700;800;900&display=swap');
@keyframes spinBounce { 0%,100%{transform:translateY(0) rotate(0deg)} 25%{transform:translateY(-14px) rotate(90deg)} 50%{transform:translateY(0) rotate(180deg)} 75%{transform:translateY(-7px) rotate(270deg)} }
@keyframes spin { to{transform:rotate(360deg)} }
@keyframes shimmer { 0%{background-position:-200% center} 100%{background-position:200% center} }
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-9px)} }
@keyframes glowPulse { 0%,100%{box-shadow:0 0 20px rgba(251,191,36,.15)} 50%{box-shadow:0 0 40px rgba(251,191,36,.4),0 0 80px rgba(251,191,36,.1)} }
@keyframes slideUp { from{opacity:0;transform:translateY(18px)} to{opacity:1;transform:translateY(0)} }
@keyframes orbFloat1 { 0%,100%{transform:translate(0,0) scale(1)} 33%{transform:translate(30px,-20px) scale(1.08)} 66%{transform:translate(-20px,30px) scale(.92)} }
@keyframes orbFloat2 { 0%,100%{transform:translate(0,0) scale(1)} 33%{transform:translate(-30px,20px) scale(.92)} 66%{transform:translate(20px,-30px) scale(1.08)} }
@keyframes badgePop { 0%{transform:scale(.7);opacity:0} 70%{transform:scale(1.08)} 100%{transform:scale(1);opacity:1} }
.gen-btn:not(:disabled):hover{transform:translateY(-2px) scale(1.02)!important;box-shadow:0 10px 40px rgba(251,191,36,.55)!important}
.gen-btn:not(:disabled):active{transform:translateY(0) scale(.97)!important}
.combo-card{animation:slideUp .45s ease forwards}
.mode-tab{transition:all .2s ease}
.preview-btn:not(:disabled):hover{opacity:.9;transform:translateY(-1px)}
`}</style>
<div style={{ minHeight: '100%', background: 'linear-gradient(160deg,#0a0500 0%,#1a0a00 25%,#04102b 60%,#020b1a 100%)', fontFamily:"'Noto Sans KR',sans-serif", position: 'relative', overflow: 'hidden' }}>
{/* ambient orbs */}
<div style={{ position:'fixed',top:'8%',left:'3%',width:440,height:440,borderRadius:'50%',background:'radial-gradient(circle,rgba(251,191,36,.07),transparent 70%)',animation:'orbFloat1 14s ease-in-out infinite',pointerEvents:'none',zIndex:0 }} />
<div style={{ position:'fixed',bottom:'10%',right:'3%',width:520,height:520,borderRadius:'50%',background:'radial-gradient(circle,rgba(59,130,246,.05),transparent 70%)',animation:'orbFloat2 17s ease-in-out infinite',pointerEvents:'none',zIndex:0 }} />
<div style={{ position:'relative',zIndex:1,maxWidth:900,margin:'0 auto',padding:'2rem 1.5rem 4rem' }}>
{/* ── Header ── */}
<div style={{ marginBottom:'2rem' }}>
<Link href="/services/lotto" style={{ display:'inline-flex',alignItems:'center',gap:'.35rem',color:'rgba(251,191,36,.45)',fontSize:'.78rem',textDecoration:'none',marginBottom:'1.5rem' }}>
</Link>
<div style={{ display:'flex',alignItems:'flex-start',justifyContent:'space-between',flexWrap:'wrap',gap:'1rem' }}>
<div>
<div style={{ fontSize:'.68rem',fontWeight:700,letterSpacing:'.16em',textTransform:'uppercase',color:'rgba(251,191,36,.55)',marginBottom:'.5rem' }}>
Monte Carlo Simulation ·
</div>
<h1 style={{ fontFamily:"'Black Han Sans',sans-serif",fontSize:'clamp(1.8rem,4.5vw,2.6rem)',color:'#fff',margin:0,lineHeight:1.1 }}>
<br />
<span style={{ background:'linear-gradient(90deg,#fbbf24,#f97316,#fbbf24)',backgroundSize:'200% auto',WebkitBackgroundClip:'text',WebkitTextFillColor:'transparent',animation:'shimmer 3s linear infinite' }}> </span>
</h1>
</div>
{isSubscribed && plan && (
<div style={{ display:'inline-flex',alignItems:'center',gap:'.5rem',background:'linear-gradient(135deg,rgba(251,191,36,.12),rgba(249,115,22,.07))',border:'1px solid rgba(251,191,36,.27)',borderRadius:'2rem',padding:'.5rem 1.1rem',animation:'glowPulse 3s ease-in-out infinite' }}>
<div style={{ width:7,height:7,borderRadius:'50%',background:'#4ade80',boxShadow:'0 0 6px rgba(74,222,128,.7)' }} />
<span style={{ color:'#fbbf24',fontSize:'.8rem',fontWeight:700 }}>{PLAN_LABELS[plan] ?? plan} </span>
</div>
)}
</div>
</div>
{/* ── 최신 당첨번호 (구독자에게만) ── */}
{isSubscribed && dashboard?.latest && (
<div style={{ background:'linear-gradient(145deg,rgba(255,255,255,.03),rgba(255,255,255,.01))',border:'1px solid rgba(255,255,255,.07)',borderRadius:'1.25rem',padding:'1.25rem 1.5rem',marginBottom:'1.25rem',display:'flex',alignItems:'center',gap:'1.25rem',flexWrap:'wrap' }}>
<div>
<div style={{ color:'rgba(255,255,255,.28)',fontSize:'.68rem',fontWeight:700,letterSpacing:'.1em',textTransform:'uppercase',marginBottom:'.25rem' }}> </div>
<div style={{ color:'rgba(255,255,255,.5)',fontSize:'.78rem' }}>{dashboard.latest.drawNo} · {dashboard.latest.date}</div>
</div>
<div style={{ display:'flex',gap:'.4rem',alignItems:'center',flexWrap:'wrap' }}>
{dashboard.latest.numbers.map((n,i) => <LottoBall key={i} n={n} size={32} />)}
<span style={{ color:'rgba(255,255,255,.2)',fontSize:'.8rem',margin:'0 .2rem' }}>+</span>
<div style={{ position:'relative',width:32,height:32,borderRadius:'50%',border:'2px solid rgba(251,191,36,.4)',display:'flex',alignItems:'center',justifyContent:'center' }}>
<LottoBall n={dashboard.latest.bonus} size={28} />
</div>
</div>
<div style={{ marginLeft:'auto',display:'flex',gap:'1.25rem' }}>
{[{l:'합계',v:dashboard.latest.metrics.sum},{l:'홀수',v:`${dashboard.latest.metrics.odd}`},{l:'짝수',v:`${dashboard.latest.metrics.even}`}].map(s=>(
<div key={s.l} style={{ textAlign:'center' }}>
<div style={{ color:'#fbbf24',fontSize:'.9rem',fontWeight:800 }}>{s.v}</div>
<div style={{ color:'rgba(255,255,255,.25)',fontSize:'.62rem' }}>{s.l}</div>
</div>
))}
</div>
</div>
)}
{/* ════════════════════════════════════════════════
무료 맛보기 섹션 (모든 사용자)
════════════════════════════════════════════════ */}
<div style={{ marginBottom:'1.5rem' }}>
{/* 섹션 라벨 */}
<div style={{ display:'flex',alignItems:'center',gap:'.75rem',marginBottom:'.875rem' }}>
<div style={{ display:'inline-flex',alignItems:'center',gap:'.4rem',background:'rgba(74,222,128,.1)',border:'1px solid rgba(74,222,128,.25)',borderRadius:'2rem',padding:'.3rem .85rem' }}>
<div style={{ width:6,height:6,borderRadius:'50%',background:'#4ade80',boxShadow:'0 0 6px rgba(74,222,128,.7)' }} />
<span style={{ color:'#4ade80',fontSize:'.7rem',fontWeight:700,letterSpacing:'.08em' }}> </span>
</div>
<span style={{ color:'rgba(255,255,255,.25)',fontSize:'.72rem' }}>1 </span>
</div>
<div style={{ background:'linear-gradient(145deg,rgba(74,222,128,.05),rgba(34,197,94,.02))',border:'1px solid rgba(74,222,128,.15)',borderRadius:'1.5rem',padding:'2rem',position:'relative',overflow:'hidden' }}>
<div style={{ position:'absolute',top:-40,right:-40,width:160,height:160,borderRadius:'50%',background:'radial-gradient(circle,rgba(74,222,128,.06),transparent)',pointerEvents:'none' }} />
<div style={{ position:'relative',textAlign:'center' }}>
{/* 번호 표시 영역 */}
<div style={{ minHeight:90,display:'flex',alignItems:'center',justifyContent:'center',marginBottom:'1.5rem',gap:'.65rem',flexWrap:'wrap' }}>
{previewState === 'loading' ? (
SPIN_NUMS.slice(0,6).map((n,i) => <SpinBall key={i} n={n} delay={i*100} />)
) : previewState === 'result' && previewNumbers.length > 0 ? (
previewNumbers.map((n,i) => <LottoBall key={i} n={n} size={62} bounce delay={i*110} />)
) : (
Array.from({length:6},(_,i)=>(
<div key={i} style={{ width:62,height:62,borderRadius:'50%',border:'2px dashed rgba(74,222,128,.2)',display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(74,222,128,.18)',fontSize:'1.3rem',fontWeight:900,animation:`float ${2+i*.28}s ease-in-out infinite`,animationDelay:`${i*.18}s` }}>?</div>
))
)}
</div>
{/* 맛보기 메트릭 */}
{previewState === 'result' && previewMetrics && (
<div style={{ animation:'slideUp .4s ease' }}>
<div style={{ display:'flex',gap:'.75rem',justifyContent:'center',marginBottom:'.75rem',flexWrap:'wrap' }}>
{[{l:'합계',v:previewMetrics.sum},{l:'홀수',v:`${previewMetrics.odd}`},{l:'짝수',v:`${previewMetrics.even}`},{l:'범위',v:previewMetrics.range}].map(s=>(
<div key={s.l} style={{ background:'rgba(74,222,128,.07)',border:'1px solid rgba(74,222,128,.15)',borderRadius:'.5rem',padding:'.3rem .7rem',textAlign:'center' }}>
<div style={{ color:'#4ade80',fontSize:'.82rem',fontWeight:800 }}>{s.v}</div>
<div style={{ color:'rgba(74,222,128,.45)',fontSize:'.6rem' }}>{s.l}</div>
</div>
))}
</div>
{/* 출처 표시 */}
<div style={{ display:'inline-flex',alignItems:'center',gap:'.35rem',background:'rgba(0,0,0,.25)',borderRadius:'2rem',padding:'.25rem .75rem',marginBottom:'1rem' }}>
<div style={{ width:5,height:5,borderRadius:'50%',background: previewSource==='nas' ? '#4ade80' : '#94a3b8' }} />
<span style={{ color:'rgba(255,255,255,.25)',fontSize:'.62rem' }}>
{previewSource==='nas' ? 'NAS Monte Carlo 시뮬레이션' : '브라우저 간이 시뮬레이션 (5,000회)'}
</span>
</div>
</div>
)}
{previewState === 'error' && (
<p style={{ color:'#f87171',fontSize:'.8rem',marginBottom:'1rem',background:'rgba(239,68,68,.08)',border:'1px solid rgba(239,68,68,.2)',borderRadius:'.75rem',padding:'.6rem 1rem' }}>
. .
</p>
)}
{/* 버튼 */}
{!previewUsed ? (
<button
className="preview-btn"
onClick={handlePreview}
disabled={previewState === 'loading'}
style={{ background:'linear-gradient(135deg,#4ade80,#22c55e)',color:'#052e16',border:'none',borderRadius:'.875rem',padding:'.9rem 2.25rem',fontSize:'.95rem',fontWeight:800,cursor:previewState==='loading'?'not-allowed':'pointer',transition:'all .2s',boxShadow:'0 4px 20px rgba(34,197,94,.28)',display:'inline-flex',alignItems:'center',gap:'.5rem' }}>
{previewState === 'loading' ? (
<><div style={{ width:16,height:16,borderRadius:'50%',border:'2px solid rgba(5,46,22,.3)',borderTop:'2px solid rgba(5,46,22,.7)',animation:'spin .7s linear infinite' }} /> ...</>
) : (
<>🎰 </>
)}
</button>
) : (
<div style={{ display:'flex',flexDirection:'column',alignItems:'center',gap:'.75rem' }}>
<div style={{ display:'inline-flex',alignItems:'center',gap:'.4rem',background:'rgba(74,222,128,.1)',border:'1px solid rgba(74,222,128,.2)',borderRadius:'2rem',padding:'.4rem 1rem',animation:'badgePop .5s cubic-bezier(.34,1.56,.64,1)' }}>
<span style={{ color:'#4ade80',fontSize:'.78rem',fontWeight:700 }}> </span>
</div>
{!isSubscribed && (
<p style={{ color:'rgba(255,255,255,.3)',fontSize:'.75rem',margin:0 }}>
</p>
)}
</div>
)}
</div>
</div>
</div>
{/* ════════════════════════════════════════════════
프리미엄 구독 섹션 (블러 게이트)
════════════════════════════════════════════════ */}
<div style={{ position:'relative' }}>
{/* 섹션 라벨 */}
<div style={{ display:'flex',alignItems:'center',gap:'.75rem',marginBottom:'.875rem' }}>
<div style={{ display:'inline-flex',alignItems:'center',gap:'.4rem',background:'rgba(251,191,36,.1)',border:'1px solid rgba(251,191,36,.25)',borderRadius:'2rem',padding:'.3rem .85rem' }}>
<svg width={10} height={10} viewBox="0 0 24 24" fill="none" stroke="#fbbf24" strokeWidth={2.5}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
<span style={{ color:'#fbbf24',fontSize:'.7rem',fontWeight:700,letterSpacing:'.08em' }}> </span>
</div>
<span style={{ color:'rgba(255,255,255,.25)',fontSize:'.72rem' }}>
{isSubscribed ? '프리미엄 번호 추천' : '구독 시 제공되는 기능 미리보기'}
</span>
</div>
{/* 프리미엄 컨텐츠 (블러 or 실제) */}
<div style={{ position:'relative', filter: isSubscribed ? 'none' : 'blur(4px)', opacity: isSubscribed ? 1 : 0.45, pointerEvents: isSubscribed ? 'auto' : 'none', transition: 'filter .3s,opacity .3s', userSelect: isSubscribed ? 'auto' : 'none' }}>
{/* 생성 카드 */}
<div style={{ background:'linear-gradient(145deg,rgba(255,255,255,.04),rgba(255,255,255,.015))',border:'1px solid rgba(251,191,36,.16)',borderRadius:'1.75rem',padding:'2.5rem',marginBottom:'1.25rem',backdropFilter:'blur(20px)',position:'relative',overflow:'hidden' }}>
<div style={{ position:'absolute',top:-70,right:-70,width:220,height:220,borderRadius:'50%',background:'radial-gradient(circle,rgba(251,191,36,.07),transparent)',pointerEvents:'none' }} />
<div style={{ position:'relative',textAlign:'center' }}>
{/* 스탯 */}
<div style={{ display:'flex',justifyContent:'center',gap:'2.5rem',marginBottom:'1.75rem',flexWrap:'wrap' }}>
{[
{icon:'⚡',val:latestRun?`${(latestRun.total_generated/10000).toFixed(0)}만 회`:'10만 회',label:'시뮬레이션'},
{icon:'📊',val:totalDraws?`${totalDraws.toLocaleString()}`:'1,130+',label:'분석 회차'},
{icon:'🎯',val:`${combos.length} / ${MAX_COMBOS}`,label:'생성 조합'},
].map(s=>(
<div key={s.label} style={{ textAlign:'center' }}>
<div style={{ fontSize:'1.05rem',marginBottom:'.2rem' }}>{s.icon}</div>
<div style={{ color:'#fbbf24',fontSize:'1.05rem',fontWeight:800 }}>{s.val}</div>
<div style={{ color:'rgba(253,230,138,.38)',fontSize:'.68rem',marginTop:'.1rem' }}>{s.label}</div>
</div>
))}
</div>
{/* 모드 탭 */}
<div style={{ display:'inline-flex',background:'rgba(0,0,0,.3)',borderRadius:'.75rem',padding:'.25rem',marginBottom:'1.75rem',gap:'.25rem' }}>
{(['single','batch'] as const).map(mode=>(
<button key={mode} className="mode-tab" onClick={()=>setGenMode(mode)} disabled={isProLoading}
style={{ background:genMode===mode?'linear-gradient(135deg,#fbbf24,#f59e0b)':'transparent',color:genMode===mode?'#78350f':'rgba(253,230,138,.45)',border:'none',borderRadius:'.5rem',padding:'.45rem 1.1rem',fontSize:'.78rem',fontWeight:genMode===mode?800:600,cursor:'pointer' }}>
{mode==='single'?'단일 생성':'5개 배치'}
</button>
))}
</div>
{/* 볼 표시 */}
<div style={{ minHeight:100,display:'flex',alignItems:'center',justifyContent:'center',marginBottom:'1.75rem',flexWrap:'wrap',gap:'.75rem' }}>
{isProLoading ? (
SPIN_NUMS.map((n,i)=><SpinBall key={i} n={n} delay={i*100} />)
) : latestCombo ? (
latestCombo.numbers.map((n,i)=><LottoBall key={i} n={n} size={68} bounce delay={i*110} highlight={latestCombo.overlap?.includes(n)} />)
) : (
Array.from({length:6},(_,i)=>(
<div key={i} style={{ width:68,height:68,borderRadius:'50%',border:'2px dashed rgba(251,191,36,.18)',display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(251,191,36,.14)',fontSize:'1.4rem',fontWeight:900,animation:`float ${2+i*.28}s ease-in-out infinite`,animationDelay:`${i*.18}s` }}>?</div>
))
)}
</div>
{/* 메트릭 */}
{latestCombo?.metrics && !isProLoading && (
<div style={{ display:'flex',gap:'1rem',justifyContent:'center',marginBottom:'1.25rem',flexWrap:'wrap' }}>
{[{l:'합계',v:latestCombo.metrics.sum},{l:'홀수',v:`${latestCombo.metrics.odd}`},{l:'짝수',v:`${latestCombo.metrics.even}`},{l:'범위',v:latestCombo.metrics.range}].map(s=>(
<div key={s.l} style={{ background:'rgba(251,191,36,.07)',border:'1px solid rgba(251,191,36,.12)',borderRadius:'.5rem',padding:'.3rem .75rem',textAlign:'center' }}>
<div style={{ color:'#fbbf24',fontSize:'.85rem',fontWeight:800 }}>{s.v}</div>
<div style={{ color:'rgba(253,230,138,.4)',fontSize:'.62rem' }}>{s.l}</div>
</div>
))}
</div>
)}
{isProLoading && (
<p style={{ color:'rgba(251,191,36,.55)',fontSize:'.82rem',marginBottom:'1.25rem',animation:'slideUp .3s ease' }}>
{genMode==='batch'?'5개 번호 조합을 배치 생성 중...':'몬테카를로 시뮬레이션으로 최적 번호를 계산 중...'}
</p>
)}
{proState === 'error' && (
<p style={{ color:'#f87171',fontSize:'.82rem',marginBottom:'1rem',background:'rgba(239,68,68,.08)',border:'1px solid rgba(239,68,68,.2)',borderRadius:'.75rem',padding:'.75rem 1rem' }}> {proError}</p>
)}
{/* 생성 버튼 */}
<button className="gen-btn" onClick={handleGenerate} disabled={isProLoading||isMaxed}
style={{ background:isProLoading||isMaxed?'rgba(251,191,36,.08)':'linear-gradient(135deg,#fbbf24,#f59e0b,#d97706)',color:isProLoading||isMaxed?'rgba(251,191,36,.28)':'#78350f',border:'none',borderRadius:'1rem',padding:'1.05rem 2.75rem',fontSize:'1rem',fontWeight:900,cursor:isProLoading||isMaxed?'not-allowed':'pointer',transition:'all .2s cubic-bezier(.34,1.56,.64,1)',boxShadow:isProLoading||isMaxed?'none':'0 4px 28px rgba(251,191,36,.32)',letterSpacing:'.02em',display:'inline-flex',alignItems:'center',gap:'.55rem' }}>
{isProLoading ? (
<><div style={{ width:16,height:16,borderRadius:'50%',border:'2px solid rgba(251,191,36,.3)',borderTop:'2px solid rgba(251,191,36,.6)',animation:'spin .7s linear infinite' }} /> ...</>
) : isMaxed ? '✓ 최대 조합 생성 완료' : (
<><svg width={17} height={17} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>{genMode==='batch'?'5개 배치 생성하기':'번호 생성하기'}</>
)}
</button>
{isMaxed && (
<div style={{ marginTop:'1rem' }}>
<button onClick={clearCombos} style={{ background:'transparent',border:'1px solid rgba(251,191,36,.18)',color:'rgba(251,191,36,.45)',borderRadius:'.75rem',padding:'.5rem 1.25rem',fontSize:'.78rem',cursor:'pointer' }}> </button>
</div>
)}
</div>
</div>
{/* 생성된 조합 목록 */}
{combos.length > 0 && (
<div style={{ marginBottom:'1.25rem' }}>
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:'.875rem' }}>
<h2 style={{ color:'rgba(255,255,255,.7)',fontSize:'.78rem',fontWeight:700,margin:0,letterSpacing:'.12em',textTransform:'uppercase' }}> </h2>
{combos.length>1&&<button onClick={clearCombos} style={{ background:'transparent',border:'none',color:'rgba(251,191,36,.35)',fontSize:'.72rem',cursor:'pointer',padding:'.25rem .5rem' }}> </button>}
</div>
<div style={{ display:'flex',flexDirection:'column',gap:'.65rem' }}>
{combos.map((c,idx)=>{
const isLatest=idx===combos.length-1;
return (
<div key={c.id} className="combo-card" style={{ background:isLatest?'linear-gradient(145deg,rgba(251,191,36,.07),rgba(249,115,22,.04))':'linear-gradient(145deg,rgba(255,255,255,.03),rgba(255,255,255,.01))',border:isLatest?'1px solid rgba(251,191,36,.28)':'1px solid rgba(255,255,255,.055)',borderRadius:'1.25rem',padding:'1.1rem 1.5rem',display:'flex',alignItems:'center',justifyContent:'space-between',flexWrap:'wrap',gap:'.75rem',animationDelay:`${idx*40}ms` }}>
<div style={{ display:'flex',alignItems:'center',gap:'.75rem',flexWrap:'wrap' }}>
<div style={{ width:26,height:26,borderRadius:'50%',background:isLatest?'linear-gradient(135deg,#fbbf24,#f59e0b)':'rgba(255,255,255,.05)',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'.72rem',fontWeight:800,color:isLatest?'#78350f':'rgba(255,255,255,.25)',flexShrink:0 }}>{idx+1}</div>
<div style={{ display:'flex',gap:'.45rem',flexWrap:'wrap' }}>
{c.numbers.map((n,ni)=><LottoBall key={ni} n={n} size={36} highlight={c.overlap?.includes(n)} />)}
</div>
</div>
<div style={{ display:'flex',alignItems:'center',gap:'.75rem' }}>
{c.metrics&&<span style={{ color:'rgba(251,191,36,.4)',fontSize:'.68rem' }}> {c.metrics.sum} · {c.metrics.odd}</span>}
<div style={{ color:'rgba(255,255,255,.18)',fontSize:'.68rem' }}>{c.createdAt.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit',second:'2-digit'})}</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* 핫/콜드 */}
{(hotNumbers.length>0||coldNumbers.length>0) && (
<div style={{ display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(260px,1fr))',gap:'1rem',marginBottom:'1.25rem' }}>
{hotNumbers.length>0&&(
<div style={{ background:'linear-gradient(145deg,rgba(239,68,68,.07),rgba(239,68,68,.02))',border:'1px solid rgba(239,68,68,.14)',borderRadius:'1.25rem',padding:'1.5rem' }}>
<div style={{ display:'flex',alignItems:'center',gap:'.5rem',marginBottom:'1rem' }}>
<div style={{ width:8,height:8,borderRadius:'50%',background:'#ef4444',boxShadow:'0 0 8px rgba(239,68,68,.7)' }} />
<span style={{ color:'#fca5a5',fontSize:'.72rem',fontWeight:700,letterSpacing:'.1em',textTransform:'uppercase' }}>Hot Numbers · </span>
</div>
<div style={{ display:'flex',gap:'.45rem',flexWrap:'wrap' }}>
{hotNumbers.map(n=><LottoBall key={n} n={n} size={36} />)}
</div>
</div>
)}
{coldNumbers.length>0&&(
<div style={{ background:'linear-gradient(145deg,rgba(59,130,246,.07),rgba(59,130,246,.02))',border:'1px solid rgba(59,130,246,.14)',borderRadius:'1.25rem',padding:'1.5rem' }}>
<div style={{ display:'flex',alignItems:'center',gap:'.5rem',marginBottom:'1rem' }}>
<div style={{ width:8,height:8,borderRadius:'50%',background:'#3b82f6',boxShadow:'0 0 8px rgba(59,130,246,.7)' }} />
<span style={{ color:'#93c5fd',fontSize:'.72rem',fontWeight:700,letterSpacing:'.1em',textTransform:'uppercase' }}>Cold Numbers · </span>
</div>
<div style={{ display:'flex',gap:'.45rem',flexWrap:'wrap' }}>
{coldNumbers.map(n=><LottoBall key={n} n={n} size={36} />)}
</div>
</div>
)}
</div>
)}
{/* 시뮬레이션 정보 */}
{latestRun&&(
<div style={{ background:'rgba(255,255,255,.02)',border:'1px solid rgba(255,255,255,.05)',borderRadius:'1rem',padding:'1rem 1.5rem',display:'flex',gap:'1.5rem',flexWrap:'wrap',alignItems:'center' }}>
<div style={{ display:'flex',alignItems:'center',gap:'.5rem' }}>
<div style={{ width:8,height:8,borderRadius:'50%',background:'#4ade80',boxShadow:'0 0 6px rgba(74,222,128,.6)',animation:'glowPulse 2s ease-in-out infinite' }} />
<span style={{ color:'rgba(255,255,255,.3)',fontSize:'.7rem',fontWeight:600,letterSpacing:'.08em',textTransform:'uppercase' }}> ({latestRun.strategy})</span>
</div>
{[{l:'생성 조합',v:`${latestRun.total_generated.toLocaleString()}`},{l:'평균 점수',v:latestRun.avg_score.toFixed(4)},{l:'실행',v:new Date(latestRun.run_at).toLocaleString('ko-KR',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}].map(s=>(
<div key={s.l}><span style={{ color:'rgba(255,255,255,.2)',fontSize:'.68rem' }}>{s.l}: </span><span style={{ color:'rgba(255,255,255,.55)',fontSize:'.68rem',fontWeight:700 }}>{s.v}</span></div>
))}
</div>
)}
</div>
{/* ── 비구독자 잠금 오버레이 ── */}
{!isSubscribed && (
<div style={{ position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',zIndex:10,padding:'1rem' }}>
<div style={{ textAlign:'center',maxWidth:400,background:'linear-gradient(145deg,rgba(10,5,0,.85),rgba(4,16,43,.8))',backdropFilter:'blur(8px)',border:'1px solid rgba(251,191,36,.25)',borderRadius:'1.5rem',padding:'2.5rem 2rem',boxShadow:'0 24px 60px rgba(0,0,0,.5)' }}>
{/* 자물쇠 아이콘 */}
<div style={{ width:60,height:60,borderRadius:'50%',background:'linear-gradient(135deg,rgba(251,191,36,.2),rgba(249,115,22,.1))',border:'1px solid rgba(251,191,36,.3)',margin:'0 auto 1.25rem',display:'flex',alignItems:'center',justifyContent:'center',animation:'glowPulse 3s ease-in-out infinite' }}>
<svg width={26} height={26} viewBox="0 0 24 24" fill="none" stroke="#fbbf24" strokeWidth={2.2}>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h3 style={{ color:'#fbbf24',fontSize:'1.15rem',fontWeight:900,margin:'0 0 .5rem',letterSpacing:'-.01em' }}> </h3>
<p style={{ color:'rgba(253,230,138,.55)',fontSize:'.82rem',lineHeight:1.6,margin:'0 0 1.5rem' }}>
<strong style={{ color:'#fbbf24' }}></strong> 1 · <strong style={{ color:'#fbbf24' }}></strong> 3 · <strong style={{ color:'#fbbf24' }}></strong> <br />/ · ·
</p>
<div style={{ display:'flex',gap:'.75rem',justifyContent:'center',flexWrap:'wrap' }}>
<Link href="/services/lotto" style={{ display:'inline-flex',alignItems:'center',gap:'.4rem',background:'linear-gradient(135deg,#fbbf24,#f59e0b)',color:'#78350f',padding:'.8rem 1.75rem',borderRadius:'.875rem',fontWeight:800,fontSize:'.88rem',textDecoration:'none',boxShadow:'0 4px 20px rgba(251,191,36,.3)' }}>
</Link>
<Link href="/login" style={{ display:'inline-flex',alignItems:'center',background:'rgba(255,255,255,.05)',border:'1px solid rgba(255,255,255,.1)',color:'rgba(255,255,255,.5)',padding:'.8rem 1.25rem',borderRadius:'.875rem',fontWeight:600,fontSize:'.88rem',textDecoration:'none' }}>
</Link>
</div>
</div>
</div>
)}
</div>
{/* ── Color Legend ── */}
<div style={{ background:'rgba(255,255,255,.02)',border:'1px solid rgba(255,255,255,.05)',borderRadius:'1rem',padding:'.875rem 1.5rem',display:'flex',gap:'1.25rem',flexWrap:'wrap',alignItems:'center',marginTop:'1.5rem' }}>
<span style={{ color:'rgba(255,255,255,.28)',fontSize:'.68rem',fontWeight:600,letterSpacing:'.08em' }}> </span>
{[{r:'110',c:'#fbbf24'},{r:'1120',c:'#3b82f6'},{r:'2130',c:'#ef4444'},{r:'3140',c:'#9ca3af'},{r:'4145',c:'#22c55e'}].map(item=>(
<div key={item.r} style={{ display:'flex',alignItems:'center',gap:'.35rem' }}>
<div style={{ width:10,height:10,borderRadius:'50%',background:item.c,boxShadow:`0 0 6px ${item.c}80` }} />
<span style={{ color:'rgba(255,255,255,.3)',fontSize:'.68rem' }}>{item.r}</span>
</div>
))}
</div>
<p style={{ textAlign:'center',color:'rgba(255,255,255,.13)',fontSize:'.68rem',marginTop:'1.75rem',lineHeight:1.7 }}>
, .
</p>
</div>
</div>
</>
);
}