'use client'; import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { createClient } from '@/lib/supabase/client'; // ─── 클라이언트 Monte Carlo 폴백 ───────────────────────────────────────────── // NAS 서버가 응답하지 않을 때 브라우저에서 직접 실행하는 간단한 시뮬레이션 function clientMonteCarlo(): { numbers: number[]; metrics: { sum: number; odd: number; even: number; min: number; max: number; range: number } } { const SIMS = 5000; let best: number[] = []; let bestScore = -Infinity; for (let i = 0; i < SIMS; i++) { const nums = pickRandom6(); const score = scoreCombo(nums); if (score > bestScore) { bestScore = score; best = nums; } } const sorted = [...best].sort((a, b) => a - b); const sum = sorted.reduce((a, b) => a + b, 0); const odd = sorted.filter(n => n % 2 !== 0).length; return { numbers: sorted, metrics: { sum, odd, even: 6 - odd, min: sorted[0], max: sorted[5], range: sorted[5] - sorted[0] }, }; } function pickRandom6(): number[] { const pool = Array.from({ length: 45 }, (_, i) => i + 1); const result: number[] = []; while (result.length < 6) { const idx = Math.floor(Math.random() * pool.length); result.push(pool.splice(idx, 1)[0]); } return result; } function scoreCombo(nums: number[]): number { const sorted = [...nums].sort((a, b) => a - b); const sum = sorted.reduce((a, b) => a + b, 0); const odd = sorted.filter(n => n % 2 !== 0).length; // 합계 100~175 선호 (역대 평균 138) const sumScore = -Math.abs(sum - 138) / 35; // 홀짝 2~4개 선호 const oddScore = odd >= 2 && odd <= 4 ? 0.5 : -0.5; // 구간 분산 (1-9, 10-19, 20-29, 30-39, 40-45) const zones = new Set(sorted.map(n => Math.min(Math.floor((n - 1) / 10), 4))); const zoneScore = zones.size * 0.4; return sumScore + oddScore + zoneScore + Math.random() * 0.05; } // ─── Types ─────────────────────────────────────────────────────────────────── interface LottoMetrics { sum: number; odd: number; even: number; min: number; max: number; range: number; } interface RecommendResponse { ok: boolean; plan: string; numbers: number[]; metrics?: LottoMetrics; recent_overlap?: { repeated_numbers: number[] }; } interface BatchResponse { ok: boolean; plan: string; count: number; items: Array<{ numbers: number[]; metrics?: LottoMetrics }>; } interface NumberStat { number: number; frequency_pct: number; z_score: number; gap: number; } interface DashboardResponse { ok: boolean; plan: string; latest: { drawNo: number; date: string; numbers: number[]; bonus: number; metrics: LottoMetrics; } | null; analysis: { total_draws: number; mean_sum: number; number_stats: NumberStat[]; } | null; simulation: { runs: Array<{ id: number; run_at: string; strategy: string; total_generated: number; avg_score: number; }>; } | null; } interface Combo { id: number; numbers: number[]; metrics?: LottoMetrics; overlap?: number[]; createdAt: Date; } type GenMode = 'single' | 'batch'; const PLAN_LABELS: Record = { lotto_gold: '🥇 골드', lotto_platinum: '💎 플래티넘', lotto_diamond: '👑 다이아', }; // 다이아 플랜은 무제한 (사실상 999) const PLAN_MAX_COMBOS: Record = { lotto_gold: 1, lotto_platinum: 3, lotto_diamond: 999, }; // ─── Lotto Ball ─────────────────────────────────────────────────────────────── function getBallStyle(n: number): { bg: string; shadow: string; text: string } { if (n <= 10) return { bg: 'linear-gradient(145deg,#fde68a,#fbbf24,#d97706)', shadow: 'rgba(251,191,36,.6)', text: '#78350f' }; if (n <= 20) return { bg: 'linear-gradient(145deg,#93c5fd,#3b82f6,#1d4ed8)', shadow: 'rgba(59,130,246,.6)', text: '#fff' }; if (n <= 30) return { bg: 'linear-gradient(145deg,#fca5a5,#ef4444,#b91c1c)', shadow: 'rgba(239,68,68,.6)', text: '#fff' }; if (n <= 40) return { bg: 'linear-gradient(145deg,#d1d5db,#9ca3af,#4b5563)', shadow: 'rgba(107,114,128,.6)', text: '#fff' }; return { bg: 'linear-gradient(145deg,#86efac,#22c55e,#15803d)', shadow: 'rgba(34,197,94,.6)', text: '#fff' }; } function LottoBall({ n, size = 52, delay = 0, bounce = false, highlight = false }: { n: number; size?: number; delay?: number; bounce?: boolean; highlight?: boolean; }) { const [show, setShow] = useState(!bounce); const { bg, shadow, text } = getBallStyle(n); useEffect(() => { if (!bounce) return; const t = setTimeout(() => setShow(true), delay); return () => clearTimeout(t); }, [bounce, delay]); return (
{n}
); } function SpinBall({ n, delay = 0 }: { n: number; delay?: number }) { const { bg, shadow, text } = getBallStyle(n); return (
{n}
); } // ─── Main Page ──────────────────────────────────────────────────────────────── export default function LottoRecommendPage() { const supabase = createClient(); // 구독 상태 const [isSubscribed, setIsSubscribed] = useState(false); const [plan, setPlan] = useState(''); const [dashboard, setDashboard] = useState(null); const [pageReady, setPageReady] = useState(false); // 무료 맛보기 const [previewNumbers, setPreviewNumbers] = useState([]); const [previewMetrics, setPreviewMetrics] = useState(null); const [previewState, setPreviewState] = useState<'idle' | 'loading' | 'result' | 'error'>('idle'); const [previewUsed, setPreviewUsed] = useState(false); const [previewSource, setPreviewSource] = useState<'nas' | 'client'>('client'); // 프리미엄 생성 const [genMode, setGenMode] = useState('single'); const [combos, setCombos] = useState([]); const [proState, setProState] = useState<'idle' | 'loading' | 'result' | 'error'>('idle'); const [proError, setProError] = useState(''); const idRef = useRef(0); // 플랜별 최대 조합 수 (plan 상태가 확정된 후 계산) const MAX_COMBOS = PLAN_MAX_COMBOS[plan] ?? 5; const SPIN_NUMS = [7, 23, 41, 14, 35, 3]; // ── 초기화: 인증 + 대시보드 ── useEffect(() => { async function init() { const { data: { user } } = await supabase.auth.getUser(); if (user) { try { const res = await fetch('/api/lotto/dashboard'); if (res.ok) { const data: DashboardResponse = await res.json(); setDashboard(data); setPlan(data.plan ?? ''); setIsSubscribed(true); } // 403 = 미구독 (pageReady는 true) } catch { /* ignore */ } } setPageReady(true); } init(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── 무료 맛보기 생성 ── const handlePreview = async () => { if (previewState === 'loading') return; setPreviewState('loading'); try { // 1) NAS API 시도 const res = await fetch('/api/lotto/preview'); if (res.ok) { const data = await res.json(); setPreviewNumbers([...data.numbers].sort((a, b) => a - b)); setPreviewMetrics(data.metrics ?? null); setPreviewSource('nas'); } else { // 2) NAS 불가 → 클라이언트 Monte Carlo 폴백 const { numbers, metrics } = clientMonteCarlo(); setPreviewNumbers(numbers); setPreviewMetrics(metrics); setPreviewSource('client'); } setPreviewState('result'); setPreviewUsed(true); } catch { // 네트워크 자체 오류도 클라이언트 폴백 try { const { numbers, metrics } = clientMonteCarlo(); setPreviewNumbers(numbers); setPreviewMetrics(metrics); setPreviewSource('client'); setPreviewState('result'); setPreviewUsed(true); } catch { setPreviewState('error'); } } }; // ── 히스토리 저장 (fire-and-forget) ── const saveHistory = (numbers: number[], source: 'nas' | 'client') => { if (!plan) return; fetch('/api/lotto/history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ numbers, source, plan_id: plan }), }).catch(() => {/* 저장 실패는 조용히 무시 */}); }; // ── 프리미엄 번호 생성 ── const handleGenerate = async () => { if (proState === 'loading' || combos.length >= MAX_COMBOS) return; setProState('loading'); setProError(''); try { const url = genMode === 'batch' ? '/api/lotto/recommend?mode=batch' : '/api/lotto/recommend?mode=single'; const res = await fetch(url); if (res.status === 403) { setIsSubscribed(false); setProState('idle'); return; } // NAS 불가 시 클라이언트 Monte Carlo 폴백 if (res.status === 503) { const count = genMode === 'batch' ? Math.min(5, MAX_COMBOS - combos.length) : 1; const newCombos: Combo[] = Array.from({ length: count }, () => { idRef.current += 1; const { numbers, metrics } = clientMonteCarlo(); saveHistory(numbers, 'client'); return { id: idRef.current, numbers, metrics, createdAt: new Date() }; }); setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS)); setProState('result'); return; } if (!res.ok) { const e = await res.json(); throw new Error(e.error ?? 'API_ERROR'); } if (genMode === 'batch') { const data: BatchResponse = await res.json(); const newCombos: Combo[] = (data.items ?? []).map((item) => { idRef.current += 1; const numbers = [...item.numbers].sort((a,b)=>a-b); saveHistory(numbers, 'nas'); return { id: idRef.current, numbers, metrics: item.metrics, createdAt: new Date() }; }); setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS)); } else { const data: RecommendResponse = await res.json(); if (!data.numbers?.length) throw new Error('EMPTY_RESULT'); idRef.current += 1; const numbers = [...data.numbers].sort((a,b)=>a-b); saveHistory(numbers, 'nas'); setCombos((prev) => [...prev, { id: idRef.current, numbers, metrics: data.metrics, overlap: data.recent_overlap?.repeated_numbers, createdAt: new Date(), }]); } setProState('result'); } catch (err: unknown) { const e = err as { message?: string }; setProError(e?.message === 'NAS_TIMEOUT' ? 'NAS 서버 응답 시간 초과.' : '생성 중 오류가 발생했습니다.'); setProState('error'); } }; const clearCombos = () => { setCombos([]); setProState('idle'); setProError(''); }; // 핫/콜드 계산 const hotNumbers = dashboard?.analysis?.number_stats ?.filter(s => s.z_score > 0.3) .sort((a,b) => b.z_score - a.z_score) .slice(0, 8) .map(s => s.number) ?? []; const coldNumbers = dashboard?.analysis?.number_stats ?.filter(s => s.z_score < -0.3) .sort((a,b) => b.gap - a.gap) .slice(0, 8) .map(s => s.number) ?? []; const latestRun = dashboard?.simulation?.runs?.[0]; const totalDraws = dashboard?.analysis?.total_draws; const isProLoading = proState === 'loading'; const isMaxed = combos.length >= MAX_COMBOS; const latestCombo = combos.length > 0 ? combos[combos.length - 1] : null; if (!pageReady) { return (
); } return ( <>
{/* ambient orbs */}
{/* ── Header ── */}
← 로또 서비스로
Monte Carlo Simulation · 로또 번호 추천

이번 주 로또
번호 추천

{isSubscribed && plan && (
{PLAN_LABELS[plan] ?? plan} 구독 중
)}
{/* ── 최신 당첨번호 (구독자에게만) ── */} {isSubscribed && dashboard?.latest && (
최신 당첨번호
제{dashboard.latest.drawNo}회 · {dashboard.latest.date}
{dashboard.latest.numbers.map((n,i) => )} +
{[{l:'합계',v:dashboard.latest.metrics.sum},{l:'홀수',v:`${dashboard.latest.metrics.odd}개`},{l:'짝수',v:`${dashboard.latest.metrics.even}개`}].map(s=>(
{s.v}
{s.l}
))}
)} {/* ════════════════════════════════════════════════ 무료 맛보기 섹션 (모든 사용자) ════════════════════════════════════════════════ */}
{/* 섹션 라벨 */}
무료 맛보기
1회 무료 번호 추천
{/* 번호 표시 영역 */}
{previewState === 'loading' ? ( SPIN_NUMS.slice(0,6).map((n,i) => ) ) : previewState === 'result' && previewNumbers.length > 0 ? ( previewNumbers.map((n,i) => ) ) : ( Array.from({length:6},(_,i)=>(
?
)) )}
{/* 맛보기 메트릭 */} {previewState === 'result' && previewMetrics && (
{[{l:'합계',v:previewMetrics.sum},{l:'홀수',v:`${previewMetrics.odd}개`},{l:'짝수',v:`${previewMetrics.even}개`},{l:'범위',v:previewMetrics.range}].map(s=>(
{s.v}
{s.l}
))}
{/* 출처 표시 */}
{previewSource==='nas' ? 'NAS Monte Carlo 시뮬레이션' : '브라우저 간이 시뮬레이션 (5,000회)'}
)} {previewState === 'error' && (

⚠️ 번호 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.

)} {/* 버튼 */} {!previewUsed ? ( ) : (
✓ 오늘의 무료 번호 생성 완료
{!isSubscribed && (

더 많은 번호와 분석이 필요하다면 아래 구독 플랜을 이용해보세요 ↓

)}
)}
{/* ════════════════════════════════════════════════ 프리미엄 구독 섹션 (블러 게이트) ════════════════════════════════════════════════ */}
{/* 섹션 라벨 */}
구독자 전용
{isSubscribed ? '프리미엄 번호 추천' : '구독 시 제공되는 기능 미리보기'}
{/* 프리미엄 컨텐츠 (블러 or 실제) */}
{/* 생성 카드 */}
{/* 스탯 */}
{[ {icon:'⚡',val:latestRun?`${(latestRun.total_generated/10000).toFixed(0)}만 회`:'10만 회',label:'시뮬레이션'}, {icon:'📊',val:totalDraws?`${totalDraws.toLocaleString()}회`:'1,130+',label:'분석 회차'}, {icon:'🎯',val:`${combos.length} / ${MAX_COMBOS}`,label:'생성 조합'}, ].map(s=>(
{s.icon}
{s.val}
{s.label}
))}
{/* 모드 탭 */}
{(['single','batch'] as const).map(mode=>( ))}
{/* 볼 표시 */}
{isProLoading ? ( SPIN_NUMS.map((n,i)=>) ) : latestCombo ? ( latestCombo.numbers.map((n,i)=>) ) : ( Array.from({length:6},(_,i)=>(
?
)) )}
{/* 메트릭 */} {latestCombo?.metrics && !isProLoading && (
{[{l:'합계',v:latestCombo.metrics.sum},{l:'홀수',v:`${latestCombo.metrics.odd}개`},{l:'짝수',v:`${latestCombo.metrics.even}개`},{l:'범위',v:latestCombo.metrics.range}].map(s=>(
{s.v}
{s.l}
))}
)} {isProLoading && (

{genMode==='batch'?`${Math.min(5, MAX_COMBOS - combos.length)}개 번호 조합을 배치 생성 중...`:'몬테카를로 시뮬레이션으로 최적 번호를 계산 중...'}

)} {proState === 'error' && (

⚠️ {proError}

)} {/* 생성 버튼 */} {isMaxed && (
)}
{/* 생성된 조합 목록 */} {combos.length > 0 && (

생성된 번호 조합

{combos.length>1&&}
{combos.map((c,idx)=>{ const isLatest=idx===combos.length-1; return (
{idx+1}
{c.numbers.map((n,ni)=>)}
{c.metrics&&합 {c.metrics.sum} · 홀 {c.metrics.odd}}
{c.createdAt.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit',second:'2-digit'})}
); })}
)} {/* 핫/콜드 */} {(hotNumbers.length>0||coldNumbers.length>0) && (
{hotNumbers.length>0&&(
Hot Numbers · 통계적 과출현
{hotNumbers.map(n=>)}
)} {coldNumbers.length>0&&(
Cold Numbers · 오래 미출현
{coldNumbers.map(n=>)}
)}
)} {/* 시뮬레이션 정보 */} {latestRun&&(
최신 시뮬레이션 ({latestRun.strategy})
{[{l:'생성 조합',v:`${latestRun.total_generated.toLocaleString()}개`},{l:'평균 점수',v:latestRun.avg_score.toFixed(4)},{l:'실행',v:new Date(latestRun.run_at).toLocaleString('ko-KR',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}].map(s=>(
{s.l}: {s.v}
))}
)}
{/* ── 비구독자 잠금 오버레이 ── */} {!isSubscribed && (
{/* 자물쇠 아이콘 */}

구독하면 더 많이 받을 수 있어요

골드 주 1회 · 플래티넘 주 3회 · 다이아 무제한
핫/콜드 번호 분석 · 시뮬레이션 통계 · 연간 패턴 리포트

구독 플랜 보기 → 로그인
)}
{/* ── Color Legend ── */}
번호 색상 {[{r:'1–10',c:'#fbbf24'},{r:'11–20',c:'#3b82f6'},{r:'21–30',c:'#ef4444'},{r:'31–40',c:'#9ca3af'},{r:'41–45',c:'#22c55e'}].map(item=>(
{item.r}
))}

본 서비스는 몬테카를로 시뮬레이션 기반 통계 분석으로, 당첨을 보장하지 않습니다.

); }