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

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