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>
</>
);
}

33
lib/admin-auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createHmac } from 'crypto';
const TOKEN_TTL = 24 * 60 * 60 * 1000; // 24시간
export function createAdminToken(): string {
const secret = process.env.ADMIN_JWT_SECRET!;
const payload = JSON.stringify({ iat: Date.now(), exp: Date.now() + TOKEN_TTL });
const encoded = Buffer.from(payload).toString('base64url');
const sig = createHmac('sha256', secret).update(encoded).digest('base64url');
return `${encoded}.${sig}`;
}
export function verifyAdminTokenNode(token: string): boolean {
try {
const secret = process.env.ADMIN_JWT_SECRET;
if (!secret) return false;
const [encoded, sig] = token.split('.');
if (!encoded || !sig) return false;
const expected = createHmac('sha256', secret).update(encoded).digest('base64url');
if (sig !== expected) return false;
const { exp } = JSON.parse(Buffer.from(encoded, 'base64url').toString());
return Date.now() < exp;
} catch {
return false;
}
}
export function checkAdminCredentials(id: string, password: string): boolean {
const adminId = process.env.ADMIN_ID;
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminId || !adminPassword) return false;
return id === adminId && password === adminPassword;
}

View File

@@ -7,26 +7,26 @@ export interface Product {
}
export const PRODUCTS: Record<string, Product> = {
lotto_basic: {
id: 'lotto_basic',
name: '로또 기본 플랜',
price: 4900,
lotto_gold: {
id: 'lotto_gold',
name: '로또 골드 플랜',
price: 900,
type: 'monthly',
description: '매주 5개 번호 조합 이메일 제공',
description: '매주 1회 번호 추천 · 이메일 발송',
},
lotto_premium: {
id: 'lotto_premium',
name: '로또 프리미엄 플랜',
lotto_platinum: {
id: 'lotto_platinum',
name: '로또 플래티넘 플랜',
price: 2900,
type: 'monthly',
description: '매주 3회 번호 + 텔레그램 알림 + 상세 분석',
},
lotto_diamond: {
id: 'lotto_diamond',
name: '로또 다이아 플랜',
price: 9900,
type: 'monthly',
description: '매주 3회 번호 + 텔레그램 알림',
},
lotto_annual: {
id: 'lotto_annual',
name: '로또 연간 플랜',
price: 89900,
type: 'annual',
description: '프리미엄 12개월 (2개월 무료)',
description: '횟수 무제한 + 연간 패턴 리포트 + 전체 기능',
},
stock_starter_install: {
id: 'stock_starter_install',

16
lib/supabase/admin.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
// 서비스 롤 키 사용 (RLS 우회, 서버 전용)
export function createAdminClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!serviceKey) {
// 서비스 롤 키 없으면 anon 키로 폴백 (RLS 제한 있음)
return createSupabaseClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
}
return createSupabaseClient(url, serviceKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
}

96
lib/telegram.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Telegram Bot API 유틸리티
* 환경변수: TELEGRAM_BOT_TOKEN
*/
const BASE = () => {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) throw new Error('TELEGRAM_BOT_TOKEN이 설정되지 않았습니다.');
return `https://api.telegram.org/bot${token}`;
};
// ─── 메시지 전송 ──────────────────────────────────────────────────────────────
export async function sendMessage(
chatId: string | number,
text: string,
options: { parse_mode?: 'Markdown' | 'HTML'; disable_web_page_preview?: boolean } = {}
): Promise<{ ok: boolean; description?: string }> {
const res = await fetch(`${BASE()}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: options.parse_mode ?? 'Markdown',
disable_web_page_preview: options.disable_web_page_preview ?? true,
}),
});
return res.json();
}
// ─── 로또 번호 알림 메시지 포맷 ──────────────────────────────────────────────
export function formatLottoMessage(
numbers: number[],
drawDate: string,
planName: string,
round?: number
): string {
const balls = numbers.map((n) => `*${String(n).padStart(2, '0')}*`).join(' ');
const roundText = round ? ` (제${round}회 예상)` : '';
return [
`🎰 *쟁승메이드 로또 번호 추천*${roundText}`,
`📅 ${drawDate} | ${planName}`,
``,
`${balls}`,
``,
`📊 합계: ${numbers.reduce((a, b) => a + b, 0)} | 홀수: ${numbers.filter((n) => n % 2 !== 0).length}`,
``,
`⚠️ 통계 기반 추천이며 당첨을 보장하지 않습니다.`,
`🔗 [번호 추천 받기](https://jaengseung.com/services/lotto/recommend)`,
].join('\n');
}
// ─── 웹훅 등록 ───────────────────────────────────────────────────────────────
export async function setWebhook(
webhookUrl: string,
secretToken?: string
): Promise<{ ok: boolean; description?: string }> {
const body: Record<string, unknown> = {
url: webhookUrl,
allowed_updates: ['message'],
};
if (secretToken) body.secret_token = secretToken;
const res = await fetch(`${BASE()}/setWebhook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json();
}
export async function getWebhookInfo(): Promise<{ ok: boolean; result?: { url: string; pending_update_count: number } }> {
const res = await fetch(`${BASE()}/getWebhookInfo`);
return res.json();
}
// ─── Telegram Update 타입 ─────────────────────────────────────────────────────
export interface TelegramUpdate {
update_id: number;
message?: {
message_id: number;
from?: {
id: number;
username?: string;
first_name?: string;
};
chat: { id: number; type: string };
text?: string;
date: number;
};
}

View File

@@ -1,7 +1,49 @@
import { type NextRequest } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';
import { updateSession } from '@/utils/supabase/middleware';
// Edge Runtime에서 Web Crypto API로 관리자 토큰 검증
async function verifyAdminToken(token: string): Promise<boolean> {
try {
const secret = process.env.ADMIN_JWT_SECRET;
if (!secret) return false;
const parts = token.split('.');
if (parts.length !== 2) return false;
const [encoded, sig] = parts;
const keyData = new TextEncoder().encode(secret);
const key = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']
);
const sigBuffer = Uint8Array.from(
atob(sig.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const dataBuffer = new TextEncoder().encode(encoded);
const valid = await crypto.subtle.verify('HMAC', key, sigBuffer, dataBuffer);
if (!valid) return false;
const paddedEncoded = encoded.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(paddedEncoded + '='.repeat((4 - paddedEncoded.length % 4) % 4)));
return Date.now() < payload.exp;
} catch {
return false;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// /admin 경로 보호 (/admin/login 제외)
if (pathname.startsWith('/admin') && !pathname.startsWith('/admin/login')) {
const token = request.cookies.get('admin_token')?.value;
if (!token || !(await verifyAdminToken(token))) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
}
return await updateSession(request);
}

View File

@@ -0,0 +1,25 @@
-- ============================================================
-- Migration 002: 텔레그램 연동 + 신규 로또 상품 추가
-- Supabase SQL Editor에서 실행하세요
-- ============================================================
-- ① profiles에 텔레그램 필드 추가
alter table public.profiles
add column if not exists telegram_chat_id text,
add column if not exists telegram_connect_token text,
add column if not exists telegram_token_expires timestamptz;
-- ② 신규 로또 상품 추가 (이미 있으면 가격/설명 업데이트)
insert into public.products (id, name, description, price, category) values
('lotto_gold', '로또 골드 플랜', '매주 1회 번호 추천 · 이메일 발송', 900, 'lotto'),
('lotto_platinum', '로또 플래티넘 플랜', '매주 3회 번호 + 상세 분석 + 텔레그램 알림', 2900, 'lotto'),
('lotto_diamond', '로또 다이아 플랜', '횟수 무제한 + 전체 기능 + 연간 패턴 리포트', 9900, 'lotto')
on conflict (id) do update set
name = excluded.name,
description = excluded.description,
price = excluded.price;
-- 구버전 상품 비활성화 (데이터 보존, 신규 결제만 막음)
update public.products
set is_active = false
where id in ('lotto_basic', 'lotto_premium', 'lotto_annual');

View File

@@ -66,8 +66,10 @@ create table public.products (
-- 초기 상품 데이터
insert into public.products (id, name, description, price, category) values
('saju_detail', 'AI 사주 상세 리포트', '신강/신약, 용신, 대운, AI 12가지 항목 해석', 4900, 'saju'),
('lotto_premium', '로또 프리미엄 구독', '매주 프리미엄 번호 5조합 + 통계', 4900, 'lotto');
('saju_detail', 'AI 사주 상세 리포트', '신강/신약, 용신, 대운, AI 12가지 항목 해석', 4900, 'saju'),
('lotto_gold', '로또 골드 플랜', '매주 1회 번호 추천 · 이메일 발송', 900, 'lotto'),
('lotto_platinum', '로또 플래티넘 플랜', '매주 3회 번호 + 상세 분석 + 텔레그램 알림', 2900, 'lotto'),
('lotto_diamond', '로또 다이아 플랜', '횟수 무제한 + 전체 기능 + 연간 패턴 리포트', 9900, 'lotto');
-- ④ orders (주문 - 결제 전 생성)
@@ -119,3 +121,28 @@ create table public.contact_requests (
alter table public.contact_requests enable row level security;
create policy "본인 의뢰 내역 조회" on public.contact_requests for select using (auth.uid() = user_id);
create policy "누구나 의뢰 생성" on public.contact_requests for insert with check (true);
-- ⑦ service_settings (서비스 노출 on/off 관리자 설정)
create table public.service_settings (
id text primary key, -- 서비스 ID: 'saju', 'lotto', 'stock', ...
name text not null,
description text,
is_active boolean default true,
order_index integer default 0,
updated_at timestamptz default now()
);
-- 초기 서비스 데이터
insert into public.service_settings (id, name, description, is_active, order_index) values
('saju', 'AI 사주 분석', '사주 입력 및 AI 해석 서비스', true, 1),
('lotto', '로또 번호 추천', '빅데이터 기반 로또 번호 분석', true, 2),
('stock', '주식 자동매매', '텔레그램 연동 자동매매 프로그램', true, 3),
('automation', '업무 자동화 RPA', '반복 업무 자동화 개발', true, 4),
('prompt', '프롬프트 엔지니어링', 'AI 프롬프트 설계 서비스', true, 5),
('freelance', '외주 개발', '맞춤형 소프트웨어 개발', true, 6);
-- service_settings는 누구나 읽기 가능 (공개 서비스 목록 조회용)
alter table public.service_settings enable row level security;
create policy "누구나 서비스 설정 조회" on public.service_settings for select using (true);
-- 쓰기는 service_role(관리자)만 가능 (별도 정책 없음 = anon 불가)