From 4cacea69c8a2eaa6258725bbf7649ad2f8d0c699 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 20 Mar 2026 01:12:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A1=9C=EB=98=90=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20Phase=201-2=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NAS 프록시 공통 헬퍼 (_nas.ts): nasGet/Post/Put/Delete + requireSubscription - API 라우트 7개: stats/performance, report/latest, report/history, analysis/personal, purchase CRUD - ReportTab: 주간 공략 리포트 (신뢰도, 추천 세트, 핫/콜드 번호, 히스토리) - PurchaseTab: 구매 기록 CRUD + 투자 통계 (총구매/당첨금/순손익/최대당첨) - PatternTab: 개인 번호 패턴 분석 (자주 선택/기피 번호, 구간 분포) - 성과 배너: 실제 검증 통계 (3개 이상 일치율, 평균 일치 개수, 무작위 대비 개선율) - 탭 네비게이션: 구독자 전용 (번호 생성/이번 주 공략/구매 기록/내 패턴) Co-Authored-By: Claude Sonnet 4.6 --- app/api/lotto/_nas.ts | 86 +++++++ app/api/lotto/analysis/personal/route.ts | 11 + app/api/lotto/purchase/[id]/route.ts | 23 ++ app/api/lotto/purchase/route.ts | 26 ++ app/api/lotto/purchase/stats/route.ts | 11 + app/api/lotto/report/history/route.ts | 13 + app/api/lotto/report/latest/route.ts | 11 + app/api/lotto/stats/performance/route.ts | 13 + app/services/lotto/recommend/PatternTab.tsx | 181 +++++++++++++ app/services/lotto/recommend/PurchaseTab.tsx | 254 +++++++++++++++++++ app/services/lotto/recommend/ReportTab.tsx | 236 +++++++++++++++++ app/services/lotto/recommend/page.tsx | 72 ++++++ 12 files changed, 937 insertions(+) create mode 100644 app/api/lotto/_nas.ts create mode 100644 app/api/lotto/analysis/personal/route.ts create mode 100644 app/api/lotto/purchase/[id]/route.ts create mode 100644 app/api/lotto/purchase/route.ts create mode 100644 app/api/lotto/purchase/stats/route.ts create mode 100644 app/api/lotto/report/history/route.ts create mode 100644 app/api/lotto/report/latest/route.ts create mode 100644 app/api/lotto/stats/performance/route.ts create mode 100644 app/services/lotto/recommend/PatternTab.tsx create mode 100644 app/services/lotto/recommend/PurchaseTab.tsx create mode 100644 app/services/lotto/recommend/ReportTab.tsx diff --git a/app/api/lotto/_nas.ts b/app/api/lotto/_nas.ts new file mode 100644 index 0000000..c9ce43c --- /dev/null +++ b/app/api/lotto/_nas.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +const LOTTO_PRODUCT_IDS = ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual']; + +function nasHeaders() { + const h: Record = {}; + if (process.env.NAS_LOTTO_API_KEY) h['Authorization'] = `Bearer ${process.env.NAS_LOTTO_API_KEY}`; + return h; +} + +function nasBase() { + const base = process.env.NAS_LOTTO_API_URL; + if (!base) throw new Error('NAS_URL_NOT_CONFIGURED'); + return base; +} + +export async function nasGet(path: string): Promise { + const res = await fetch(`${nasBase()}${path}`, { + headers: nasHeaders(), signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`NAS_${res.status}`); + return res.json(); +} + +export async function nasPost(path: string, body: unknown): Promise { + const res = await fetch(`${nasBase()}${path}`, { + method: 'POST', + headers: { ...nasHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`NAS_${res.status}`); + return res.json(); +} + +export async function nasPut(path: string, body: unknown): Promise { + const res = await fetch(`${nasBase()}${path}`, { + method: 'PUT', + headers: { ...nasHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`NAS_${res.status}`); + return res.json(); +} + +export async function nasDelete(path: string): Promise { + const res = await fetch(`${nasBase()}${path}`, { + method: 'DELETE', headers: nasHeaders(), signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`NAS_${res.status}`); + return res.json(); +} + +export interface AuthResult { userId: string; plan: string; } + +export async function requireSubscription(): Promise { + const supabase = await createClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + + const { data: sub } = await supabase + .from('subscriptions').select('product_id') + .eq('user_id', user.id).eq('status', 'active') + .in('product_id', LOTTO_PRODUCT_IDS).maybeSingle(); + if (sub) return { userId: user.id, plan: sub.product_id }; + + const ago31 = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(); + const { data: order } = await supabase + .from('orders').select('product_id') + .eq('user_id', user.id).eq('status', 'paid') + .in('product_id', LOTTO_PRODUCT_IDS) + .gte('created_at', ago31) + .order('created_at', { ascending: false }).limit(1).maybeSingle(); + if (order) return { userId: user.id, plan: order.product_id }; + + return NextResponse.json({ error: 'NOT_SUBSCRIBED' }, { status: 403 }); +} + +export function handleNasError(err: unknown): NextResponse { + const e = err as { name?: string }; + if (e?.name === 'TimeoutError') return NextResponse.json({ error: 'NAS_TIMEOUT' }, { status: 504 }); + console.error('[NAS]', err); + return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 }); +} diff --git a/app/api/lotto/analysis/personal/route.ts b/app/api/lotto/analysis/personal/route.ts new file mode 100644 index 0000000..cc740c9 --- /dev/null +++ b/app/api/lotto/analysis/personal/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { nasGet, requireSubscription, handleNasError } from '../../_nas'; + +export async function GET() { + try { + const auth = await requireSubscription(); + if (auth instanceof NextResponse) return auth; + const data = await nasGet('/api/lotto/analysis/personal'); + return NextResponse.json(data); + } catch (err) { return handleNasError(err); } +} diff --git a/app/api/lotto/purchase/[id]/route.ts b/app/api/lotto/purchase/[id]/route.ts new file mode 100644 index 0000000..641f2eb --- /dev/null +++ b/app/api/lotto/purchase/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { nasPut, nasDelete, requireSubscription, handleNasError } from '../../_nas'; + +export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const auth = await requireSubscription(); + if (auth instanceof NextResponse) return auth; + const { id } = await params; + const body = await request.json(); + const data = await nasPut(`/api/lotto/purchase/${id}`, body); + return NextResponse.json(data); + } catch (err) { return handleNasError(err); } +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const auth = await requireSubscription(); + if (auth instanceof NextResponse) return auth; + const { id } = await params; + const data = await nasDelete(`/api/lotto/purchase/${id}`); + return NextResponse.json(data); + } catch (err) { return handleNasError(err); } +} diff --git a/app/api/lotto/purchase/route.ts b/app/api/lotto/purchase/route.ts new file mode 100644 index 0000000..bee820c --- /dev/null +++ b/app/api/lotto/purchase/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { nasGet, nasPost, requireSubscription, handleNasError } from '../_nas'; + +export async function GET(request: Request) { + try { + const auth = await requireSubscription(); + if (auth instanceof NextResponse) return auth; + const { searchParams } = new URL(request.url); + const params = new URLSearchParams(); + if (searchParams.get('draw_no')) params.set('draw_no', searchParams.get('draw_no')!); + if (searchParams.get('days')) params.set('days', searchParams.get('days')!); + const qs = params.toString() ? `?${params}` : ''; + const data = await nasGet(`/api/lotto/purchase${qs}`); + return NextResponse.json(data); + } catch (err) { return handleNasError(err); } +} + +export async function POST(request: Request) { + try { + const auth = await requireSubscription(); + if (auth instanceof NextResponse) return auth; + const body = await request.json(); + const data = await nasPost('/api/lotto/purchase', body); + return NextResponse.json(data, { status: 201 }); + } catch (err) { return handleNasError(err); } +} diff --git a/app/api/lotto/purchase/stats/route.ts b/app/api/lotto/purchase/stats/route.ts new file mode 100644 index 0000000..cc93d55 --- /dev/null +++ b/app/api/lotto/purchase/stats/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { nasGet, requireSubscription, handleNasError } from '../../_nas'; + +export async function GET() { + try { + const auth = await requireSubscription(); + if (auth instanceof NextResponse) return auth; + const data = await nasGet('/api/lotto/purchase/stats'); + return NextResponse.json(data); + } catch (err) { return handleNasError(err); } +} diff --git a/app/api/lotto/report/history/route.ts b/app/api/lotto/report/history/route.ts new file mode 100644 index 0000000..cf3823a --- /dev/null +++ b/app/api/lotto/report/history/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { nasGet, requireSubscription, handleNasError } from '../../_nas'; + +export async function GET(request: Request) { + try { + const auth = await requireSubscription(); + if (auth instanceof NextResponse) return auth; + const { searchParams } = new URL(request.url); + const limit = searchParams.get('limit') ?? '10'; + const data = await nasGet(`/api/lotto/report/history?limit=${limit}`); + return NextResponse.json(data); + } catch (err) { return handleNasError(err); } +} diff --git a/app/api/lotto/report/latest/route.ts b/app/api/lotto/report/latest/route.ts new file mode 100644 index 0000000..8b3c3ed --- /dev/null +++ b/app/api/lotto/report/latest/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { nasGet, requireSubscription, handleNasError } from '../../_nas'; + +export async function GET() { + try { + const auth = await requireSubscription(); + if (auth instanceof NextResponse) return auth; + const data = await nasGet('/api/lotto/report/latest'); + return NextResponse.json(data); + } catch (err) { return handleNasError(err); } +} diff --git a/app/api/lotto/stats/performance/route.ts b/app/api/lotto/stats/performance/route.ts new file mode 100644 index 0000000..20696d4 --- /dev/null +++ b/app/api/lotto/stats/performance/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { nasGet, handleNasError } from '../../_nas'; + +export async function GET() { + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + const data = await nasGet('/api/lotto/stats/performance'); + return NextResponse.json(data); + } catch (err) { return handleNasError(err); } +} diff --git a/app/services/lotto/recommend/PatternTab.tsx b/app/services/lotto/recommend/PatternTab.tsx new file mode 100644 index 0000000..fc3a329 --- /dev/null +++ b/app/services/lotto/recommend/PatternTab.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface PersonalPattern { + total_analyzed: number; + number_frequency: Record; + top_picks: number[]; + least_picks: number[]; + pattern: { + avg_odd_count: number; + avg_sum: number; + avg_range: number; + consecutive_rate: number; + zone_avg: Record; + }; + vs_draw_avg: { + odd_diff: number; + sum_diff: number; + odd_tendency: string; + sum_tendency: string; + }; +} + +function getBallStyle(n: number) { + if (n <= 10) return { bg: 'linear-gradient(145deg,#fde68a,#fbbf24,#d97706)', text: '#78350f' }; + if (n <= 20) return { bg: 'linear-gradient(145deg,#93c5fd,#3b82f6,#1d4ed8)', text: '#fff' }; + if (n <= 30) return { bg: 'linear-gradient(145deg,#fca5a5,#ef4444,#b91c1c)', text: '#fff' }; + if (n <= 40) return { bg: 'linear-gradient(145deg,#d1d5db,#9ca3af,#4b5563)', text: '#fff' }; + return { bg: 'linear-gradient(145deg,#86efac,#22c55e,#15803d)', text: '#fff' }; +} + +function SmallBall({ n, size = 30, freq }: { n: number; size?: number; freq?: number }) { + const { bg, text } = getBallStyle(n); + return ( +
+
{n}
+ {freq !== undefined &&
{freq}회
} +
+ ); +} + +function ZoneBar({ label, value, max }: { label: string; value: number; max: number }) { + const pct = max > 0 ? (value / max) * 100 : 0; + return ( +
+
{label}
+
+
+
+
{value.toFixed(1)}
+
+ ); +} + +export default function PatternTab() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + fetch('/api/lotto/analysis/personal').then(r => r.json()) + .then(setData) + .catch(() => setError('패턴 분석을 불러오지 못했습니다.')) + .finally(() => setLoading(false)); + }, []); + + if (loading) return ( +
+
+
+ ); + + if (error) return
{error}
; + + if (!data || data.total_analyzed === 0) return ( +
+
📊
+
아직 분석할 데이터가 없습니다
+
번호 생성 탭에서 번호를 추천받으면 패턴이 쌓입니다
+
+ ); + + const zoneMax = Math.max(...Object.values(data.pattern.zone_avg)); + const tendencyColor = (tendency: string) => + tendency.includes('고') || tendency.includes('홀수') ? '#f87171' : tendency.includes('저') || tendency.includes('짝수') ? '#60a5fa' : '#4ade80'; + + return ( +
+ +
+
+
PERSONAL PATTERN ANALYSIS
+

내 번호 선택 패턴

+
+
+ 총 분석 + {data.total_analyzed} + +
+
+ +
+ + {/* 자주 선택한 번호 */} +
+
⭐ 자주 선택한 번호 TOP 10
+
+ {data.top_picks.map(n => ( + + ))} +
+
+ + {/* 한 번도 안 쓴 번호 */} +
+
💤 거의 안 쓴 번호
+
+ {data.least_picks.map(n => )} +
+
이 번호들도 가끔 포함해보세요
+
+ + {/* 패턴 지표 */} +
+
📐 선택 패턴 지표
+ {[ + { label: '평균 홀수 개수', value: data.pattern.avg_odd_count.toFixed(1) + '개', ref: '역대 평균 3.0개', refColor: 'rgba(255,255,255,.2)' }, + { label: '평균 합계', value: data.pattern.avg_sum.toFixed(0), ref: '역대 평균 138', refColor: 'rgba(255,255,255,.2)' }, + { label: '평균 범위(최대-최소)', value: data.pattern.avg_range.toFixed(1), ref: '', refColor: '' }, + { label: '연속번호 포함률', value: `${(data.pattern.consecutive_rate * 100).toFixed(0)}%`, ref: '', refColor: '' }, + ].map(({ label, value, ref }) => ( +
+ {label} +
+
{value}
+ {ref &&
{ref}
} +
+
+ ))} +
+ + {/* 구간별 선택 분포 */} +
+
🎯 구간별 선택 분포
+
+ {Object.entries(data.pattern.zone_avg).map(([zone, val]) => ( + + ))} +
+
+ + {/* 역대 당첨과 비교 */} +
+
⚖️ 역대 당첨 평균과 비교
+
+
+
홀수 선택 경향
+
{data.vs_draw_avg.odd_tendency}
+
+ 당첨 평균 대비 {data.vs_draw_avg.odd_diff > 0 ? '+' : ''}{data.vs_draw_avg.odd_diff.toFixed(1)}개 +
+
+
+
합계 선택 경향
+
{data.vs_draw_avg.sum_tendency}
+
+ 당첨 평균 대비 {data.vs_draw_avg.sum_diff > 0 ? '+' : ''}{data.vs_draw_avg.sum_diff.toFixed(1)} +
+
+
+
+
+
+ ); +} diff --git a/app/services/lotto/recommend/PurchaseTab.tsx b/app/services/lotto/recommend/PurchaseTab.tsx new file mode 100644 index 0000000..3af6b37 --- /dev/null +++ b/app/services/lotto/recommend/PurchaseTab.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface PurchaseRecord { + id: number; + draw_no: number; + amount: number; + sets: number; + prize: number; + note: string; + created_at: string; +} + +interface PurchaseStats { + total_records: number; + total_invested: number; + total_prize: number; + net: number; + return_rate: number; + prize_count: number; + max_prize: number; +} + +function StatCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +export default function PurchaseTab() { + const [records, setRecords] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editPrize, setEditPrize] = useState(''); + const [editNote, setEditNote] = useState(''); + const [showAdd, setShowAdd] = useState(false); + const [addForm, setAddForm] = useState({ draw_no: '', amount: '5000', sets: '5', prize: '0', note: '' }); + const [saving, setSaving] = useState(false); + + const load = async () => { + try { + const [recRes, statRes] = await Promise.all([ + fetch('/api/lotto/purchase').then(r => r.json()), + fetch('/api/lotto/purchase/stats').then(r => r.json()), + ]); + setRecords(recRes.records ?? []); + setStats(statRes); + } finally { setLoading(false); } + }; + + useEffect(() => { load(); }, []); + + const handleAdd = async () => { + if (!addForm.draw_no) return; + setSaving(true); + try { + await fetch('/api/lotto/purchase', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + draw_no: parseInt(addForm.draw_no), + amount: parseInt(addForm.amount), + sets: parseInt(addForm.sets), + prize: parseInt(addForm.prize), + note: addForm.note, + }), + }); + setShowAdd(false); + setAddForm({ draw_no: '', amount: '5000', sets: '5', prize: '0', note: '' }); + await load(); + } finally { setSaving(false); } + }; + + const handleUpdate = async (id: number) => { + setSaving(true); + try { + await fetch(`/api/lotto/purchase/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prize: parseInt(editPrize) || 0, note: editNote }), + }); + setEditingId(null); + await load(); + } finally { setSaving(false); } + }; + + const handleDelete = async (id: number) => { + if (!confirm('삭제하시겠습니까?')) return; + await fetch(`/api/lotto/purchase/${id}`, { method: 'DELETE' }); + await load(); + }; + + const inputStyle: React.CSSProperties = { + background: 'rgba(255,255,255,.06)', border: '1px solid rgba(255,255,255,.12)', + borderRadius: '.4rem', padding: '.35rem .65rem', color: '#fff', fontSize: '.78rem', width: '100%', + outline: 'none', + }; + + if (loading) return ( +
+
+
+ ); + + return ( +
+ + {/* 통계 카드 */} + {stats && ( +
+
INVESTMENT STATS
+
+ + + = 0 ? '+' : ''}${(stats.net / 10000).toFixed(1)}만원`} + sub={`회수율 ${stats.return_rate.toFixed(1)}%`} + color={stats.net >= 0 ? '#4ade80' : '#f87171'} + /> + 0 ? `${stats.max_prize.toLocaleString()}원` : '-'} color="#fbbf24" /> +
+
+ )} + + {/* 구매 기록 테이블 */} +
+
+
PURCHASE HISTORY
+ +
+ + {/* 추가 폼 */} + {showAdd && ( +
+
+
+
회차 *
+ setAddForm(p => ({ ...p, draw_no: e.target.value }))} /> +
+
+
구매금액
+ setAddForm(p => ({ ...p, amount: e.target.value }))} /> +
+
+
세트수
+ setAddForm(p => ({ ...p, sets: e.target.value }))} /> +
+
+
당첨금
+ setAddForm(p => ({ ...p, prize: e.target.value }))} /> +
+
+
메모
+ setAddForm(p => ({ ...p, note: e.target.value }))} /> +
+
+
+ + +
+
+ )} + + {/* 레코드 목록 */} + {records.length === 0 ? ( +
+ 구매 기록이 없습니다. 구매 후 기록을 추가해보세요. +
+ ) : ( +
+ + + + {['회차', '구매금액', '세트', '당첨금', '손익', '메모', ''].map(h => ( + + ))} + + + + {records.map(rec => { + const net = rec.prize - rec.amount; + const isEditing = editingId === rec.id; + return ( + + + + + + + + + + ); + })} + +
{h}
{rec.draw_no}회{rec.amount.toLocaleString()}원{rec.sets}세트 + {isEditing ? ( + setEditPrize(e.target.value)} /> + ) : ( + 0 ? '#4ade80' : 'rgba(255,255,255,.3)' }}> + {rec.prize > 0 ? `${rec.prize.toLocaleString()}원` : '-'} + + )} + 0 ? '#4ade80' : net < 0 ? '#f87171' : 'rgba(255,255,255,.3)', fontFamily: "'JetBrains Mono',monospace", fontWeight: 700 }}> + {net > 0 ? '+' : ''}{net.toLocaleString()} + + {isEditing ? ( + setEditNote(e.target.value)} /> + ) : ( + {rec.note || '-'} + )} + + {isEditing ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/app/services/lotto/recommend/ReportTab.tsx b/app/services/lotto/recommend/ReportTab.tsx new file mode 100644 index 0000000..23e639e --- /dev/null +++ b/app/services/lotto/recommend/ReportTab.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +// ─── Types ─────────────────────────────────────────────────────────────────── +interface ReportData { + target_drw_no: number; + based_on_draw: number; + generated_at: string; + hot_numbers: number[]; + cold_numbers: number[]; + overdue_numbers: number[]; + recent_pattern: { + last3_numbers: number[]; + triple_appear: number[]; + recent_sum_avg: number; + recent_odd_avg: number; + }; + recommended_sets: Array<{ + strategy: string; + numbers: number[]; + description: string; + }>; + confidence_score: number; + confidence_factors: { + data_volume: number; + pattern_consistency: number; + recent_trend: number; + }; +} + +interface HistoryItem { drw_no: number; generated_at: string; } + +function getBallStyle(n: number) { + if (n <= 10) return { bg: 'linear-gradient(145deg,#fde68a,#fbbf24,#d97706)', text: '#78350f' }; + if (n <= 20) return { bg: 'linear-gradient(145deg,#93c5fd,#3b82f6,#1d4ed8)', text: '#fff' }; + if (n <= 30) return { bg: 'linear-gradient(145deg,#fca5a5,#ef4444,#b91c1c)', text: '#fff' }; + if (n <= 40) return { bg: 'linear-gradient(145deg,#d1d5db,#9ca3af,#4b5563)', text: '#fff' }; + return { bg: 'linear-gradient(145deg,#86efac,#22c55e,#15803d)', text: '#fff' }; +} + +function SmallBall({ n, size = 32 }: { n: number; size?: number }) { + const { bg, text } = getBallStyle(n); + return ( +
{n}
+ ); +} + +function ConfidenceBar({ label, value }: { label: string; value: number }) { + const color = value >= 85 ? '#4ade80' : value >= 70 ? '#fbbf24' : '#f87171'; + return ( +
+
+ {label} + {value} +
+
+
+
+
+ ); +} + +export default function ReportTab() { + const [report, setReport] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [copiedIdx, setCopiedIdx] = useState(null); + + useEffect(() => { + Promise.all([ + fetch('/api/lotto/report/latest').then(r => r.json()), + fetch('/api/lotto/report/history?limit=10').then(r => r.json()), + ]).then(([rep, hist]) => { + setReport(rep); + setHistory(hist.reports ?? []); + }).catch(() => setError('리포트를 불러오지 못했습니다.')) + .finally(() => setLoading(false)); + }, []); + + const copyNumbers = (numbers: number[], idx: number) => { + navigator.clipboard.writeText(numbers.join(', ')); + setCopiedIdx(idx); + setTimeout(() => setCopiedIdx(null), 1500); + }; + + if (loading) return ( +
+
+
리포트 불러오는 중...
+
+ ); + + if (error) return ( +
{error}
+ ); + + if (!report) return null; + + const strategyColors = ['#fbbf24', '#60a5fa', '#a78bfa']; + + return ( +
+ + {/* 헤더 */} +
+
+
WEEKLY ATTACK REPORT
+

+ 제{report.target_drw_no}회 공략 리포트 +

+
+ {report.based_on_draw}회까지 데이터 기반 · {new Date(report.generated_at).toLocaleDateString('ko-KR')} 생성 +
+
+ {/* 신뢰도 점수 */} +
+
CONFIDENCE
+
{report.confidence_score}
+
/100
+
+
+ +
+ + {/* 추천 번호 세트 */} +
+
RECOMMENDED SETS
+
+ {report.recommended_sets.map((set, i) => ( +
+
+
+
+ {set.strategy} +
+ +
+
+ {set.numbers.map(n => )} +
+
{set.description}
+
+ ))} +
+
+ + {/* 핫/콜드/미출현 */} + {[ + { label: '🔥 최근 과출현', numbers: report.hot_numbers, color: '#f87171', desc: '최근 10회 2회 이상 출현' }, + { label: '❄️ 저빈도 번호', numbers: report.cold_numbers, color: '#60a5fa', desc: '역대 출현 빈도 하위' }, + { label: '⏳ 장기 미출현', numbers: report.overdue_numbers, color: '#a78bfa', desc: '가장 오래 미출현 번호' }, + ].map(({ label, numbers, color, desc }) => ( +
+
{label}
+
{desc}
+
+ {numbers.map(n => )} +
+
+ ))} + + {/* 최근 패턴 */} +
+
📊 최근 패턴
+ {[ + { label: '최근 10회 합계 평균', value: report.recent_pattern.recent_sum_avg.toFixed(1) }, + { label: '최근 10회 홀수 평균', value: report.recent_pattern.recent_odd_avg.toFixed(1) + '개' }, + ].map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} + {report.recent_pattern.triple_appear.length > 0 && ( +
+
직전 3회 연속 출현
+
+ {report.recent_pattern.triple_appear.map(n => )} +
+
+ )} +
+ + {/* 신뢰도 상세 */} +
+
🎯 신뢰도 분석
+ + + +
+
+ + {/* 이전 리포트 목록 */} + {history.length > 0 && ( +
+
REPORT HISTORY
+
+ {history.map(h => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/app/services/lotto/recommend/page.tsx b/app/services/lotto/recommend/page.tsx index fac414c..c1272ac 100644 --- a/app/services/lotto/recommend/page.tsx +++ b/app/services/lotto/recommend/page.tsx @@ -2,6 +2,9 @@ import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; +import ReportTab from './ReportTab'; +import PurchaseTab from './PurchaseTab'; +import PatternTab from './PatternTab'; import { createClient } from '@/lib/supabase/client'; // ─── 전략 타입 ──────────────────────────────────────────────────────────────── @@ -299,6 +302,8 @@ export default function LottoRecommendPage() { const [combos, setCombos] = useState([]); const [proState, setProState] = useState<'idle' | 'loading' | 'result' | 'error'>('idle'); const [proError, setProError] = useState(''); + const [activeTab, setActiveTab] = useState<'generate' | 'report' | 'purchase' | 'pattern'>('generate'); + const [perfStats, setPerfStats] = useState<{ total_checked: number; avg_correct: number; rate_3plus: number; vs_random: { improvement_pct: number } } | null>(null); const idRef = useRef(0); const MAX_COMBOS = PLAN_MAX_COMBOS[plan] ?? 5; @@ -318,6 +323,11 @@ export default function LottoRecommendPage() { } } catch { /* ignore */ } } + // 성과 통계 (구독 여부 무관 로드 시도) + try { + const perfRes = await fetch('/api/lotto/stats/performance'); + if (perfRes.ok) { const p = await perfRes.json(); setPerfStats(p); } + } catch { /* ignore */ } setPageReady(true); } init(); @@ -525,6 +535,66 @@ export default function LottoRecommendPage() {

+ {/* ── 성과 배너 ── */} + {perfStats && perfStats.total_checked > 0 && ( +
+
+
+ 실제 검증 성과 +
+
+
+
{(perfStats.rate_3plus * 100).toFixed(1)}%
+
3개 이상 일치율
+
+
+
{perfStats.avg_correct.toFixed(1)}개
+
평균 일치 번호
+
+
+
+{perfStats.vs_random.improvement_pct.toFixed(0)}%
+
무작위 대비
+
+
+
{perfStats.total_checked.toLocaleString()}
+
검증 건수
+
+
+
+ )} + + {/* ── 탭 네비게이션 ── */} + {isSubscribed && ( +
+ {([ + { key: 'generate', label: '🎲 번호 생성' }, + { key: 'report', label: '📋 이번 주 공략' }, + { key: 'purchase', label: '💰 구매 기록' }, + { key: 'pattern', label: '🔍 내 패턴' }, + ] as const).map(tab => ( + + ))} +
+ )} + + {/* ── 탭별 컨텐츠: 공략/구매/패턴 ── */} + {isSubscribed && activeTab === 'report' && } + {isSubscribed && activeTab === 'purchase' && } + {isSubscribed && activeTab === 'pattern' && } + + {/* ── 기존 메인 콘텐츠 (번호 생성 탭 or 비구독) ── */} +
+ {/* ── 통계 인디케이터 패널 (전체 공개) ── */}
{[ @@ -1121,6 +1191,8 @@ export default function LottoRecommendPage() {

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

+
{/* ── 기존 메인 콘텐츠 래퍼 닫기 ── */} +