diff --git a/app/api/lotto/history/route.ts b/app/api/lotto/history/route.ts new file mode 100644 index 0000000..b8ebf57 --- /dev/null +++ b/app/api/lotto/history/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +/** + * POST /api/lotto/history + * 생성된 로또 번호 조합을 히스토리에 저장 + * Body: { numbers: number[], source: 'nas' | 'client', plan_id: string } + * + * GET /api/lotto/history + * 내 로또 번호 히스토리 조회 + * Query: limit (기본 50) + */ + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + } + + let body: { numbers?: number[]; source?: string; plan_id?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 }); + } + + const { numbers, source = 'client', plan_id } = body; + if (!Array.isArray(numbers) || numbers.length !== 6 || !plan_id) { + return NextResponse.json({ error: 'INVALID_BODY' }, { status: 400 }); + } + + const { error } = await supabase.from('lotto_history').insert({ + user_id: user.id, + numbers, + source, + plan_id, + }); + + if (error) { + console.error('lotto_history insert error:', error); + return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 }); + } + + return NextResponse.json({ ok: true }); +} + +export async function GET(req: NextRequest) { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + } + + const limit = Math.min(Number(req.nextUrl.searchParams.get('limit') ?? '50'), 200); + + const { data, error } = await supabase + .from('lotto_history') + .select('id, numbers, source, plan_id, created_at') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .limit(limit); + + if (error) { + return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 }); + } + + return NextResponse.json({ ok: true, history: data ?? [] }); +} diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index 68a0a8d..a39d307 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) { return url; } -type Tab = 'profile' | 'saju' | 'payments' | 'orders'; +type Tab = 'profile' | 'saju' | 'lotto' | 'payments' | 'orders'; type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting'; interface SajuRecord { @@ -48,6 +48,26 @@ interface Order { status: string; } +interface LottoHistoryItem { + id: number; + numbers: number[]; + source: string; + plan_id: string; + created_at: string; +} + +interface ActiveSubscription { + product_id: string; + created_at: string; + expires_at: string; +} + +const PLAN_LABELS: Record = { + lotto_gold: { label: '골드', emoji: '🥇', color: 'amber' }, + lotto_platinum: { label: '플래티넘', emoji: '💎', color: 'sky' }, + lotto_diamond: { label: '다이아', emoji: '👑', color: 'violet' }, +}; + export default function MyPage() { const router = useRouter(); const supabase = createClient(); @@ -57,6 +77,8 @@ export default function MyPage() { const [sajuRecords, setSajuRecords] = useState([]); const [payments, setPayments] = useState([]); const [orders, setOrders] = useState([]); + const [lottoHistory, setLottoHistory] = useState([]); + const [activeSubscriptions, setActiveSubscriptions] = useState([]); // 텔레그램 연동 상태 const [telegramChatId, setTelegramChatId] = useState(null); @@ -109,6 +131,35 @@ export default function MyPage() { .maybeSingle(); setTelegramChatId(profile?.telegram_chat_id ?? null); + // 활성 구독 조회 (paid 상태의 lotto 플랜) + const LOTTO_PLANS = ['lotto_gold', 'lotto_platinum', 'lotto_diamond']; + const { data: subs } = await supabase + .from('orders') + .select('product_id, created_at') + .eq('user_id', user.id) + .eq('status', 'paid') + .in('product_id', LOTTO_PLANS) + .order('created_at', { ascending: false }); + + if (subs && subs.length > 0) { + const activeSubs: ActiveSubscription[] = subs.map((s) => { + const createdAt = new Date(s.created_at); + const expiresAt = new Date(createdAt); + expiresAt.setDate(expiresAt.getDate() + 31); + return { product_id: s.product_id, created_at: s.created_at, expires_at: expiresAt.toISOString() }; + }); + setActiveSubscriptions(activeSubs); + } + + // 로또 히스토리 조회 + const { data: history } = await supabase + .from('lotto_history') + .select('id, numbers, source, plan_id, created_at') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .limit(50); + setLottoHistory(history ?? []); + setLoading(false); } init(); @@ -178,6 +229,7 @@ export default function MyPage() { const tabs: { key: Tab; label: string; count?: number }[] = [ { key: 'profile', label: '내 정보' }, { key: 'saju', label: '사주 기록', count: sajuRecords.length }, + { key: 'lotto', label: '🎰 로또 기록', count: lottoHistory.length }, { key: 'payments', label: '결제 내역', count: payments.length }, { key: 'orders', label: '의뢰 내역', count: orders.length }, ]; @@ -269,6 +321,58 @@ export default function MyPage() { + {/* 구독 중인 서비스 */} + {activeSubscriptions.length > 0 && ( +
+

+
+ 구독 중인 서비스 +

+
+ {activeSubscriptions.map((sub) => { + const info = PLAN_LABELS[sub.product_id]; + const expiresDate = new Date(sub.expires_at); + const daysLeft = Math.max(0, Math.ceil((expiresDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24))); + const isExpired = daysLeft === 0; + return ( +
+
+ {info?.emoji ?? '🎟'} +
+
+ 로또 번호 추천 {info?.label ?? sub.product_id} +
+
+ {new Date(sub.created_at).toLocaleDateString('ko-KR')} 구독 시작 +
+
+
+
+ {isExpired ? ( + 만료됨 + ) : ( + <> +
+ D-{daysLeft} +
+
{expiresDate.toLocaleDateString('ko-KR')} 만료
+ + )} +
+
+ ); + })} +
+ +
+ )} + {/* 텔레그램 연동 카드 */}

@@ -416,6 +520,56 @@ export default function MyPage() {

)} + {/* 로또 번호 기록 */} + {tab === 'lotto' && ( +
+ {lottoHistory.length === 0 ? ( + + ) : ( +
+
총 {lottoHistory.length}개 조합 생성
+ {lottoHistory.map((item) => { + const info = PLAN_LABELS[item.plan_id]; + return ( +
+
+
+ {item.numbers.map((n) => { + const color = + n <= 10 ? 'bg-yellow-400 text-yellow-900' : + n <= 20 ? 'bg-blue-500 text-white' : + n <= 30 ? 'bg-red-500 text-white' : + n <= 40 ? 'bg-slate-500 text-white' : + 'bg-green-500 text-white'; + return ( + + {n} + + ); + })} +
+
+
+ + {item.source === 'nas' ? 'NAS 추천' : '로컬 생성'} + + {info?.emoji} {info?.label} + {new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+
+ ); + })} +
+ )} +
+ )} + {/* 사주 기록 */} {tab === 'saju' && (
diff --git a/app/services/lotto/recommend/page.tsx b/app/services/lotto/recommend/page.tsx index 5c5f9d5..6a9e68e 100644 --- a/app/services/lotto/recommend/page.tsx +++ b/app/services/lotto/recommend/page.tsx @@ -274,6 +274,16 @@ export default function LottoRecommendPage() { } }; + // ── 히스토리 저장 (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; @@ -292,6 +302,7 @@ export default function LottoRecommendPage() { 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)); @@ -305,16 +316,20 @@ export default function LottoRecommendPage() { const data: BatchResponse = await res.json(); const newCombos: Combo[] = (data.items ?? []).map((item) => { idRef.current += 1; - return { id: idRef.current, numbers: [...item.numbers].sort((a,b)=>a-b), metrics: item.metrics, createdAt: new Date() }; + 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: [...data.numbers].sort((a,b)=>a-b), + numbers, metrics: data.metrics, overlap: data.recent_overlap?.repeated_numbers, createdAt: new Date(), @@ -583,7 +598,7 @@ export default function LottoRecommendPage() { {/* 메트릭 */} {latestCombo?.metrics && !isProLoading && (
- {[{l:'홀수',v:`${latestCombo.metrics.odd}개`},{l:'짝수',v:`${latestCombo.metrics.even}개`},{l:'범위',v:latestCombo.metrics.range}].map(s=>( + {[{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}
@@ -637,7 +652,7 @@ export default function LottoRecommendPage() {
- {c.metrics&&홀 {c.metrics.odd} · 짝 {c.metrics.even}} + {c.metrics&&합 {c.metrics.sum} · 홀 {c.metrics.odd}}
{c.createdAt.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit',second:'2-digit'})}
diff --git a/supabase/migrations/003_lotto_history.sql b/supabase/migrations/003_lotto_history.sql new file mode 100644 index 0000000..09e7eaf --- /dev/null +++ b/supabase/migrations/003_lotto_history.sql @@ -0,0 +1,21 @@ +-- ─── 로또 번호 히스토리 테이블 ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS lotto_history ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + numbers integer[] NOT NULL, + source text NOT NULL DEFAULT 'client', -- 'nas' | 'client' + plan_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- RLS +ALTER TABLE lotto_history ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "lotto_history_select_own" ON lotto_history + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "lotto_history_insert_own" ON lotto_history + FOR INSERT WITH CHECK (auth.uid() = user_id); + +-- 인덱스 +CREATE INDEX IF NOT EXISTS lotto_history_user_created ON lotto_history (user_id, created_at DESC);