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:
236
app/services/lotto/recommend/ReportTab.tsx
Normal file
236
app/services/lotto/recommend/ReportTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user