feat: 로또 서비스 Phase 1-2 프론트엔드 고도화

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 01:12:59 +09:00
parent b306b0e42c
commit 4cacea69c8
12 changed files with 937 additions and 0 deletions

86
app/api/lotto/_nas.ts Normal file
View File

@@ -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<string, string> = {};
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<AuthResult | NextResponse> {
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 });
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,181 @@
'use client';
import { useState, useEffect } from 'react';
interface PersonalPattern {
total_analyzed: number;
number_frequency: Record<string, number>;
top_picks: number[];
least_picks: number[];
pattern: {
avg_odd_count: number;
avg_sum: number;
avg_range: number;
consecutive_rate: number;
zone_avg: Record<string, number>;
};
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3 }}>
<div style={{
width: size, height: size, borderRadius: '50%', background: bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.35, fontWeight: 900, color: text, flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,.3)',
}}>{n}</div>
{freq !== undefined && <div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.55rem', fontFamily: "'JetBrains Mono',monospace" }}>{freq}</div>}
</div>
);
}
function ZoneBar({ label, value, max }: { label: string; value: number; max: number }) {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem' }}>
<div style={{ color: 'rgba(255,255,255,.4)', fontSize: '.65rem', minWidth: 44, fontFamily: "'JetBrains Mono',monospace" }}>{label}</div>
<div style={{ flex: 1, height: 8, background: 'rgba(255,255,255,.06)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'linear-gradient(90deg,#fbbf24,#f97316)', borderRadius: 4, transition: 'width 1s ease' }} />
</div>
<div style={{ color: '#fbbf24', fontSize: '.65rem', minWidth: 24, textAlign: 'right', fontFamily: "'JetBrains Mono',monospace" }}>{value.toFixed(1)}</div>
</div>
);
}
export default function PatternTab() {
const [data, setData] = useState<PersonalPattern | null>(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 (
<div style={{ textAlign: 'center', padding: '4rem 0' }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', border: '3px solid rgba(251,191,36,.15)', borderTop: '3px solid #fbbf24', animation: 'spin .8s linear infinite', margin: '0 auto 1rem' }} />
</div>
);
if (error) return <div style={{ textAlign: 'center', padding: '4rem 0', color: '#f87171', fontSize: '.85rem' }}>{error}</div>;
if (!data || data.total_analyzed === 0) return (
<div style={{ textAlign: 'center', padding: '5rem 1rem' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>📊</div>
<div style={{ color: 'rgba(255,255,255,.4)', fontSize: '.9rem', marginBottom: '.5rem' }}> </div>
<div style={{ color: 'rgba(255,255,255,.2)', fontSize: '.75rem' }}> </div>
</div>
);
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 (
<div style={{ animation: 'slideUp .4s ease forwards' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<div>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: '.6rem', color: 'rgba(251,191,36,.5)', letterSpacing: '.12em', marginBottom: '.3rem' }}>PERSONAL PATTERN ANALYSIS</div>
<h2 style={{ color: '#fff', fontSize: '1.3rem', fontWeight: 900, margin: 0 }}> </h2>
</div>
<div style={{ background: 'rgba(251,191,36,.08)', border: '1px solid rgba(251,191,36,.2)', borderRadius: '.75rem', padding: '.5rem 1rem', marginLeft: 'auto' }}>
<span style={{ color: 'rgba(255,255,255,.4)', fontSize: '.65rem' }}> </span>
<span style={{ color: '#fbbf24', fontWeight: 900, fontFamily: "'JetBrains Mono',monospace" }}>{data.total_analyzed}</span>
<span style={{ color: 'rgba(255,255,255,.4)', fontSize: '.65rem' }}></span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(280px,1fr))', gap: '1rem' }}>
{/* 자주 선택한 번호 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: '#fbbf24', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}> TOP 10</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>
{data.top_picks.map(n => (
<SmallBall key={n} n={n} size={34} freq={data.number_frequency[String(n)] ?? 0} />
))}
</div>
</div>
{/* 한 번도 안 쓴 번호 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: '#60a5fa', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}>💤 </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>
{data.least_picks.map(n => <SmallBall key={n} n={n} size={34} />)}
</div>
<div style={{ color: 'rgba(255,255,255,.25)', fontSize: '.65rem', marginTop: '.75rem' }}> </div>
</div>
{/* 패턴 지표 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: 'rgba(255,255,255,.5)', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}>📐 </div>
{[
{ 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 }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', padding: '.45rem 0', borderBottom: '1px solid rgba(255,255,255,.05)' }}>
<span style={{ color: 'rgba(255,255,255,.35)', fontSize: '.68rem' }}>{label}</span>
<div style={{ textAlign: 'right' }}>
<div style={{ color: '#fff', fontWeight: 700, fontSize: '.8rem', fontFamily: "'JetBrains Mono',monospace" }}>{value}</div>
{ref && <div style={{ color: 'rgba(255,255,255,.2)', fontSize: '.58rem' }}>{ref}</div>}
</div>
</div>
))}
</div>
{/* 구간별 선택 분포 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: 'rgba(255,255,255,.5)', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}>🎯 </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '.5rem' }}>
{Object.entries(data.pattern.zone_avg).map(([zone, val]) => (
<ZoneBar key={zone} label={zone} value={val} max={zoneMax} />
))}
</div>
</div>
{/* 역대 당첨과 비교 */}
<div style={{ gridColumn: '1/-1', background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: 'rgba(255,255,255,.5)', fontSize: '.72rem', fontWeight: 700, marginBottom: '1rem' }}> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(200px,1fr))', gap: '1rem' }}>
<div style={{ background: 'rgba(255,255,255,.03)', borderRadius: '.75rem', padding: '1rem', textAlign: 'center' }}>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.65rem', marginBottom: '.4rem' }}> </div>
<div style={{ color: tendencyColor(data.vs_draw_avg.odd_tendency), fontSize: '1.1rem', fontWeight: 900 }}>{data.vs_draw_avg.odd_tendency}</div>
<div style={{ color: 'rgba(255,255,255,.25)', fontSize: '.65rem', marginTop: '.3rem', fontFamily: "'JetBrains Mono',monospace" }}>
{data.vs_draw_avg.odd_diff > 0 ? '+' : ''}{data.vs_draw_avg.odd_diff.toFixed(1)}
</div>
</div>
<div style={{ background: 'rgba(255,255,255,.03)', borderRadius: '.75rem', padding: '1rem', textAlign: 'center' }}>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.65rem', marginBottom: '.4rem' }}> </div>
<div style={{ color: tendencyColor(data.vs_draw_avg.sum_tendency), fontSize: '1.1rem', fontWeight: 900 }}>{data.vs_draw_avg.sum_tendency}</div>
<div style={{ color: 'rgba(255,255,255,.25)', fontSize: '.65rem', marginTop: '.3rem', fontFamily: "'JetBrains Mono',monospace" }}>
{data.vs_draw_avg.sum_diff > 0 ? '+' : ''}{data.vs_draw_avg.sum_diff.toFixed(1)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '.75rem', padding: '1rem', textAlign: 'center' }}>
<div style={{ color: 'rgba(255,255,255,.35)', fontSize: '.65rem', marginBottom: '.4rem' }}>{label}</div>
<div style={{ color: color ?? '#fff', fontSize: '1.3rem', fontWeight: 900, fontFamily: "'JetBrains Mono',monospace", lineHeight: 1.1 }}>{value}</div>
{sub && <div style={{ color: 'rgba(255,255,255,.25)', fontSize: '.6rem', marginTop: '.2rem' }}>{sub}</div>}
</div>
);
}
export default function PurchaseTab() {
const [records, setRecords] = useState<PurchaseRecord[]>([]);
const [stats, setStats] = useState<PurchaseStats | null>(null);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(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 (
<div style={{ textAlign: 'center', padding: '4rem 0' }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', border: '3px solid rgba(251,191,36,.15)', borderTop: '3px solid #fbbf24', animation: 'spin .8s linear infinite', margin: '0 auto 1rem' }} />
</div>
);
return (
<div style={{ animation: 'slideUp .4s ease forwards' }}>
{/* 통계 카드 */}
{stats && (
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: '.6rem', color: 'rgba(251,191,36,.5)', letterSpacing: '.12em', marginBottom: '.75rem' }}>INVESTMENT STATS</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(120px,1fr))', gap: '.6rem' }}>
<StatCard label="총 구매금액" value={`${(stats.total_invested / 10000).toFixed(1)}만원`} sub={`${stats.total_records}회 구매`} />
<StatCard label="총 당첨금" value={`${(stats.total_prize / 10000).toFixed(1)}만원`} sub={`${stats.prize_count}건 당첨`} color="#4ade80" />
<StatCard
label="순손익"
value={`${stats.net >= 0 ? '+' : ''}${(stats.net / 10000).toFixed(1)}만원`}
sub={`회수율 ${stats.return_rate.toFixed(1)}%`}
color={stats.net >= 0 ? '#4ade80' : '#f87171'}
/>
<StatCard label="최대 당첨금" value={stats.max_prize > 0 ? `${stats.max_prize.toLocaleString()}` : '-'} color="#fbbf24" />
</div>
</div>
)}
{/* 구매 기록 테이블 */}
<div style={{ background: 'rgba(255,255,255,.02)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,.06)' }}>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: '.65rem', color: 'rgba(255,255,255,.4)', letterSpacing: '.1em' }}>PURCHASE HISTORY</div>
<button
onClick={() => setShowAdd(!showAdd)}
style={{
background: 'rgba(251,191,36,.1)', border: '1px solid rgba(251,191,36,.25)',
color: '#fbbf24', borderRadius: '.5rem', padding: '.3rem .75rem', fontSize: '.72rem', cursor: 'pointer', fontWeight: 700,
}}>
+
</button>
</div>
{/* 추가 폼 */}
{showAdd && (
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,.06)', background: 'rgba(251,191,36,.04)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(100px,1fr))', gap: '.5rem', marginBottom: '.75rem' }}>
<div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.62rem', marginBottom: '.25rem' }}> *</div>
<input style={inputStyle} placeholder="1181" value={addForm.draw_no} onChange={e => setAddForm(p => ({ ...p, draw_no: e.target.value }))} />
</div>
<div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.62rem', marginBottom: '.25rem' }}></div>
<input style={inputStyle} placeholder="5000" value={addForm.amount} onChange={e => setAddForm(p => ({ ...p, amount: e.target.value }))} />
</div>
<div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.62rem', marginBottom: '.25rem' }}></div>
<input style={inputStyle} placeholder="5" value={addForm.sets} onChange={e => setAddForm(p => ({ ...p, sets: e.target.value }))} />
</div>
<div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.62rem', marginBottom: '.25rem' }}></div>
<input style={inputStyle} placeholder="0" value={addForm.prize} onChange={e => setAddForm(p => ({ ...p, prize: e.target.value }))} />
</div>
<div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.62rem', marginBottom: '.25rem' }}></div>
<input style={inputStyle} placeholder="5등 1개" value={addForm.note} onChange={e => setAddForm(p => ({ ...p, note: e.target.value }))} />
</div>
</div>
<div style={{ display: 'flex', gap: '.5rem' }}>
<button onClick={handleAdd} disabled={saving || !addForm.draw_no}
style={{ background: '#fbbf24', color: '#020c1e', border: 'none', borderRadius: '.5rem', padding: '.4rem 1rem', fontSize: '.75rem', fontWeight: 700, cursor: saving ? 'not-allowed' : 'pointer' }}>
{saving ? '저장 중...' : '저장'}
</button>
<button onClick={() => setShowAdd(false)}
style={{ background: 'rgba(255,255,255,.06)', color: 'rgba(255,255,255,.4)', border: '1px solid rgba(255,255,255,.1)', borderRadius: '.5rem', padding: '.4rem 1rem', fontSize: '.75rem', cursor: 'pointer' }}>
</button>
</div>
</div>
)}
{/* 레코드 목록 */}
{records.length === 0 ? (
<div style={{ padding: '3rem', textAlign: 'center', color: 'rgba(255,255,255,.2)', fontSize: '.8rem' }}>
. .
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '.75rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,.06)' }}>
{['회차', '구매금액', '세트', '당첨금', '손익', '메모', ''].map(h => (
<th key={h} style={{ padding: '.6rem 1rem', color: 'rgba(255,255,255,.3)', fontWeight: 600, textAlign: 'left', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{records.map(rec => {
const net = rec.prize - rec.amount;
const isEditing = editingId === rec.id;
return (
<tr key={rec.id} style={{ borderBottom: '1px solid rgba(255,255,255,.04)', transition: 'background .15s' }}>
<td style={{ padding: '.7rem 1rem', color: '#fbbf24', fontWeight: 700, fontFamily: "'JetBrains Mono',monospace" }}>{rec.draw_no}</td>
<td style={{ padding: '.7rem 1rem', color: 'rgba(255,255,255,.6)' }}>{rec.amount.toLocaleString()}</td>
<td style={{ padding: '.7rem 1rem', color: 'rgba(255,255,255,.4)' }}>{rec.sets}</td>
<td style={{ padding: '.7rem 1rem' }}>
{isEditing ? (
<input style={{ ...inputStyle, width: 80 }} value={editPrize} onChange={e => setEditPrize(e.target.value)} />
) : (
<span style={{ color: rec.prize > 0 ? '#4ade80' : 'rgba(255,255,255,.3)' }}>
{rec.prize > 0 ? `${rec.prize.toLocaleString()}` : '-'}
</span>
)}
</td>
<td style={{ padding: '.7rem 1rem', color: net > 0 ? '#4ade80' : net < 0 ? '#f87171' : 'rgba(255,255,255,.3)', fontFamily: "'JetBrains Mono',monospace", fontWeight: 700 }}>
{net > 0 ? '+' : ''}{net.toLocaleString()}
</td>
<td style={{ padding: '.7rem 1rem' }}>
{isEditing ? (
<input style={{ ...inputStyle, width: 100 }} value={editNote} onChange={e => setEditNote(e.target.value)} />
) : (
<span style={{ color: 'rgba(255,255,255,.4)' }}>{rec.note || '-'}</span>
)}
</td>
<td style={{ padding: '.7rem 1rem', whiteSpace: 'nowrap' }}>
{isEditing ? (
<div style={{ display: 'flex', gap: '.3rem' }}>
<button onClick={() => handleUpdate(rec.id)} disabled={saving}
style={{ background: '#4ade80', color: '#020c1e', border: 'none', borderRadius: '.35rem', padding: '.25rem .6rem', fontSize: '.65rem', fontWeight: 700, cursor: 'pointer' }}></button>
<button onClick={() => setEditingId(null)}
style={{ background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.4)', border: 'none', borderRadius: '.35rem', padding: '.25rem .6rem', fontSize: '.65rem', cursor: 'pointer' }}></button>
</div>
) : (
<div style={{ display: 'flex', gap: '.3rem' }}>
<button onClick={() => { setEditingId(rec.id); setEditPrize(String(rec.prize)); setEditNote(rec.note); }}
style={{ background: 'rgba(251,191,36,.1)', color: '#fbbf24', border: '1px solid rgba(251,191,36,.2)', borderRadius: '.35rem', padding: '.25rem .6rem', fontSize: '.65rem', cursor: 'pointer' }}></button>
<button onClick={() => handleDelete(rec.id)}
style={{ background: 'rgba(239,68,68,.1)', color: '#f87171', border: '1px solid rgba(239,68,68,.2)', borderRadius: '.35rem', padding: '.25rem .6rem', fontSize: '.65rem', cursor: 'pointer' }}></button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div style={{
width: size, height: size, borderRadius: '50%', background: bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.35, fontWeight: 900, color: text, flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,.3)',
}}>{n}</div>
);
}
function ConfidenceBar({ label, value }: { label: string; value: number }) {
const color = value >= 85 ? '#4ade80' : value >= 70 ? '#fbbf24' : '#f87171';
return (
<div style={{ marginBottom: '.6rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '.25rem' }}>
<span style={{ color: 'rgba(255,255,255,.4)', fontSize: '.7rem' }}>{label}</span>
<span style={{ color, fontSize: '.7rem', fontWeight: 700, fontFamily: "'JetBrains Mono',monospace" }}>{value}</span>
</div>
<div style={{ height: 5, background: 'rgba(255,255,255,.07)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${value}%`, background: color, borderRadius: 3, transition: 'width 1s ease' }} />
</div>
</div>
);
}
export default function ReportTab() {
const [report, setReport] = useState<ReportData | null>(null);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [copiedIdx, setCopiedIdx] = useState<number | null>(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 (
<div style={{ textAlign: 'center', padding: '4rem 0' }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', border: '3px solid rgba(251,191,36,.15)', borderTop: '3px solid #fbbf24', animation: 'spin .8s linear infinite', margin: '0 auto 1rem' }} />
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.75rem' }}> ...</div>
</div>
);
if (error) return (
<div style={{ textAlign: 'center', padding: '4rem 0', color: '#f87171', fontSize: '.85rem' }}>{error}</div>
);
if (!report) return null;
const strategyColors = ['#fbbf24', '#60a5fa', '#a78bfa'];
return (
<div style={{ animation: 'slideUp .4s ease forwards' }}>
{/* 헤더 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '.75rem' }}>
<div>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: '.6rem', color: 'rgba(251,191,36,.5)', letterSpacing: '.15em', marginBottom: '.3rem' }}>WEEKLY ATTACK REPORT</div>
<h2 style={{ color: '#fff', fontSize: '1.4rem', fontWeight: 900, margin: 0 }}>
{report.target_drw_no}
</h2>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.72rem', marginTop: '.2rem' }}>
{report.based_on_draw} · {new Date(report.generated_at).toLocaleDateString('ko-KR')}
</div>
</div>
{/* 신뢰도 점수 */}
<div style={{
background: 'rgba(251,191,36,.08)', border: '1px solid rgba(251,191,36,.2)',
borderRadius: '1rem', padding: '.75rem 1.25rem', textAlign: 'center',
}}>
<div style={{ color: 'rgba(251,191,36,.5)', fontSize: '.6rem', fontFamily: "'JetBrains Mono',monospace", letterSpacing: '.1em' }}>CONFIDENCE</div>
<div style={{ color: '#fbbf24', fontSize: '2rem', fontWeight: 900, lineHeight: 1.1, fontFamily: "'JetBrains Mono',monospace" }}>{report.confidence_score}</div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.6rem' }}>/100</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(280px,1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
{/* 추천 번호 세트 */}
<div style={{ gridColumn: '1/-1', background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: '.6rem', color: 'rgba(251,191,36,.5)', letterSpacing: '.12em', marginBottom: '1rem' }}>RECOMMENDED SETS</div>
<div style={{ display: 'grid', gap: '.75rem' }}>
{report.recommended_sets.map((set, i) => (
<div key={i} style={{
background: `rgba(${i === 0 ? '251,191,36' : i === 1 ? '96,165,250' : '167,139,250'},.05)`,
border: `1px solid rgba(${i === 0 ? '251,191,36' : i === 1 ? '96,165,250' : '167,139,250'},.15)`,
borderRadius: '.75rem', padding: '1rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '.6rem', flexWrap: 'wrap', gap: '.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.5rem' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: strategyColors[i] }} />
<span style={{ color: strategyColors[i], fontSize: '.72rem', fontWeight: 700 }}>{set.strategy}</span>
</div>
<button
onClick={() => copyNumbers(set.numbers, i)}
style={{
background: 'rgba(255,255,255,.06)', border: '1px solid rgba(255,255,255,.1)',
color: copiedIdx === i ? '#4ade80' : 'rgba(255,255,255,.4)',
borderRadius: '.4rem', padding: '.2rem .6rem', fontSize: '.65rem', cursor: 'pointer',
}}>
{copiedIdx === i ? '✓ 복사됨' : '복사'}
</button>
</div>
<div style={{ display: 'flex', gap: '.4rem', flexWrap: 'wrap', marginBottom: '.5rem' }}>
{set.numbers.map(n => <SmallBall key={n} n={n} size={36} />)}
</div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.68rem' }}>{set.description}</div>
</div>
))}
</div>
</div>
{/* 핫/콜드/미출현 */}
{[
{ 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 }) => (
<div key={label} style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1rem' }}>
<div style={{ color, fontSize: '.75rem', fontWeight: 700, marginBottom: '.3rem' }}>{label}</div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.65rem', marginBottom: '.75rem' }}>{desc}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.35rem' }}>
{numbers.map(n => <SmallBall key={n} n={n} size={30} />)}
</div>
</div>
))}
{/* 최근 패턴 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1rem' }}>
<div style={{ color: 'rgba(255,255,255,.5)', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}>📊 </div>
{[
{ 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 }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '.4rem 0', borderBottom: '1px solid rgba(255,255,255,.05)' }}>
<span style={{ color: 'rgba(255,255,255,.3)', fontSize: '.7rem' }}>{label}</span>
<span style={{ color: '#fbbf24', fontSize: '.7rem', fontWeight: 700, fontFamily: "'JetBrains Mono',monospace" }}>{value}</span>
</div>
))}
{report.recent_pattern.triple_appear.length > 0 && (
<div style={{ marginTop: '.75rem' }}>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.65rem', marginBottom: '.4rem' }}> 3 </div>
<div style={{ display: 'flex', gap: '.3rem' }}>
{report.recent_pattern.triple_appear.map(n => <SmallBall key={n} n={n} size={28} />)}
</div>
</div>
)}
</div>
{/* 신뢰도 상세 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1rem' }}>
<div style={{ color: 'rgba(255,255,255,.5)', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}>🎯 </div>
<ConfidenceBar label="데이터 충분도" value={report.confidence_factors.data_volume} />
<ConfidenceBar label="패턴 안정성" value={report.confidence_factors.pattern_consistency} />
<ConfidenceBar label="최근 트렌드" value={report.confidence_factors.recent_trend} />
</div>
</div>
{/* 이전 리포트 목록 */}
{history.length > 0 && (
<div style={{ background: 'rgba(255,255,255,.02)', border: '1px solid rgba(255,255,255,.05)', borderRadius: '1rem', padding: '1rem' }}>
<div style={{ color: 'rgba(255,255,255,.4)', fontSize: '.7rem', fontWeight: 700, marginBottom: '.75rem', fontFamily: "'JetBrains Mono',monospace", letterSpacing: '.08em' }}>REPORT HISTORY</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.4rem' }}>
{history.map(h => (
<button key={h.drw_no}
style={{
background: h.drw_no === report.target_drw_no ? 'rgba(251,191,36,.15)' : 'rgba(255,255,255,.05)',
border: `1px solid ${h.drw_no === report.target_drw_no ? 'rgba(251,191,36,.4)' : 'rgba(255,255,255,.1)'}`,
color: h.drw_no === report.target_drw_no ? '#fbbf24' : 'rgba(255,255,255,.4)',
borderRadius: '.5rem', padding: '.3rem .65rem', fontSize: '.68rem', cursor: 'pointer',
}}>
{h.drw_no}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<Combo[]>([]);
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() {
</p>
</div>
{/* ── 성과 배너 ── */}
{perfStats && perfStats.total_checked > 0 && (
<div style={{ background: 'linear-gradient(135deg,rgba(74,222,128,.06),rgba(6,182,212,.06))', border: '1px solid rgba(74,222,128,.2)', borderRadius: '1rem', padding: '.85rem 1.25rem', marginBottom: '1.5rem', display: 'flex', flexWrap: 'wrap', gap: '1rem', alignItems: 'center', justifyContent: 'space-between', animation: 'slideIn .5s ease forwards' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.5rem' }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', background: '#4ade80', boxShadow: '0 0 8px rgba(74,222,128,.8)', animation: 'glowPulse 2s ease-in-out infinite', flexShrink: 0 }} />
<span style={{ color: '#4ade80', fontSize: '.72rem', fontWeight: 700 }}> </span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.5rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#4ade80', fontWeight: 900, fontSize: '1.1rem', fontFamily: "'JetBrains Mono',monospace" }}>{(perfStats.rate_3plus * 100).toFixed(1)}%</div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.6rem' }}>3 </div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#4ade80', fontWeight: 900, fontSize: '1.1rem', fontFamily: "'JetBrains Mono',monospace" }}>{perfStats.avg_correct.toFixed(1)}</div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.6rem' }}> </div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#06b6d4', fontWeight: 900, fontSize: '1.1rem', fontFamily: "'JetBrains Mono',monospace" }}>+{perfStats.vs_random.improvement_pct.toFixed(0)}%</div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.6rem' }}> </div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: 'rgba(255,255,255,.4)', fontWeight: 700, fontSize: '.95rem', fontFamily: "'JetBrains Mono',monospace" }}>{perfStats.total_checked.toLocaleString()}</div>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.6rem' }}> </div>
</div>
</div>
</div>
)}
{/* ── 탭 네비게이션 ── */}
{isSubscribed && (
<div style={{ display: 'flex', gap: '.3rem', marginBottom: '1.5rem', background: 'rgba(255,255,255,.04)', borderRadius: '.75rem', padding: '.3rem', flexWrap: 'wrap' }}>
{([
{ key: 'generate', label: '🎲 번호 생성' },
{ key: 'report', label: '📋 이번 주 공략' },
{ key: 'purchase', label: '💰 구매 기록' },
{ key: 'pattern', label: '🔍 내 패턴' },
] as const).map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
style={{
flex: 1, minWidth: 80,
background: activeTab === tab.key ? 'rgba(251,191,36,.15)' : 'transparent',
border: `1px solid ${activeTab === tab.key ? 'rgba(251,191,36,.35)' : 'transparent'}`,
color: activeTab === tab.key ? '#fbbf24' : 'rgba(255,255,255,.35)',
borderRadius: '.5rem', padding: '.5rem .5rem', fontSize: '.72rem', fontWeight: 700,
cursor: 'pointer', transition: 'all .2s ease', whiteSpace: 'nowrap',
}}>
{tab.label}
</button>
))}
</div>
)}
{/* ── 탭별 컨텐츠: 공략/구매/패턴 ── */}
{isSubscribed && activeTab === 'report' && <ReportTab />}
{isSubscribed && activeTab === 'purchase' && <PurchaseTab />}
{isSubscribed && activeTab === 'pattern' && <PatternTab />}
{/* ── 기존 메인 콘텐츠 (번호 생성 탭 or 비구독) ── */}
<div style={{ display: isSubscribed && activeTab !== 'generate' ? 'none' : 'block' }}>
{/* ── 통계 인디케이터 패널 (전체 공개) ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: '.75rem', marginBottom: '2rem' }}>
{[
@@ -1121,6 +1191,8 @@ export default function LottoRecommendPage() {
<p style={{ textAlign: 'center', color: 'rgba(255,255,255,.1)', fontSize: '.65rem', marginTop: '1.75rem', lineHeight: 1.7, fontFamily: "'JetBrains Mono',monospace" }}>
, .
</p>
</div>{/* ── 기존 메인 콘텐츠 래퍼 닫기 ── */}
</div>
</div>
</>