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 => (
+ | {h} |
+ ))}
+
+
+
+ {records.map(rec => {
+ const net = rec.prize - rec.amount;
+ const isEditing = editingId === rec.id;
+ return (
+
+ | {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.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() {
본 서비스는 몬테카를로 시뮬레이션 기반 통계 분석으로, 당첨을 보장하지 않습니다.
+
{/* ── 기존 메인 콘텐츠 래퍼 닫기 ── */}
+
>