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:
86
app/api/lotto/_nas.ts
Normal file
86
app/api/lotto/_nas.ts
Normal 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 });
|
||||
}
|
||||
11
app/api/lotto/analysis/personal/route.ts
Normal file
11
app/api/lotto/analysis/personal/route.ts
Normal 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); }
|
||||
}
|
||||
23
app/api/lotto/purchase/[id]/route.ts
Normal file
23
app/api/lotto/purchase/[id]/route.ts
Normal 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); }
|
||||
}
|
||||
26
app/api/lotto/purchase/route.ts
Normal file
26
app/api/lotto/purchase/route.ts
Normal 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); }
|
||||
}
|
||||
11
app/api/lotto/purchase/stats/route.ts
Normal file
11
app/api/lotto/purchase/stats/route.ts
Normal 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); }
|
||||
}
|
||||
13
app/api/lotto/report/history/route.ts
Normal file
13
app/api/lotto/report/history/route.ts
Normal 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); }
|
||||
}
|
||||
11
app/api/lotto/report/latest/route.ts
Normal file
11
app/api/lotto/report/latest/route.ts
Normal 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); }
|
||||
}
|
||||
13
app/api/lotto/stats/performance/route.ts
Normal file
13
app/api/lotto/stats/performance/route.ts
Normal 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); }
|
||||
}
|
||||
181
app/services/lotto/recommend/PatternTab.tsx
Normal file
181
app/services/lotto/recommend/PatternTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
app/services/lotto/recommend/PurchaseTab.tsx
Normal file
254
app/services/lotto/recommend/PurchaseTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user