diff --git a/app/admin/components/AdminSidebar.tsx b/app/admin/components/AdminSidebar.tsx new file mode 100644 index 0000000..1d97496 --- /dev/null +++ b/app/admin/components/AdminSidebar.tsx @@ -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: ( + + + + ), + }, + { + href: '/admin/members', + label: '회원 관리', + icon: ( + + + + ), + }, + { + href: '/admin/services', + label: '서비스 설정', + icon: ( + + + + + ), + }, + { + href: '/admin/contacts', + label: '문의 내역', + icon: ( + + + + ), + }, +]; + +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 ( + + ); +} diff --git a/app/admin/contacts/page.tsx b/app/admin/contacts/page.tsx new file mode 100644 index 0000000..e06bf0e --- /dev/null +++ b/app/admin/contacts/page.tsx @@ -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 = { + 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 = { + lotto: '로또 추천', + stock: '주식 자동매매', + automation: '업무 자동화', + prompt: '프롬프트 엔지니어링', + freelance: '외주 개발', + saju: 'AI 사주', + general: '일반 문의', +}; + +export default function AdminContactsPage() { + const [contacts, setContacts] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [updating, setUpdating] = useState(null); + const [filterStatus, setFilterStatus] = useState('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 ( +
+
+
+

문의 내역

+

고객 문의 및 외주 의뢰 관리

+
+ {pendingCount > 0 && ( + + 미처리 {pendingCount}건 + + )} +
+ + {/* 필터 탭 */} +
+ {[['all', '전체'], ['pending', '미처리'], ['in_progress', '처리중'], ['completed', '완료']].map(([val, label]) => ( + + ))} +
+ + {loading ? ( +
+
+
+ ) : ( +
+ {/* 목록 */} +
+ {filtered.length === 0 ? ( +
+ 문의 내역이 없습니다 +
+ ) : ( + filtered.map((contact) => ( + + )) + )} +
+ + {/* 상세 패널 */} + {selected && ( +
+
+

문의 상세

+ +
+ +
+
+
이름
+
{selected.name ?? '-'}
+
+
+
이메일
+
{selected.email}
+
+
+
서비스
+
{SERVICE_LABELS[selected.service] ?? selected.service}
+
+
+
접수일
+
{new Date(selected.created_at).toLocaleString('ko-KR')}
+
+
+
내용
+
+ {selected.message} +
+
+
+ + {/* 상태 변경 */} +
+

상태 변경

+
+ {(['pending', 'in_progress', 'completed'] as const).map((s) => ( + + ))} +
+
+ + {/* 이메일 바로 보내기 링크 */} + + + + + 이메일 답장하기 + +
+ )} +
+ )} +
+ ); +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..d86bc75 --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -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 ( +
+
+ {label} +
+ {icon} +
+
+

{value}

+
+ ); +} + +export default function AdminDashboard() { + const [stats, setStats] = useState(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 ( +
+ {/* 헤더 */} +
+

대시보드

+

쟁승메이드 운영 현황

+
+ + {loading ? ( +
+
+
+ ) : ( + <> + {/* 통계 카드 */} +
+ + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> +
+ + {/* 월별 수익 차트 */} +
+

월별 수익 현황 (최근 6개월)

+
+ {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 ( +
+ + {item.revenue > 0 ? `₩${(item.revenue / 1000).toFixed(0)}K` : ''} + +
+
0 ? '4px' : '0' }} + /> +
+ {monthLabel}월 +
+ ); + })} +
+ {(stats?.totalRevenue ?? 0) === 0 && ( +

결제 데이터가 없습니다

+ )} +
+ + )} +
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..14ab2d4 --- /dev/null +++ b/app/admin/layout.tsx @@ -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 ( +
+ +
+ {children} +
+
+ ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..1545aab --- /dev/null +++ b/app/admin/login/page.tsx @@ -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 ( +
+
+ {/* 로고 */} +
+
+ 관 +
+

관리자 로그인

+

쟁승메이드 관리자 전용

+
+ + {/* 폼 */} +
+
+ + 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 입력" + /> +
+
+ + 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="비밀번호 입력" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ .env.local의 ADMIN_ID / ADMIN_PASSWORD로 로그인 +

+
+
+ ); +} diff --git a/app/admin/members/page.tsx b/app/admin/members/page.tsx new file mode 100644 index 0000000..e632e29 --- /dev/null +++ b/app/admin/members/page.tsx @@ -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([]); + 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 ( +
+
+
+

회원 관리

+

가입 회원 목록 및 결제 현황

+
+ + 총 {members.length}명 + +
+ + {/* 검색 */} +
+ 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" + /> +
+ + {loading ? ( +
+
+
+ ) : ( +
+ + + + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((m) => ( + + + + + + + + )) + )} + +
이메일이름가입일결제 건수총 결제액
+ 회원 데이터가 없습니다 +
{m.email ?? '-'}{m.full_name ?? '-'} + {new Date(m.created_at).toLocaleDateString('ko-KR')} + + 0 ? 'bg-green-900/40 text-green-400' : 'bg-slate-700 text-slate-500'}`}> + {m.orderCount}건 + + + {m.totalPaid > 0 ? `₩${m.totalPaid.toLocaleString()}` : '-'} +
+
+ )} +
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..97fba64 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function AdminRootPage() { + redirect('/admin/dashboard'); +} diff --git a/app/admin/services/page.tsx b/app/admin/services/page.tsx new file mode 100644 index 0000000..d928198 --- /dev/null +++ b/app/admin/services/page.tsx @@ -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 = { + saju: '🔮', + lotto: '🎰', + stock: '📈', + automation: '🤖', + prompt: '💡', + freelance: '🛠', +}; + +export default function AdminServicesPage() { + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [toggling, setToggling] = useState(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 ( +
+
+

서비스 설정

+

각 서비스의 노출 여부를 관리합니다

+
+ + {loading ? ( +
+
+
+ ) : ( +
+ {services.map((service) => ( +
+
+
+ {SERVICE_ICONS[service.id] ?? '📦'} +
+

{service.name}

+

{service.description}

+
+
+ {/* 토글 스위치 */} + +
+ + {/* 상태 배지 */} +
+ + + {service.is_active ? '활성' : '비활성'} + + {!service.is_active && ( + 사이트에서 숨겨집니다 + )} +
+
+ ))} +
+ )} + +
+

+ 💡 서비스 on/off는 Supabase의 service_settings 테이블에 저장됩니다. + 아직 테이블이 없으면 아래 SQL을 실행하세요. +

+
{`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()
+);`}
+
+
+ ); +} diff --git a/app/api/admin/contacts/route.ts b/app/api/admin/contacts/route.ts new file mode 100644 index 0000000..01a12b0 --- /dev/null +++ b/app/api/admin/contacts/route.ts @@ -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 }); +} diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts new file mode 100644 index 0000000..e520127 --- /dev/null +++ b/app/api/admin/login/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/logout/route.ts b/app/api/admin/logout/route.ts new file mode 100644 index 0000000..c51a10d --- /dev/null +++ b/app/api/admin/logout/route.ts @@ -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; +} diff --git a/app/api/admin/members/route.ts b/app/api/admin/members/route.ts new file mode 100644 index 0000000..863164f --- /dev/null +++ b/app/api/admin/members/route.ts @@ -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 }); +} diff --git a/app/api/admin/services/route.ts b/app/api/admin/services/route.ts new file mode 100644 index 0000000..ad05a16 --- /dev/null +++ b/app/api/admin/services/route.ts @@ -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 }, +]; diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..8a5da4a --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -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 = {}; + 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 }); +} diff --git a/app/api/lotto/dashboard/route.ts b/app/api/lotto/dashboard/route.ts new file mode 100644 index 0000000..c6ce57a --- /dev/null +++ b/app/api/lotto/dashboard/route.ts @@ -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 { + const base = process.env.NAS_LOTTO_API_URL; + if (!base) throw new Error('NAS_URL_NOT_CONFIGURED'); + + const headers: Record = {}; + 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 }); + } +} diff --git a/app/api/lotto/preview/route.ts b/app/api/lotto/preview/route.ts new file mode 100644 index 0000000..cd145a7 --- /dev/null +++ b/app/api/lotto/preview/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/app/api/lotto/recommend/route.ts b/app/api/lotto/recommend/route.ts new file mode 100644 index 0000000..7d92b98 --- /dev/null +++ b/app/api/lotto/recommend/route.ts @@ -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>, 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 { + const base = process.env.NAS_LOTTO_API_URL; + if (!base) throw new Error('NAS_URL_NOT_CONFIGURED'); + + const headers: Record = {}; + 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 }); + } +} diff --git a/app/api/telegram/connect/route.ts b/app/api/telegram/connect/route.ts new file mode 100644 index 0000000..e89abb2 --- /dev/null +++ b/app/api/telegram/connect/route.ts @@ -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 }); +} diff --git a/app/api/telegram/setup/route.ts b/app/api/telegram/setup/route.ts new file mode 100644 index 0000000..549e5a1 --- /dev/null +++ b/app/api/telegram/setup/route.ts @@ -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 }); +} diff --git a/app/api/telegram/webhook/route.ts b/app/api/telegram/webhook/route.ts new file mode 100644 index 0000000..b99b259 --- /dev/null +++ b/app/api/telegram/webhook/route.ts @@ -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 명령으로 유저 텔레그램 계정 연결 + */ +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 + + 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 }); +} diff --git a/app/components/DashboardShell.tsx b/app/components/DashboardShell.tsx index 3aeab22..6cbab83 100644 --- a/app/components/DashboardShell.tsx +++ b/app/components/DashboardShell.tsx @@ -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); diff --git a/app/components/TelegramGuideModal.tsx b/app/components/TelegramGuideModal.tsx new file mode 100644 index 0000000..0be5096 --- /dev/null +++ b/app/components/TelegramGuideModal.tsx @@ -0,0 +1,256 @@ +'use client'; + +/** + * TelegramGuideModal + * 고객에게 텔레그램 연결 방법을 단계별로 시각적으로 설명하는 모달 + * - 이미지로 캡처해서 공유하거나 인앱으로 보여줄 수 있음 + */ +export default function TelegramGuideModal({ onClose }: { onClose: () => void }) { + const steps = [ + { + no: 1, + title: '마이페이지 접속', + desc: '로그인 후 우측 상단 프로필 메뉴 → 마이페이지로 이동합니다.', + icon: ( + + + + ), + color: 'bg-blue-50 border-blue-200 text-blue-600', + dot: 'bg-blue-500', + mockup: ( +
+
+
+ 내 계정 + ▶ 마이페이지 +
+
내 정보 · 결제 내역 · 텔레그램 연동
+
+ ), + }, + { + no: 2, + title: '\'내 정보\' 탭 → 텔레그램 연결하기 클릭', + desc: '\'내 정보\' 탭의 텔레그램 알림 연동 섹션에서 버튼을 클릭합니다.', + icon: ( + + + + ), + color: 'bg-sky-50 border-sky-200 text-sky-600', + dot: 'bg-sky-500', + mockup: ( +
+
+ + 텔레그램 알림 연동 + 플래티넘 · 다이아 +
+
+
+
+ + + +
+
+
연결 안 됨
+
텔레그램으로 번호를 받아보세요
+
+
+
+ 텔레그램 연결하기 +
+
+
+ ), + }, + { + no: 3, + title: '\'텔레그램 봇 열기\' 버튼 클릭', + desc: '연결 코드가 생성되면 파란색 버튼을 클릭합니다. 자동으로 텔레그램 앱이 열립니다.', + icon: ( + + + + ), + color: 'bg-indigo-50 border-indigo-200 text-indigo-600', + dot: 'bg-indigo-500', + mockup: ( +
+
+

📱 아래 순서로 진행하세요

+
    +
  1. 아래 버튼으로 텔레그램 봇을 엽니다
  2. +
  3. 텔레그램에서 시작 버튼을 누릅니다
  4. +
  5. 봇 메시지 확인 후 새로고침
  6. +
+
+
+
+ + + + 텔레그램 봇 열기 ↗ +
+
+
+ ), + }, + { + no: 4, + title: '텔레그램에서 \'시작\' 버튼 클릭', + desc: '텔레그램 앱이 열리면 채팅창 하단의 파란 \'시작\' 버튼을 클릭합니다. 자동으로 연결이 완료됩니다.', + icon: ( + + + + ), + color: 'bg-sky-50 border-sky-200 text-sky-500', + dot: 'bg-sky-400', + mockup: ( +
+ {/* 텔레그램 UI 모킹 */} +
+
+ + + +
+
+
쟁승메이드 로또봇
+
bot
+
+
+
+ 안녕하세요! 로또 번호 알림 봇입니다. 아래 시작 버튼을 눌러 계정을 연결하세요. +
+
+
+ 시작 +
+
+
+ ), + }, + { + no: 5, + title: '연결 완료!', + desc: '봇이 "연결 완료" 메시지를 보냅니다. 마이페이지로 돌아와 \'연결 확인 새로고침\' 버튼을 누르면 완료됩니다.', + icon: ( + + + + ), + color: 'bg-emerald-50 border-emerald-200 text-emerald-600', + dot: 'bg-emerald-500', + mockup: ( +
+
+
+ 🎉 텔레그램 연결 완료!
+ 이제 매주 로또 번호를 이 채팅으로 받아보실 수 있습니다. 🎰 +
+
+
+
+
+ + + +
+
+
+ 연결됨 + +
+
매주 로또 번호 알림 수신 중
+
+
+
+
+ ), + }, + ]; + + return ( +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+
+

+ + + + 텔레그램 연결 방법 +

+

5단계로 쉽게 연결할 수 있습니다

+
+ +
+ + {/* 스텝 목록 */} +
+ {steps.map((step, idx) => ( +
+ {/* 연결선 */} + {idx < steps.length - 1 && ( +
+ )} + +
+ {/* 스텝 번호 + 아이콘 */} +
+ {step.icon} +
+ +
+
+ + {step.no} + +

{step.title}

+
+

{step.desc}

+ {/* 화면 목업 */} +
+ {step.mockup} +
+
+
+
+ ))} + + {/* 안내 메시지 */} +
+

+ + + + 알아두세요 +

+
    +
  • • 연결 코드는 발급 후 15분 이내에 사용해야 합니다
  • +
  • • 텔레그램 앱이 설치되어 있어야 합니다 (iOS / Android / PC 모두 가능)
  • +
  • • 연결은 언제든 마이페이지에서 해제할 수 있습니다
  • +
  • • 플래티넘 · 다이아 구독자만 텔레그램 알림을 받을 수 있습니다
  • +
+
+
+
+
+ ); +} diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index bdce876..68a0a8d 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -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([]); const [orders, setOrders] = useState([]); + // 텔레그램 연동 상태 + const [telegramChatId, setTelegramChatId] = useState(null); + const [telegramLinkState, setTelegramLinkState] = useState('idle'); + const [telegramDeepLink, setTelegramDeepLink] = useState(''); + const [telegramLinkExpiry, setTelegramLinkExpiry] = useState(''); + 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 (
@@ -122,6 +184,11 @@ export default function MyPage() { return (
+ {/* 텔레그램 가이드 모달 */} + {showTelegramGuide && ( + setShowTelegramGuide(false)} /> + )} + {/* 헤더 */}
@@ -202,6 +269,109 @@ export default function MyPage() {
+ {/* 텔레그램 연동 카드 */} +
+

+
+ 텔레그램 알림 연동 + + 플래티넘 · 다이아 전용 +

+ + {telegramChatId ? ( + /* ── 연결됨 ── */ +
+
+
+ + + +
+
+
+ 연결됨 + +
+
Chat ID: {telegramChatId}
+
+
+ +
+ ) : telegramLinkState === 'waiting' ? ( + /* ── 연결 대기 중 ── */ +
+
+

📱 아래 순서로 진행하세요

+
    +
  1. 아래 버튼을 클릭해 텔레그램 봇을 엽니다
  2. +
  3. 텔레그램에서 시작 버튼을 누릅니다
  4. +
  5. 봇이 "연결 완료" 메시지를 보내면 새로고침을 눌러주세요
  6. +
+

⏱ 유효시간: {telegramLinkExpiry}까지

+
+
+ + + + + 텔레그램 봇 열기 + + + +
+
+ ) : ( + /* ── 미연결 ── */ +
+
+
+ + + +
+
+
연결 안 됨
+
텔레그램으로 번호를 바로 받아보세요
+
+
+ +
+ )} +
+

@@ -219,6 +389,17 @@ export default function MyPage() {
새 사주 보기
+ +
+ + + +
+
+
로또 번호 추천
+
구독자 전용
+
+
diff --git a/app/services/lotto/page.tsx b/app/services/lotto/page.tsx index 8ee30a0..b2c3273 100644 --- a/app/services/lotto/page.tsx +++ b/app/services/lotto/page.tsx @@ -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() {

+ {/* ─── 구독자 전용 번호 추천 CTA ─── */} +
+
+
+ {/* glow */} +
+
+
+
+
+ + + +
+
+
+ 구독자 전용 + +
+

+ 지금 바로 번호를 추천받으세요 +

+

+ 몬테카를로 시뮬레이션으로 최대 5조합 즉시 생성 +

+
+
+ + 번호 추천받기 → + +
+
+
+ {/* ─── 분석 기능 ─── */}
@@ -198,7 +238,10 @@ export default function LottoPage() { {plan.highlight && (
추천
)} -
{plan.name.toUpperCase()}
+
+ {plan.badge} + {plan.name.toUpperCase()} +
{plan.price} {plan.period} diff --git a/app/services/lotto/recommend/page.tsx b/app/services/lotto/recommend/page.tsx new file mode 100644 index 0000000..88d13a7 --- /dev/null +++ b/app/services/lotto/recommend/page.tsx @@ -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 = { + lotto_gold: '🥇 골드', + lotto_platinum: '💎 플래티넘', + lotto_diamond: '👑 다이아', +}; + +// 다이아 플랜은 무제한 (사실상 999) +const PLAN_MAX_COMBOS: Record = { + 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 ( +
+
+ {n} +
+ ); +} + +function SpinBall({ n, delay = 0 }: { n: number; delay?: number }) { + const { bg, shadow, text } = getBallStyle(n); + return ( +
+ {n} +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function LottoRecommendPage() { + const supabase = createClient(); + + // 구독 상태 + const [isSubscribed, setIsSubscribed] = useState(false); + const [plan, setPlan] = useState(''); + const [dashboard, setDashboard] = useState(null); + const [pageReady, setPageReady] = useState(false); + + // 무료 맛보기 + const [previewNumbers, setPreviewNumbers] = useState([]); + const [previewMetrics, setPreviewMetrics] = useState(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('single'); + const [combos, setCombos] = useState([]); + 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 ( +
+
+
+ ); + } + + return ( + <> + + +
+ {/* ambient orbs */} +
+
+ +
+ + {/* ── Header ── */} +
+ + ← 로또 서비스로 + +
+
+
+ Monte Carlo Simulation · 로또 번호 추천 +
+

+ 이번 주 로또
+ 번호 추천 +

+
+ {isSubscribed && plan && ( +
+
+ {PLAN_LABELS[plan] ?? plan} 구독 중 +
+ )} +
+
+ + {/* ── 최신 당첨번호 (구독자에게만) ── */} + {isSubscribed && dashboard?.latest && ( +
+
+
최신 당첨번호
+
제{dashboard.latest.drawNo}회 · {dashboard.latest.date}
+
+
+ {dashboard.latest.numbers.map((n,i) => )} + + +
+ +
+
+
+ {[{l:'합계',v:dashboard.latest.metrics.sum},{l:'홀수',v:`${dashboard.latest.metrics.odd}개`},{l:'짝수',v:`${dashboard.latest.metrics.even}개`}].map(s=>( +
+
{s.v}
+
{s.l}
+
+ ))} +
+
+ )} + + {/* ════════════════════════════════════════════════ + 무료 맛보기 섹션 (모든 사용자) + ════════════════════════════════════════════════ */} +
+ {/* 섹션 라벨 */} +
+
+
+ 무료 맛보기 +
+ 1회 무료 번호 추천 +
+ +
+
+ +
+ {/* 번호 표시 영역 */} +
+ {previewState === 'loading' ? ( + SPIN_NUMS.slice(0,6).map((n,i) => ) + ) : previewState === 'result' && previewNumbers.length > 0 ? ( + previewNumbers.map((n,i) => ) + ) : ( + Array.from({length:6},(_,i)=>( +
?
+ )) + )} +
+ + {/* 맛보기 메트릭 */} + {previewState === 'result' && previewMetrics && ( +
+
+ {[{l:'합계',v:previewMetrics.sum},{l:'홀수',v:`${previewMetrics.odd}개`},{l:'짝수',v:`${previewMetrics.even}개`},{l:'범위',v:previewMetrics.range}].map(s=>( +
+
{s.v}
+
{s.l}
+
+ ))} +
+ {/* 출처 표시 */} +
+
+ + {previewSource==='nas' ? 'NAS Monte Carlo 시뮬레이션' : '브라우저 간이 시뮬레이션 (5,000회)'} + +
+
+ )} + + {previewState === 'error' && ( +

+ ⚠️ 번호 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요. +

+ )} + + {/* 버튼 */} + {!previewUsed ? ( + + ) : ( +
+
+ ✓ 오늘의 무료 번호 생성 완료 +
+ {!isSubscribed && ( +

+ 더 많은 번호와 분석이 필요하다면 아래 구독 플랜을 이용해보세요 ↓ +

+ )} +
+ )} +
+
+
+ + {/* ════════════════════════════════════════════════ + 프리미엄 구독 섹션 (블러 게이트) + ════════════════════════════════════════════════ */} +
+ {/* 섹션 라벨 */} +
+
+ + + + 구독자 전용 +
+ + {isSubscribed ? '프리미엄 번호 추천' : '구독 시 제공되는 기능 미리보기'} + +
+ + {/* 프리미엄 컨텐츠 (블러 or 실제) */} +
+ + {/* 생성 카드 */} +
+
+
+ {/* 스탯 */} +
+ {[ + {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=>( +
+
{s.icon}
+
{s.val}
+
{s.label}
+
+ ))} +
+ + {/* 모드 탭 */} +
+ {(['single','batch'] as const).map(mode=>( + + ))} +
+ + {/* 볼 표시 */} +
+ {isProLoading ? ( + SPIN_NUMS.map((n,i)=>) + ) : latestCombo ? ( + latestCombo.numbers.map((n,i)=>) + ) : ( + Array.from({length:6},(_,i)=>( +
?
+ )) + )} +
+ + {/* 메트릭 */} + {latestCombo?.metrics && !isProLoading && ( +
+ {[{l:'합계',v:latestCombo.metrics.sum},{l:'홀수',v:`${latestCombo.metrics.odd}개`},{l:'짝수',v:`${latestCombo.metrics.even}개`},{l:'범위',v:latestCombo.metrics.range}].map(s=>( +
+
{s.v}
+
{s.l}
+
+ ))} +
+ )} + + {isProLoading && ( +

+ {genMode==='batch'?'5개 번호 조합을 배치 생성 중...':'몬테카를로 시뮬레이션으로 최적 번호를 계산 중...'} +

+ )} + {proState === 'error' && ( +

⚠️ {proError}

+ )} + + {/* 생성 버튼 */} + + {isMaxed && ( +
+ +
+ )} +
+
+ + {/* 생성된 조합 목록 */} + {combos.length > 0 && ( +
+
+

생성된 번호 조합

+ {combos.length>1&&} +
+
+ {combos.map((c,idx)=>{ + const isLatest=idx===combos.length-1; + return ( +
+
+
{idx+1}
+
+ {c.numbers.map((n,ni)=>)} +
+
+
+ {c.metrics&&합 {c.metrics.sum} · 홀 {c.metrics.odd}} +
{c.createdAt.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit',second:'2-digit'})}
+
+
+ ); + })} +
+
+ )} + + {/* 핫/콜드 */} + {(hotNumbers.length>0||coldNumbers.length>0) && ( +
+ {hotNumbers.length>0&&( +
+
+
+ Hot Numbers · 통계적 과출현 +
+
+ {hotNumbers.map(n=>)} +
+
+ )} + {coldNumbers.length>0&&( +
+
+
+ Cold Numbers · 오래 미출현 +
+
+ {coldNumbers.map(n=>)} +
+
+ )} +
+ )} + + {/* 시뮬레이션 정보 */} + {latestRun&&( +
+
+
+ 최신 시뮬레이션 ({latestRun.strategy}) +
+ {[{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=>( +
{s.l}: {s.v}
+ ))} +
+ )} +
+ + {/* ── 비구독자 잠금 오버레이 ── */} + {!isSubscribed && ( +
+
+ {/* 자물쇠 아이콘 */} +
+ + + + +
+

구독하면 더 많이 받을 수 있어요

+

+ 골드 주 1회 · 플래티넘 주 3회 · 다이아 무제한
핫/콜드 번호 분석 · 시뮬레이션 통계 · 연간 패턴 리포트 +

+
+ + 구독 플랜 보기 → + + + 로그인 + +
+
+
+ )} +
+ + {/* ── Color Legend ── */} +
+ 번호 색상 + {[{r:'1–10',c:'#fbbf24'},{r:'11–20',c:'#3b82f6'},{r:'21–30',c:'#ef4444'},{r:'31–40',c:'#9ca3af'},{r:'41–45',c:'#22c55e'}].map(item=>( +
+
+ {item.r} +
+ ))} +
+ +

+ 본 서비스는 몬테카를로 시뮬레이션 기반 통계 분석으로, 당첨을 보장하지 않습니다. +

+
+
+ + ); +} diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts new file mode 100644 index 0000000..e556a07 --- /dev/null +++ b/lib/admin-auth.ts @@ -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; +} diff --git a/lib/products.ts b/lib/products.ts index 251b3c9..8340108 100644 --- a/lib/products.ts +++ b/lib/products.ts @@ -7,26 +7,26 @@ export interface Product { } export const PRODUCTS: Record = { - 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', diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts new file mode 100644 index 0000000..5783fd5 --- /dev/null +++ b/lib/supabase/admin.ts @@ -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 }, + }); +} diff --git a/lib/telegram.ts b/lib/telegram.ts new file mode 100644 index 0000000..c7b2990 --- /dev/null +++ b/lib/telegram.ts @@ -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 = { + 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; + }; +} diff --git a/middleware.ts b/middleware.ts index 593b130..99f99d6 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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 { + 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); } diff --git a/supabase/migrations/002_telegram_and_products.sql b/supabase/migrations/002_telegram_and_products.sql new file mode 100644 index 0000000..e7cb7a6 --- /dev/null +++ b/supabase/migrations/002_telegram_and_products.sql @@ -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'); diff --git a/supabase/schema.sql b/supabase/schema.sql index a142a46..87704d8 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -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 불가)